From cf3bf45c3131b0d4461a39ec32af6e1bd5309dfe Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 10 May 2026 16:25:44 +0700 Subject: [PATCH 01/11] Extract permission response command --- .../server/src/server/agent/agent-prompt.ts | 7 +- .../server/src/server/agent/mcp-server.ts | 9 +- .../server/agent/permission-response.test.ts | 138 ++++++++++++++++++ .../src/server/agent/permission-response.ts | 40 +++++ packages/server/src/server/session.ts | 70 +-------- 5 files changed, 200 insertions(+), 64 deletions(-) create mode 100644 packages/server/src/server/agent/permission-response.test.ts create mode 100644 packages/server/src/server/agent/permission-response.ts diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index 2c4a7c016f..492e4ffd8e 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -5,13 +5,18 @@ import type { AgentManager, ManagedAgent } from "./agent-manager.js"; import type { AgentStorage } from "./agent-storage.js"; import { ensureAgentLoaded } from "./agent-loading.js"; +export type AgentRunController = Pick< + AgentManager, + "getAgent" | "tryRunOutOfBand" | "hasInFlightRun" | "replaceAgentRun" | "streamAgent" +>; + export interface StartAgentRunOptions { replaceRunning?: boolean; runOptions?: AgentRunOptions; } export function startAgentRun( - agentManager: AgentManager, + agentManager: AgentRunController, agentId: string, prompt: AgentPromptInput, logger: Logger, diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index a8f334c195..2f54ebb245 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -80,6 +80,7 @@ import { setupFinishNotification, startCreatedAgentInitialPrompt, } from "./agent-prompt.js"; +import { respondToAgentPermission } from "./permission-response.js"; import type { GitHubService } from "../../services/github-service.js"; import type { WorkspaceGitService } from "../workspace-git-service.js"; import type { CreatePaseoWorktreeInput } from "../paseo-worktree-service.js"; @@ -2624,7 +2625,13 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, }, async ({ agentId, requestId, response }) => { - await agentManager.respondToPermission(agentId, requestId, response); + await respondToAgentPermission({ + agentManager, + agentId, + requestId, + response, + logger: childLogger, + }); return { content: [], structuredContent: ensureValidJson({ success: true }), diff --git a/packages/server/src/server/agent/permission-response.test.ts b/packages/server/src/server/agent/permission-response.test.ts new file mode 100644 index 0000000000..380f2f6dbe --- /dev/null +++ b/packages/server/src/server/agent/permission-response.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, test } from "vitest"; + +import { createTestLogger } from "../../test-utils/test-logger.js"; +import type { + AgentPromptInput, + AgentPermissionResult, + AgentRunOptions, + AgentPermissionResponse, +} from "./agent-sdk-types.js"; +import type { AgentStreamEvent } from "../messages.js"; +import { respondToAgentPermission } from "./permission-response.js"; + +class FakePermissionAgentManager { + permissionResult: AgentPermissionResult | void; + hasRunInFlight = false; + outOfBandHandled = false; + permissionResponses: Array<{ + agentId: string; + requestId: string; + response: AgentPermissionResponse; + }> = []; + streamRuns: Array<{ agentId: string; prompt: AgentPromptInput; options?: AgentRunOptions }> = []; + replacementRuns: Array<{ agentId: string; prompt: AgentPromptInput; options?: AgentRunOptions }> = + []; + + async respondToPermission( + agentId: string, + requestId: string, + response: AgentPermissionResponse, + ): Promise { + this.permissionResponses.push({ agentId, requestId, response }); + return this.permissionResult; + } + + tryRunOutOfBand(): boolean { + return this.outOfBandHandled; + } + + hasInFlightRun(): boolean { + return this.hasRunInFlight; + } + + streamAgent( + agentId: string, + prompt: AgentPromptInput, + options?: AgentRunOptions, + ): AsyncGenerator { + this.streamRuns.push({ agentId, prompt, options }); + return emptyAgentStream(); + } + + replaceAgentRun( + agentId: string, + prompt: AgentPromptInput, + options?: AgentRunOptions, + ): AsyncGenerator { + this.replacementRuns.push({ agentId, prompt, options }); + return emptyAgentStream(); + } +} + +async function* emptyAgentStream(): AsyncGenerator {} + +describe("respondToAgentPermission", () => { + const logger = createTestLogger(); + + test("starts a follow-up run returned by the provider permission response", async () => { + const agentManager = new FakePermissionAgentManager(); + agentManager.permissionResult = { followUpPrompt: "implement the approved plan" }; + + await respondToAgentPermission({ + agentManager, + agentId: "agent-1", + requestId: "permission-1", + response: { behavior: "allow" }, + logger, + }); + + expect(agentManager.permissionResponses).toEqual([ + { + agentId: "agent-1", + requestId: "permission-1", + response: { behavior: "allow" }, + }, + ]); + expect(agentManager.streamRuns).toEqual([ + { + agentId: "agent-1", + prompt: "implement the approved plan", + }, + ]); + expect(agentManager.replacementRuns).toEqual([]); + }); + + test("does not start a run when the permission response has no follow-up prompt", async () => { + const agentManager = new FakePermissionAgentManager(); + + await respondToAgentPermission({ + agentManager, + agentId: "agent-1", + requestId: "permission-1", + response: { behavior: "deny", message: "not now" }, + logger, + }); + + expect(agentManager.permissionResponses).toEqual([ + { + agentId: "agent-1", + requestId: "permission-1", + response: { behavior: "deny", message: "not now" }, + }, + ]); + expect(agentManager.streamRuns).toEqual([]); + expect(agentManager.replacementRuns).toEqual([]); + }); + + test("replaces an in-flight run for follow-up prompts", async () => { + const agentManager = new FakePermissionAgentManager(); + agentManager.hasRunInFlight = true; + agentManager.permissionResult = { followUpPrompt: "continue after approval" }; + + await respondToAgentPermission({ + agentManager, + agentId: "agent-1", + requestId: "permission-1", + response: { behavior: "allow" }, + logger, + }); + + expect(agentManager.streamRuns).toEqual([]); + expect(agentManager.replacementRuns).toEqual([ + { + agentId: "agent-1", + prompt: "continue after approval", + }, + ]); + }); +}); diff --git a/packages/server/src/server/agent/permission-response.ts b/packages/server/src/server/agent/permission-response.ts new file mode 100644 index 0000000000..5d473e1287 --- /dev/null +++ b/packages/server/src/server/agent/permission-response.ts @@ -0,0 +1,40 @@ +import type { Logger } from "pino"; + +import type { AgentPermissionResponse, AgentPermissionResult } from "./agent-sdk-types.js"; +import { startAgentRun, type AgentRunController } from "./agent-prompt.js"; + +export interface PermissionResponseAgentManager extends AgentRunController { + respondToPermission( + agentId: string, + requestId: string, + response: AgentPermissionResponse, + ): Promise; +} + +export interface RespondToAgentPermissionParams { + agentManager: PermissionResponseAgentManager; + agentId: string; + requestId: string; + response: AgentPermissionResponse; + logger: Logger; +} + +export async function respondToAgentPermission( + params: RespondToAgentPermissionParams, +): Promise { + const { agentManager, agentId, requestId, response, logger } = params; + logger.debug( + { agentId, requestId }, + `Handling permission response for agent ${agentId}, request ${requestId}`, + ); + + const result = await agentManager.respondToPermission(agentId, requestId, response); + logger.debug({ agentId }, `Permission response forwarded to agent ${agentId}`); + + if (result?.followUpPrompt) { + logger.debug({ agentId }, "Permission response requires follow-up turn, starting agent stream"); + startAgentRun(agentManager, agentId, result.followUpPrompt, logger, { + replaceRunning: true, + }); + } +} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index d5d88c64ce..bdbb17c869 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -72,6 +72,7 @@ import { waitForAgentRunStartWithTimeout, unarchiveAgentState, } from "./agent/agent-prompt.js"; +import { respondToAgentPermission } from "./agent/permission-response.js"; import { experimental_createMCPClient } from "ai"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js"; @@ -138,7 +139,6 @@ import { type AgentPromptInput, type AgentRunOptions, type AgentSessionConfig, - type AgentStreamEvent, type ProviderSnapshotEntry, } from "./agent/agent-sdk-types.js"; import type { StoredAgentRecord } from "./agent/agent-storage.js"; @@ -1170,52 +1170,6 @@ export class Session { return this.agentManager.hasInFlightRun(agentId); } - /** - * Start streaming an agent run and forward results via the websocket broadcast - */ - private startAgentStream( - agentId: string, - prompt: AgentPromptInput, - runOptions?: AgentRunOptions, - ): { ok: true } | { ok: false; error: string } { - this.sessionLogger.trace( - { - agentId, - promptType: typeof prompt === "string" ? "string" : "structured", - hasRunOptions: Boolean(runOptions), - }, - "agent.session.start_stream.request", - ); - let iterator: AsyncGenerator; - try { - const shouldReplace = this.agentManager.hasInFlightRun(agentId); - iterator = shouldReplace - ? this.agentManager.replaceAgentRun(agentId, prompt, runOptions) - : this.agentManager.streamAgent(agentId, prompt, runOptions); - this.sessionLogger.trace( - { agentId, shouldReplace }, - "agent.session.start_stream.iterator_returned", - ); - } catch (error) { - this.handleAgentRunError(agentId, error, "Failed to start agent run"); - return { ok: false, error: errorToFriendlyMessage(error) }; - } - - void (async () => { - try { - for await (const _ of iterator) { - // Events are forwarded via the session's AgentManager subscription. - } - this.sessionLogger.trace({ agentId }, "agent.session.iterator.drained"); - } catch (error) { - this.sessionLogger.trace({ agentId, err: error }, "agent.session.iterator.error"); - this.handleAgentRunError(agentId, error, "Agent stream failed"); - } - })(); - - return { ok: true }; - } - private handleAgentRunError(agentId: string, error: unknown, context: string): void { const message = errorToFriendlyMessage(error); this.sessionLogger.error({ err: error, agentId, context }, `${context} for agent ${agentId}`); @@ -4732,22 +4686,14 @@ export class Session { requestId: string, response: AgentPermissionResponse, ): Promise { - this.sessionLogger.debug( - { agentId, requestId }, - `Handling permission response for agent ${agentId}, request ${requestId}`, - ); - try { - const result = await this.agentManager.respondToPermission(agentId, requestId, response); - this.sessionLogger.debug({ agentId }, `Permission response forwarded to agent ${agentId}`); - - if (result?.followUpPrompt) { - this.sessionLogger.debug( - { agentId }, - "Permission response requires follow-up turn, starting agent stream", - ); - this.startAgentStream(agentId, result.followUpPrompt); - } + await respondToAgentPermission({ + agentManager: this.agentManager, + agentId, + requestId, + response, + logger: this.sessionLogger, + }); } catch (error) { this.sessionLogger.error( { err: error, agentId, requestId }, From d8747b0efa61b93f767dd08f2c892fc13e84c319 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 10 May 2026 16:40:27 +0700 Subject: [PATCH 02/11] Extract create agent command --- .../src/server/agent/create-agent/create.ts | 615 ++++++++++++++++++ .../server/src/server/agent/mcp-server.ts | 420 +++--------- packages/server/src/server/session.ts | 124 ++-- 3 files changed, 738 insertions(+), 421 deletions(-) create mode 100644 packages/server/src/server/agent/create-agent/create.ts diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts new file mode 100644 index 0000000000..424a3c6207 --- /dev/null +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -0,0 +1,615 @@ +import type { Logger } from "pino"; + +import { PARENT_AGENT_ID_LABEL } from "../../../shared/agent-labels.js"; +import type { TerminalManager } from "../../../terminal/terminal-manager.js"; +import type { CreatePaseoWorktreeInput } from "../../paseo-worktree-service.js"; +import { expandUserPath, resolvePathFromBase } from "../../path-utils.js"; +import { toWorktreeRequestError } from "../../worktree-errors.js"; +import type { WorkspaceGitService } from "../../workspace-git-service.js"; +import type { + AgentWorktreeSetupContinuation, + CreatePaseoWorktreeSetupContinuationInput, + CreatePaseoWorktreeWorkflowFn, + CreatePaseoWorktreeWorkflowResult, +} from "../../worktree-session.js"; +import type { AgentAttachment, FirstAgentContext, GitSetupOptions } from "../../messages.js"; +import type { AgentManager, ManagedAgent } from "../agent-manager.js"; +import { scheduleAgentMetadataGeneration } from "../agent-metadata-generator.js"; +import type { + AgentProvider, + AgentPromptContentBlock, + AgentPromptInput, + AgentRunOptions, + AgentSessionConfig, +} from "../agent-sdk-types.js"; +import type { AgentStorage } from "../agent-storage.js"; +import { getAgentProviderDefinition } from "../provider-manifest.js"; +import type { ProviderDefinition } from "../provider-registry.js"; +import { + setupFinishNotification, + startCreatedAgentInitialPrompt, +} from "../agent-prompt.js"; +import { resolveAndValidateCreateAgentMode } from "../create-agent-mode.js"; +import { resolveClientMessageId } from "../../client-message-id.js"; +import { resolveRequiredProviderModel } from "../mcp-shared.js"; +import { + appendTimelineItemIfAgentKnown, + emitLiveTimelineItemIfAgentKnown, +} from "../timeline-append.js"; + +const OPENCODE_PROVIDER_ID = "opencode"; +const OPENCODE_BUILD_MODE_ID = "build"; +const OPENCODE_LEGACY_FULL_ACCESS_MODE_ID = "full-access"; +const OPENCODE_AUTO_ACCEPT_FEATURE_ID = "auto_accept"; + +function isOpenCodeLegacyFullAccessMode( + provider: AgentProvider, + modeId: string | undefined, +): boolean { + return provider === OPENCODE_PROVIDER_ID && modeId === OPENCODE_LEGACY_FULL_ACCESS_MODE_ID; +} + +function withOpenCodeAutoAcceptFeature( + features: Record | undefined, + enabled: boolean, +): Record { + return { + ...features, + [OPENCODE_AUTO_ACCEPT_FEATURE_ID]: enabled, + }; +} + +function hasOpenCodeAutoAcceptFeature(agent: ManagedAgent): boolean { + if (agent.provider !== OPENCODE_PROVIDER_ID) { + return false; + } + return ( + agent.features?.some( + (feature) => + feature.id === OPENCODE_AUTO_ACCEPT_FEATURE_ID && + (feature.value === true || feature.value === "true"), + ) === true || agent.config.featureValues?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true + ); +} + +function isAgentInUnattendedState( + dependencies: CreateAgentCommandDependencies, + agent: ManagedAgent, +): boolean { + return ( + isParentInUnattendedMode(dependencies, agent.provider, agent.currentModeId) || + hasOpenCodeAutoAcceptFeature(agent) + ); +} + +export interface CreateAgentWorkspace { + workspaceId: string; +} + +export interface CreateAgentSessionWorktreeResult { + sessionConfig: AgentSessionConfig; + setupContinuation?: AgentWorktreeSetupContinuation; +} + +interface CreateAgentCommandDependencies { + agentManager: AgentManager; + agentStorage: AgentStorage; + logger: Logger; + paseoHome?: string; + workspaceGitService?: Pick< + WorkspaceGitService, + "getSnapshot" | "listWorktrees" | "resolveRepoRoot" + >; + terminalManager?: TerminalManager | null; + providerRegistry?: Record | null; + createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn; +} + +export interface CreateAgentFromSessionInput { + kind: "session"; + config: AgentSessionConfig; + workspaceId?: string; + worktreeName?: string; + initialPrompt?: string; + clientMessageId?: string; + outputSchema?: Record; + images?: Array<{ data: string; mimeType: string }>; + attachments?: AgentAttachment[]; + git?: GitSetupOptions; + labels: Record; + env?: Record; + provisionalTitle: string | null; + explicitTitle: string | null; + firstAgentContext: FirstAgentContext; + buildSessionConfig: ( + config: AgentSessionConfig, + gitOptions?: GitSetupOptions, + legacyWorktreeName?: string, + firstAgentContext?: FirstAgentContext, + ) => Promise; + resolveWorkspace: (input: { cwd: string; workspaceId?: string }) => Promise; +} + +export interface CreateAgentFromMcpInput { + kind: "mcp"; + provider: string; + title: string; + initialPrompt: string; + cwd?: string; + thinking?: string; + features?: Record; + labels?: Record; + mode?: string; + background: boolean; + notifyOnFinish: boolean; + callerAgentId?: string; + callerContext?: { + lockedCwd?: string; + allowCustomCwd?: boolean; + childAgentDefaultLabels?: Record; + } | null; + worktree?: { + worktreeName?: string; + baseBranch?: string; + refName?: string; + action?: "branch-off" | "checkout"; + githubPrNumber?: number; + }; +} + +export type CreateAgentCommandInput = CreateAgentFromSessionInput | CreateAgentFromMcpInput; + +export interface CreateAgentCommandResult { + snapshot: ManagedAgent; + liveSnapshot: ManagedAgent; + background: boolean; + initialPromptStarted: boolean; +} + +interface ResolvedCreateAgent { + config: AgentSessionConfig; + createOptions?: AgentCreateOptions; + metadataInitialPrompt?: string; + prompt?: AgentPromptInput; + runOptions?: AgentRunOptions; + explicitTitle: string | null; + setupContinuation?: AgentWorktreeSetupContinuation; + background: boolean; + promptFailure: "throw" | "log"; + promptLogger?: Logger; +} + +interface AgentCreateOptions { + labels?: Record; + workspaceId?: string; + initialPrompt?: string; + env?: Record; + initialTitle?: string | null; +} + +export async function createAgentCommand( + dependencies: CreateAgentCommandDependencies, + input: CreateAgentCommandInput, +): Promise { + const resolved = + input.kind === "session" + ? await resolveSessionCreateAgent(dependencies, input) + : await resolveMcpCreateAgent(dependencies, input); + + const snapshot = await dependencies.agentManager.createAgent( + resolved.config, + undefined, + resolved.createOptions, + ); + + resolved.setupContinuation?.startAfterAgentCreate({ + agentId: snapshot.id, + }); + + let liveSnapshot = snapshot; + let initialPromptStarted = false; + if (resolved.prompt !== undefined) { + const sendResult = await sendInitialPrompt(dependencies, resolved, snapshot); + initialPromptStarted = sendResult.started; + liveSnapshot = sendResult.liveSnapshot; + } + + if (input.kind === "mcp" && input.notifyOnFinish && input.callerAgentId && initialPromptStarted) { + setupFinishNotification({ + agentManager: dependencies.agentManager, + agentStorage: dependencies.agentStorage, + childAgentId: snapshot.id, + callerAgentId: input.callerAgentId, + logger: dependencies.logger, + }); + } + + return { + snapshot, + liveSnapshot, + background: resolved.background, + initialPromptStarted, + }; +} + +async function resolveSessionCreateAgent( + dependencies: CreateAgentCommandDependencies, + input: CreateAgentFromSessionInput, +): Promise { + const trimmedPrompt = input.initialPrompt?.trim(); + const { sessionConfig, setupContinuation } = await input.buildSessionConfig( + input.config, + input.git, + input.worktreeName, + input.firstAgentContext, + ); + const workspace = await input.resolveWorkspace({ + cwd: sessionConfig.cwd, + workspaceId: input.workspaceId, + }); + const prompt = buildAgentPrompt(trimmedPrompt ?? "", input.images, input.attachments); + const hasPromptContent = Array.isArray(prompt) ? prompt.length > 0 : prompt.length > 0; + + return { + config: sessionConfig, + createOptions: { + labels: input.labels, + workspaceId: workspace.workspaceId, + initialPrompt: trimmedPrompt, + env: input.env, + initialTitle: input.provisionalTitle, + }, + metadataInitialPrompt: trimmedPrompt, + prompt: hasPromptContent ? prompt : undefined, + runOptions: input.outputSchema ? { outputSchema: input.outputSchema } : undefined, + explicitTitle: input.explicitTitle, + setupContinuation, + background: true, + promptFailure: "throw", + promptLogger: dependencies.logger.child({ + clientMessageId: resolveClientMessageId(input.clientMessageId), + }), + }; +} + +async function resolveMcpCreateAgent( + dependencies: CreateAgentCommandDependencies, + input: CreateAgentFromMcpInput, +): Promise { + const resolvedProviderModel = resolveRequiredProviderModel(input.provider); + const provider = resolvedProviderModel.provider; + const parentAgent = input.callerAgentId + ? requireParentAgent(dependencies.agentManager, input.callerAgentId) + : null; + const cwd = parentAgent + ? resolveChildAgentCwd({ + parentCwd: parentAgent.cwd, + requestedCwd: input.cwd, + lockedCwd: input.callerContext?.lockedCwd, + allowCustomCwd: input.callerContext?.allowCustomCwd ?? true, + }) + : expandUserPath(input.cwd ?? process.cwd()); + const { resolvedCwd, setupContinuation } = await resolveMcpCwd({ + dependencies, + cwd, + worktree: input.worktree, + initialPrompt: input.initialPrompt, + }); + + const parentForResolve = parentAgent + ? { + provider: parentAgent.provider, + modeId: parentAgent.currentModeId, + isUnattended: isAgentInUnattendedState(dependencies, parentAgent), + } + : null; + const { mode: resolvedMode, features: resolvedFeatures } = resolveCreateModeAndFeatures( + dependencies, + { + provider, + requestedMode: input.mode, + parent: parentForResolve, + features: input.features, + }, + ); + + const labels = mergeLabels( + input.callerAgentId, + input.callerContext?.childAgentDefaultLabels, + input.labels, + ); + + const trimmedPrompt = input.initialPrompt.trim(); + return { + config: { + provider, + cwd: resolvedCwd, + modeId: resolvedMode, + title: input.title.trim(), + model: resolvedProviderModel.model, + thinkingOptionId: input.thinking, + ...(resolvedFeatures ? { featureValues: resolvedFeatures } : {}), + }, + createOptions: labels ? { labels } : undefined, + metadataInitialPrompt: trimmedPrompt, + prompt: trimmedPrompt, + explicitTitle: input.title.trim(), + setupContinuation, + background: input.background, + promptFailure: "log", + }; +} + +function resolveCreateModeAndFeatures( + dependencies: CreateAgentCommandDependencies, + input: { + provider: AgentProvider; + requestedMode: string | undefined; + parent: { provider: AgentProvider; modeId: string | null; isUnattended: boolean } | null; + features: Record | undefined; + }, +): { mode: string | undefined; features: Record | undefined } { + const legacyOpenCodeFullAccess = isOpenCodeLegacyFullAccessMode( + input.provider, + input.requestedMode, + ); + const inheritsOpenCodeUnattended = + input.provider === OPENCODE_PROVIDER_ID && + input.requestedMode === undefined && + input.parent?.isUnattended === true; + const inheritsOpenCodeAutoAccept = + inheritsOpenCodeUnattended && input.features?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === undefined; + const requestedMode = legacyOpenCodeFullAccess ? OPENCODE_BUILD_MODE_ID : input.requestedMode; + const features = + legacyOpenCodeFullAccess || inheritsOpenCodeAutoAccept + ? withOpenCodeAutoAcceptFeature(input.features, true) + : input.features; + const mode = + inheritsOpenCodeUnattended && requestedMode === undefined + ? OPENCODE_BUILD_MODE_ID + : resolveAndValidateCreateAgentMode({ + requestedMode, + targetProvider: input.provider, + parent: input.parent, + availableModes: getAvailableModeIds(dependencies, input.provider), + targetUnattendedMode: getUnattendedModeId(dependencies, input.provider), + }); + + return { mode, features }; +} + +async function sendInitialPrompt( + dependencies: CreateAgentCommandDependencies, + resolved: ResolvedCreateAgent, + snapshot: ManagedAgent, +): Promise<{ started: boolean; liveSnapshot: ManagedAgent }> { + scheduleAgentMetadataGeneration({ + agentManager: dependencies.agentManager, + agentId: snapshot.id, + cwd: snapshot.cwd, + workspaceGitService: dependencies.workspaceGitService, + initialPrompt: resolved.metadataInitialPrompt, + explicitTitle: resolved.explicitTitle, + paseoHome: dependencies.paseoHome, + logger: dependencies.logger, + }); + + try { + const prompt = resolved.prompt; + if (prompt === undefined) { + return { started: false, liveSnapshot: snapshot }; + } + const liveSnapshot = await startCreatedAgentInitialPrompt({ + agentManager: dependencies.agentManager, + agentId: snapshot.id, + snapshot, + prompt, + runOptions: resolved.runOptions, + logger: resolved.promptLogger ?? dependencies.logger, + }); + return { started: true, liveSnapshot }; + } catch (error) { + if (resolved.promptFailure === "throw") { + throw error; + } + dependencies.logger.error({ err: error, agentId: snapshot.id }, "Failed to run initial prompt"); + return { started: false, liveSnapshot: snapshot }; + } +} + +function buildAgentPrompt( + text: string, + images?: Array<{ data: string; mimeType: string }>, + attachments?: AgentAttachment[], +): AgentPromptInput { + const normalized = text.trim(); + const hasImages = (images?.length ?? 0) > 0; + const hasAttachments = (attachments?.length ?? 0) > 0; + if (!hasImages && !hasAttachments) { + return normalized; + } + const blocks: AgentPromptContentBlock[] = []; + if (normalized.length > 0) { + blocks.push({ type: "text", text: normalized }); + } + for (const image of images ?? []) { + blocks.push({ type: "image", data: image.data, mimeType: image.mimeType }); + } + for (const attachment of attachments ?? []) { + blocks.push(attachment); + } + return blocks; +} + +function requireParentAgent(agentManager: AgentManager, parentAgentId: string): ManagedAgent { + const parentAgent = agentManager.getAgent(parentAgentId); + if (!parentAgent) { + throw new Error(`Parent agent ${parentAgentId} not found`); + } + return parentAgent; +} + +function resolveChildAgentCwd(params: { + parentCwd: string; + requestedCwd?: string; + lockedCwd?: string; + allowCustomCwd: boolean; +}): string { + const lockedCwd = params.lockedCwd?.trim(); + if (lockedCwd) { + return expandUserPath(lockedCwd); + } + + const requestedCwd = params.requestedCwd?.trim(); + if (!requestedCwd || !params.allowCustomCwd) { + return params.parentCwd; + } + + return resolvePathFromBase(params.parentCwd, requestedCwd); +} + +async function resolveMcpCwd(params: { + dependencies: CreateAgentCommandDependencies; + cwd: string; + initialPrompt: string; + worktree: CreateAgentFromMcpInput["worktree"]; +}): Promise<{ resolvedCwd: string; setupContinuation?: AgentWorktreeSetupContinuation }> { + const { dependencies, worktree } = params; + if (!worktree) { + return { resolvedCwd: params.cwd }; + } + const shouldCreateWorktree = Boolean( + worktree.worktreeName || worktree.refName || worktree.action || worktree.githubPrNumber, + ); + if (!shouldCreateWorktree) { + return { resolvedCwd: params.cwd }; + } + if ( + worktree.worktreeName && + !worktree.baseBranch && + !worktree.refName && + !worktree.action && + worktree.githubPrNumber === undefined + ) { + throw new Error("baseBranch is required when creating a worktree"); + } + const baseBranch = worktree.baseBranch; + const createdWorktree = await createMcpWorktree({ + input: { + cwd: params.cwd, + worktreeSlug: worktree.worktreeName, + refName: worktree.refName, + action: worktree.action, + githubPrNumber: worktree.githubPrNumber, + ...(params.initialPrompt ? { firstAgentContext: { prompt: params.initialPrompt } } : {}), + runSetup: false, + paseoHome: dependencies.paseoHome, + }, + createPaseoWorktree: dependencies.createPaseoWorktree, + resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined, + setupContinuation: { + kind: "agent", + terminalManager: dependencies.terminalManager ?? null, + appendTimelineItem: ({ agentId, item }) => + appendTimelineItemIfAgentKnown({ + agentManager: dependencies.agentManager, + agentId, + item, + }), + emitLiveTimelineItem: ({ agentId, item }) => + emitLiveTimelineItemIfAgentKnown({ + agentManager: dependencies.agentManager, + agentId, + item, + }), + logger: dependencies.logger, + }, + }); + return { + resolvedCwd: createdWorktree.worktree.worktreePath, + setupContinuation: createdWorktree.setupContinuation, + }; +} + +interface CreateMcpWorktreeOptions { + input: CreatePaseoWorktreeInput; + createPaseoWorktree: CreatePaseoWorktreeWorkflowFn | undefined; + resolveDefaultBranch?: (repoRoot: string) => Promise; + setupContinuation?: CreatePaseoWorktreeSetupContinuationInput; +} + +async function createMcpWorktree( + options: CreateMcpWorktreeOptions, +): Promise { + try { + if (!options.createPaseoWorktree) { + throw new Error("Paseo worktree service is not configured"); + } + return await options.createPaseoWorktree(options.input, { + ...(options.resolveDefaultBranch + ? { resolveDefaultBranch: options.resolveDefaultBranch } + : {}), + ...(options.setupContinuation ? { setupContinuation: options.setupContinuation } : {}), + }); + } catch (error) { + throw toWorktreeRequestError(error); + } +} + +function mergeLabels( + callerAgentId: string | undefined, + childAgentDefaultLabels: Record | undefined, + labels: Record | undefined, +): Record | undefined { + const mergedLabels = { + ...(callerAgentId ? { [PARENT_AGENT_ID_LABEL]: callerAgentId } : {}), + ...childAgentDefaultLabels, + ...labels, + }; + return Object.keys(mergedLabels).length > 0 ? mergedLabels : undefined; +} + +function getProviderModes( + dependencies: CreateAgentCommandDependencies, + provider: AgentProvider, +): ProviderDefinition["modes"] | undefined { + const fromRegistry = dependencies.providerRegistry?.[provider]; + if (fromRegistry) { + return fromRegistry.modes; + } + try { + return getAgentProviderDefinition(provider).modes; + } catch { + return undefined; + } +} + +function getAvailableModeIds( + dependencies: CreateAgentCommandDependencies, + provider: AgentProvider, +): string[] | undefined { + return getProviderModes(dependencies, provider)?.map((mode) => mode.id); +} + +function getUnattendedModeId( + dependencies: CreateAgentCommandDependencies, + provider: AgentProvider, +): string | undefined { + return getProviderModes(dependencies, provider)?.find((mode) => mode.isUnattended)?.id; +} + +function isParentInUnattendedMode( + dependencies: CreateAgentCommandDependencies, + provider: AgentProvider, + modeId: string | null, +): boolean { + if (modeId === null) { + return false; + } + const modes = getProviderModes(dependencies, provider); + if (!modes) { + return false; + } + return modes.some((mode) => mode.id === modeId && mode.isUnattended === true); +} + diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 2f54ebb245..3c425c7757 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -10,7 +10,7 @@ import type { } from "@modelcontextprotocol/sdk/types.js"; import type { AgentProvider } from "./agent-sdk-types.js"; -import type { AgentManager, ManagedAgent, WaitForAgentResult } from "./agent-manager.js"; +import type { AgentManager, WaitForAgentResult } from "./agent-manager.js"; import { AgentFeatureSchema, AgentPermissionRequestPayloadSchema, @@ -29,10 +29,6 @@ import { selectItemsByProjectedLimit } from "./timeline-projection.js"; import type { AgentStorage } from "./agent-storage.js"; import { ensureAgentLoaded } from "./agent-loading.js"; import { isStoredAgentProviderAvailable } from "../persistence-hooks.js"; -import { - appendTimelineItemIfAgentKnown, - emitLiveTimelineItemIfAgentKnown, -} from "./timeline-append.js"; import { getPaseoWorktreesRoot } from "../../utils/worktree.js"; import { archivePaseoWorktree, @@ -40,13 +36,11 @@ import { type ArchivePaseoWorktreeDependencies, } from "../paseo-worktree-archive-service.js"; import { WaitForAgentTracker } from "./wait-for-agent-tracker.js"; -import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js"; +import { createAgentCommand } from "./create-agent/create.js"; import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js"; import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../path-utils.js"; -import { PARENT_AGENT_ID_LABEL } from "../../shared/agent-labels.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js"; import type { - AgentWorktreeSetupContinuation, CreatePaseoWorktreeSetupContinuationInput, CreatePaseoWorktreeWorkflowFn, CreatePaseoWorktreeWorkflowResult, @@ -59,8 +53,6 @@ import { } from "../schedule/types.js"; import type { ScheduleCadence, UpdateScheduleInput } from "../schedule/types.js"; import type { ProviderDefinition } from "./provider-registry.js"; -import { getAgentProviderDefinition } from "./provider-manifest.js"; -import { resolveAndValidateCreateAgentMode } from "./create-agent-mode.js"; import { resolveSnapshotCwd } from "./provider-snapshot-manager.js"; import { AgentModelSchema, @@ -75,11 +67,7 @@ import { toScheduleSummary, waitForAgentWithTimeout, } from "./mcp-shared.js"; -import { - sendPromptToAgent, - setupFinishNotification, - startCreatedAgentInitialPrompt, -} from "./agent-prompt.js"; +import { sendPromptToAgent, setupFinishNotification } from "./agent-prompt.js"; import { respondToAgentPermission } from "./permission-response.js"; import type { GitHubService } from "../../services/github-service.js"; import type { WorkspaceGitService } from "../workspace-git-service.js"; @@ -135,42 +123,6 @@ const CODEX_TO_CLAUDE_MODE: Record = { "full-access": "bypassPermissions", }; -const OPENCODE_PROVIDER_ID = "opencode"; -const OPENCODE_BUILD_MODE_ID = "build"; -const OPENCODE_LEGACY_FULL_ACCESS_MODE_ID = "full-access"; -const OPENCODE_AUTO_ACCEPT_FEATURE_ID = "auto_accept"; - -function isOpenCodeLegacyFullAccessMode( - provider: AgentProvider, - modeId: string | undefined, -): boolean { - return provider === OPENCODE_PROVIDER_ID && modeId === OPENCODE_LEGACY_FULL_ACCESS_MODE_ID; -} - -function withOpenCodeAutoAcceptFeature( - features: Record | undefined, - enabled: boolean, -): Record { - return { - ...features, - [OPENCODE_AUTO_ACCEPT_FEATURE_ID]: enabled, - }; -} - -function hasOpenCodeAutoAcceptFeature(agent: ManagedAgent): boolean { - if (agent.provider !== OPENCODE_PROVIDER_ID) { - return false; - } - return ( - agent.features?.some( - (feature) => - feature.id === OPENCODE_AUTO_ACCEPT_FEATURE_ID && - feature.type === "toggle" && - feature.value === true, - ) === true || agent.config.featureValues?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true - ); -} - function mapModeAcrossProviders( sourceMode: string, sourceProvider: AgentProvider, @@ -895,6 +847,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom "Draft provider settings used to compute available features.", ), }; + type TopLevelCreateAgentArgs = z.infer; if (options.voiceOnly || options.enableVoiceTools || callerContext?.enableVoiceTools) { registerTool( @@ -939,208 +892,6 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom return server; } - interface ResolvedCreateAgentArgs { - provider: AgentProvider; - initialPrompt: string; - background: boolean; - normalizedTitle: string | null; - model: string | undefined; - thinkingOptionId: string | undefined; - features: Record | undefined; - labels: Record | undefined; - notifyOnFinish: boolean; - resolvedCwd: string; - resolvedMode: string | undefined; - setupContinuation: AgentWorktreeSetupContinuation | undefined; - } - - const getProviderModes = (provider: AgentProvider) => { - const fromRegistry = providerRegistry?.[provider]; - if (fromRegistry) { - return fromRegistry.modes; - } - try { - return getAgentProviderDefinition(provider).modes; - } catch { - return undefined; - } - }; - - const getAvailableModeIds = (provider: AgentProvider): string[] | undefined => { - return getProviderModes(provider)?.map((mode) => mode.id); - }; - - const getUnattendedModeId = (provider: AgentProvider): string | undefined => { - return getProviderModes(provider)?.find((mode) => mode.isUnattended)?.id; - }; - - const isParentInUnattendedMode = (provider: AgentProvider, modeId: string | null): boolean => { - if (modeId === null) return false; - const modes = getProviderModes(provider); - if (!modes) return false; - return modes.some((mode) => mode.id === modeId && mode.isUnattended === true); - }; - - const isAgentInUnattendedState = (agent: ManagedAgent): boolean => { - return ( - isParentInUnattendedMode(agent.provider, agent.currentModeId) || - hasOpenCodeAutoAcceptFeature(agent) - ); - }; - - const resolveCreateModeAndFeatures = (input: { - provider: AgentProvider; - requestedMode: string | undefined; - parent: { provider: AgentProvider; modeId: string | null; isUnattended: boolean } | null; - features: Record | undefined; - }): { mode: string | undefined; features: Record | undefined } => { - const legacyOpenCodeFullAccess = isOpenCodeLegacyFullAccessMode( - input.provider, - input.requestedMode, - ); - const inheritsOpenCodeUnattended = - input.provider === OPENCODE_PROVIDER_ID && - input.requestedMode === undefined && - input.parent?.isUnattended === true; - const inheritsOpenCodeAutoAccept = - inheritsOpenCodeUnattended && input.features?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === undefined; - const requestedMode = legacyOpenCodeFullAccess ? OPENCODE_BUILD_MODE_ID : input.requestedMode; - const features = - legacyOpenCodeFullAccess || inheritsOpenCodeAutoAccept - ? withOpenCodeAutoAcceptFeature(input.features, true) - : input.features; - const mode = - inheritsOpenCodeUnattended && requestedMode === undefined - ? OPENCODE_BUILD_MODE_ID - : resolveAndValidateCreateAgentMode({ - requestedMode, - targetProvider: input.provider, - parent: input.parent, - availableModes: getAvailableModeIds(input.provider), - targetUnattendedMode: getUnattendedModeId(input.provider), - }); - - return { mode, features }; - }; - - const resolveCallerCreateAgentArgs = ( - args: unknown, - parentAgentId: string, - ): ResolvedCreateAgentArgs => { - const callerArgs = agentToAgentCreateAgentArgsSchema.parse(args); - const resolvedProviderModel = resolveRequiredProviderModel(callerArgs.provider); - const parentAgent = agentManager.getAgent(parentAgentId); - if (!parentAgent) { - throw new Error(`Parent agent ${parentAgentId} not found`); - } - const provider = resolvedProviderModel.provider; - const settings = callerArgs.settings; - const resolvedCwd = resolveChildAgentCwd({ - parentCwd: parentAgent.cwd, - requestedCwd: callerArgs.cwd, - lockedCwd: callerContext?.lockedCwd, - allowCustomCwd: callerContext?.allowCustomCwd ?? true, - }); - const resolvedRuntime = resolveCreateModeAndFeatures({ - provider, - requestedMode: settings?.modeId, - parent: { - provider: parentAgent.provider, - modeId: parentAgent.currentModeId, - isUnattended: isAgentInUnattendedState(parentAgent), - }, - features: settings?.features, - }); - return { - provider, - initialPrompt: callerArgs.initialPrompt, - background: callerArgs.background ?? false, - normalizedTitle: callerArgs.title.trim(), - model: resolvedProviderModel.model, - thinkingOptionId: settings?.thinkingOptionId, - features: resolvedRuntime.features, - labels: callerArgs.labels, - notifyOnFinish: callerArgs.notifyOnFinish ?? false, - resolvedCwd, - resolvedMode: resolvedRuntime.mode, - setupContinuation: undefined, - }; - }; - - const resolveTopLevelCreateAgentArgs = async ( - args: unknown, - ): Promise => { - const topLevelArgs = topLevelCreateAgentArgsSchema.parse(args); - const resolvedProviderModel = resolveRequiredProviderModel(topLevelArgs.provider); - const { cwd, settings, worktreeName, baseBranch, refName, action, githubPrNumber } = - topLevelArgs; - const resolvedRuntime = resolveCreateModeAndFeatures({ - provider: resolvedProviderModel.provider, - requestedMode: settings?.modeId, - parent: null, - features: settings?.features, - }); - let resolvedCwd = expandUserPath(cwd); - let setupContinuation: AgentWorktreeSetupContinuation | undefined; - - const shouldCreateWorktree = Boolean(worktreeName || refName || action || githubPrNumber); - if (shouldCreateWorktree) { - if (worktreeName && !baseBranch && !refName && !action && githubPrNumber === undefined) { - throw new Error("baseBranch is required when creating a worktree"); - } - const createdWorktree = await createMcpWorktree({ - input: { - cwd: resolvedCwd, - worktreeSlug: worktreeName, - refName, - action, - githubPrNumber, - ...(topLevelArgs.initialPrompt - ? { firstAgentContext: { prompt: topLevelArgs.initialPrompt } } - : {}), - runSetup: false, - paseoHome: options.paseoHome, - }, - createPaseoWorktree: options.createPaseoWorktree, - resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined, - setupContinuation: { - kind: "agent", - terminalManager: terminalManager ?? null, - appendTimelineItem: ({ agentId, item }) => - appendTimelineItemIfAgentKnown({ - agentManager, - agentId, - item, - }), - emitLiveTimelineItem: ({ agentId, item }) => - emitLiveTimelineItemIfAgentKnown({ - agentManager, - agentId, - item, - }), - logger: childLogger, - }, - }); - resolvedCwd = createdWorktree.worktree.worktreePath; - setupContinuation = createdWorktree.setupContinuation; - } - - return { - provider: resolvedProviderModel.provider, - initialPrompt: topLevelArgs.initialPrompt, - background: topLevelArgs.background ?? false, - normalizedTitle: topLevelArgs.title.trim(), - model: resolvedProviderModel.model, - thinkingOptionId: settings?.thinkingOptionId, - features: resolvedRuntime.features, - labels: topLevelArgs.labels, - notifyOnFinish: topLevelArgs.notifyOnFinish ?? false, - resolvedCwd, - resolvedMode: resolvedRuntime.mode, - setupContinuation, - }; - }; - registerTool( "create_agent", { @@ -1160,88 +911,46 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, }, async (args: unknown) => { - const resolved = callerAgentId - ? resolveCallerCreateAgentArgs(args, callerAgentId) - : await resolveTopLevelCreateAgentArgs(args); - const { - provider, - initialPrompt, - background, - normalizedTitle, - model, - thinkingOptionId, - features, - labels, - notifyOnFinish, - resolvedCwd, - resolvedMode, - setupContinuation, - } = resolved; - - const childAgentDefaultLabels = callerContext?.childAgentDefaultLabels; - const mergedLabels = { - ...(callerAgentId ? { [PARENT_AGENT_ID_LABEL]: callerAgentId } : {}), - ...childAgentDefaultLabels, - ...labels, - }; - const snapshot = await agentManager.createAgent( + const { parsedArgs, worktree } = resolveCreateAgentToolArgs(args); + const { snapshot, background, initialPromptStarted } = await createAgentCommand( { - provider, - cwd: resolvedCwd, - modeId: resolvedMode, - title: normalizedTitle ?? undefined, - model, - thinkingOptionId, - featureValues: features, + agentManager, + agentStorage, + logger: childLogger, + paseoHome: options.paseoHome, + workspaceGitService: options.workspaceGitService, + terminalManager, + providerRegistry, + createPaseoWorktree: options.createPaseoWorktree, + }, + { + kind: "mcp", + provider: parsedArgs.provider, + title: parsedArgs.title, + initialPrompt: parsedArgs.initialPrompt, + cwd: parsedArgs.cwd, + thinking: parsedArgs.settings?.thinkingOptionId, + features: parsedArgs.settings?.features, + labels: parsedArgs.labels, + mode: parsedArgs.settings?.modeId, + background: parsedArgs.background ?? false, + notifyOnFinish: parsedArgs.notifyOnFinish ?? false, + callerAgentId, + callerContext, + worktree, }, - undefined, - Object.keys(mergedLabels).length > 0 ? { labels: mergedLabels } : undefined, ); - setupContinuation?.startAfterAgentCreate({ - agentId: snapshot.id, - }); - - const trimmedPrompt = initialPrompt.trim(); - scheduleAgentMetadataGeneration({ - agentManager, - agentId: snapshot.id, - cwd: snapshot.cwd, - workspaceGitService: options.workspaceGitService, - initialPrompt: trimmedPrompt, - explicitTitle: snapshot.config.title, - paseoHome: options.paseoHome, - logger: childLogger, - }); - - let liveSnapshot = snapshot; try { - liveSnapshot = await startCreatedAgentInitialPrompt({ - agentManager, - agentId: snapshot.id, - snapshot, - prompt: trimmedPrompt, - logger: childLogger, - }); - if (notifyOnFinish && callerAgentId) { - setupFinishNotification({ - agentManager, - agentStorage, - childAgentId: snapshot.id, - callerAgentId, - logger: childLogger, - }); - } - - // If not running in background, wait for completion - if (!background) { + if (!background && initialPromptStarted) { const result = await waitForAgentWithTimeout(agentManager, snapshot.id, { waitForActive: true, }); + const liveSnapshot = agentManager.getAgent(snapshot.id) ?? snapshot; const responseData = { - agentId: liveSnapshot.id, - type: provider, + agentId: snapshot.id, + type: snapshot.provider, status: result.status, cwd: liveSnapshot.cwd, currentModeId: liveSnapshot.currentModeId, @@ -1263,12 +972,12 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom } // Return immediately if background=true - const currentSnapshot = agentManager.getAgent(snapshot.id) ?? liveSnapshot; + const currentSnapshot = agentManager.getAgent(snapshot.id) ?? snapshot; const response = { content: [], structuredContent: ensureValidJson({ agentId: currentSnapshot.id, - type: provider, + type: snapshot.provider, status: currentSnapshot.lifecycle, cwd: currentSnapshot.cwd, currentModeId: currentSnapshot.currentModeId, @@ -1281,6 +990,43 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, ); + function resolveCreateAgentToolArgs(args: unknown): { + parsedArgs: + | z.infer + | z.infer; + worktree: ReturnType; + } { + if (callerAgentId) { + return { + parsedArgs: agentToAgentCreateAgentArgsSchema.parse(args), + worktree: undefined, + }; + } + const parsedArgs = topLevelCreateAgentArgsSchema.parse(args); + return { + parsedArgs, + worktree: resolveTopLevelCreateAgentWorktree(parsedArgs), + }; + } + + function resolveTopLevelCreateAgentWorktree(args: TopLevelCreateAgentArgs): + | { + worktreeName?: string; + baseBranch?: string; + refName?: string; + action?: "branch-off" | "checkout"; + githubPrNumber?: number; + } + | undefined { + return { + worktreeName: args.worktreeName, + baseBranch: args.baseBranch, + refName: args.refName, + action: args.action, + githubPrNumber: args.githubPrNumber, + }; + } + registerTool( "wait_for_agent", { @@ -1670,22 +1416,10 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom } const trimmedName = name?.trim(); - if (trimmedName) { - const record = await agentStorage.get(agentId); - if (!record) { - throw new Error(`Agent ${agentId} not found`); - } - await agentStorage.upsert({ - ...record, - title: trimmedName, - updatedAt: new Date().toISOString(), - }); - agentManager.notifyAgentState(agentId); - } - - if (labels) { - await agentManager.setLabels(agentId, labels); - } + await agentManager.updateAgentMetadata(agentId, { + ...(trimmedName ? { title: trimmedName } : {}), + ...(labels ? { labels } : {}), + }); return { content: [], diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index bdbb17c869..d28f8b029c 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -68,10 +68,10 @@ import { ensureAgentLoaded } from "./agent/agent-loading.js"; import { formatSystemNotificationPrompt, sendPromptToAgent, - startCreatedAgentInitialPrompt, waitForAgentRunStartWithTimeout, unarchiveAgentState, } from "./agent/agent-prompt.js"; +import { resolveCreateAgentTitles } from "./agent/create-agent-title.js"; import { respondToAgentPermission } from "./agent/permission-response.js"; import { experimental_createMCPClient } from "ai"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; @@ -107,8 +107,7 @@ import type { AgentTimelineFetchDirection, ManagedAgent, } from "./agent/agent-manager.js"; -import { scheduleAgentMetadataGeneration } from "./agent/agent-metadata-generator.js"; -import { resolveCreateAgentTitles } from "./agent/create-agent-title.js"; +import { createAgentCommand } from "./agent/create-agent/create.js"; import { buildStoredAgentPayload, resolveEffectiveThinkingOptionId, @@ -213,7 +212,6 @@ import type { LocalSpeechModelId } from "./speech/providers/local/models.js"; import { toResolver, type Resolvable } from "./speech/provider-resolver.js"; import type { SpeechReadinessSnapshot, SpeechReadinessState } from "./speech/speech-runtime.js"; import type pino from "pino"; -import { resolveClientMessageId } from "./client-message-id.js"; import { ChatServiceError, FileBackedChatService, @@ -3127,7 +3125,6 @@ export class Session { configTitle: config.title, initialPrompt: trimmedPrompt, }); - const resolvedConfig: AgentSessionConfig = config; const firstAgentContext: FirstAgentContext = { ...(trimmedPrompt ? { prompt: trimmedPrompt } : {}), @@ -3141,28 +3138,39 @@ export class Session { }); createdWorktreeForCleanup = createdWorktree; const createAgentConfig: AgentSessionConfig = createdWorktree - ? { ...resolvedConfig, cwd: createdWorktree.worktree.worktreePath } - : resolvedConfig; - const { sessionConfig, setupContinuation } = await this.buildAgentSessionConfig( - createAgentConfig, - git, - worktreeName, - firstAgentContext, + ? { ...config, cwd: createdWorktree.worktree.worktreePath } + : config; + + const { snapshot, liveSnapshot } = await createAgentCommand( + { + agentManager: this.agentManager, + agentStorage: this.agentStorage, + logger: this.sessionLogger, + paseoHome: this.paseoHome, + workspaceGitService: this.workspaceGitService, + }, + { + kind: "session", + config: createAgentConfig, + workspaceId: msg.workspaceId, + worktreeName, + initialPrompt, + clientMessageId, + outputSchema, + images, + attachments, + git, + labels, + env, + provisionalTitle, + explicitTitle, + firstAgentContext, + buildSessionConfig: (sessionConfig, gitOptions, legacyWorktreeName, ctx) => + this.buildAgentSessionConfig(sessionConfig, gitOptions, legacyWorktreeName, ctx), + resolveWorkspace: ({ cwd, workspaceId }) => + this.resolveCreateAgentWorkspace(cwd, workspaceId), + }, ); - let resolvedWorkspace = msg.workspaceId - ? await this.workspaceRegistry.get(msg.workspaceId) - : ((await this.findWorkspaceByDirectory(sessionConfig.cwd)) ?? - (await this.findOrCreateWorkspaceForDirectory(sessionConfig.cwd))); - if (!resolvedWorkspace) { - throw new Error(`Workspace not found: ${msg.workspaceId}`); - } - const snapshot = await this.agentManager.createAgent(sessionConfig, undefined, { - labels, - workspaceId: resolvedWorkspace.workspaceId, - initialPrompt: trimmedPrompt, - env, - initialTitle: provisionalTitle, - }); createdAgentId = snapshot.id; await this.forwardAgentUpdate(snapshot); this.createAgentLifecycleDispatch.registerAutoArchiveIfRequested({ @@ -3171,16 +3179,6 @@ export class Session { createdWorktree, }); - const liveSnapshot = await this.sendInitialCreateAgentPrompt({ - snapshot, - trimmedPrompt, - images, - attachments, - clientMessageId, - outputSchema, - explicitTitle, - }); - if (requestId) { const agentPayload = await this.buildAgentPayload(liveSnapshot); this.emit({ @@ -3194,10 +3192,6 @@ export class Session { }); } - setupContinuation?.startAfterAgentCreate({ - agentId: snapshot.id, - }); - this.sessionLogger.info( { agentId: snapshot.id, provider: snapshot.provider }, `Created agent ${snapshot.id} (${snapshot.provider})`, @@ -3232,44 +3226,18 @@ export class Session { } } - private async sendInitialCreateAgentPrompt(params: { - snapshot: ManagedAgent; - trimmedPrompt: string | undefined; - images: Array<{ data: string; mimeType: string }> | undefined; - attachments: AgentAttachment[] | undefined; - clientMessageId: string | undefined; - outputSchema: Record | undefined; - explicitTitle: string | null; - }): Promise { - const { snapshot, trimmedPrompt, images, attachments, clientMessageId, outputSchema } = params; - const hasPrompt = Boolean(trimmedPrompt); - const hasImages = (images?.length ?? 0) > 0; - const hasAttachments = (attachments?.length ?? 0) > 0; - if (!hasPrompt && !hasImages && !hasAttachments) { - return snapshot; - } - scheduleAgentMetadataGeneration({ - agentManager: this.agentManager, - agentId: snapshot.id, - cwd: snapshot.cwd, - workspaceGitService: this.workspaceGitService, - initialPrompt: trimmedPrompt, - explicitTitle: params.explicitTitle, - paseoHome: this.paseoHome, - logger: this.sessionLogger, - }); - const prompt = this.buildAgentPrompt(trimmedPrompt || "", images, attachments); - - return await startCreatedAgentInitialPrompt({ - agentManager: this.agentManager, - agentId: snapshot.id, - snapshot, - prompt, - runOptions: outputSchema ? { outputSchema } : undefined, - logger: this.sessionLogger.child({ - clientMessageId: resolveClientMessageId(clientMessageId), - }), - }); + private async resolveCreateAgentWorkspace( + cwd: string, + workspaceId?: string, + ): Promise<{ workspaceId: string }> { + const resolvedWorkspace = workspaceId + ? await this.workspaceRegistry.get(workspaceId) + : ((await this.findWorkspaceByDirectory(cwd)) ?? + (await this.findOrCreateWorkspaceForDirectory(cwd))); + if (!resolvedWorkspace) { + throw new Error(`Workspace not found: ${workspaceId}`); + } + return { workspaceId: resolvedWorkspace.workspaceId }; } private async handleResumeAgentRequest( From 9c79f22ea4c1b1db1d0b67f7bad1ca89da3b7929 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 10 May 2026 16:49:25 +0700 Subject: [PATCH 03/11] Extract agent lifecycle commands --- .../server/agent/lifecycle-command.test.ts | 224 ++++++++++++++++++ .../src/server/agent/lifecycle-command.ts | 186 +++++++++++++++ .../server/src/server/agent/mcp-server.ts | 49 +++- packages/server/src/server/session.ts | 101 +++----- 4 files changed, 485 insertions(+), 75 deletions(-) create mode 100644 packages/server/src/server/agent/lifecycle-command.test.ts create mode 100644 packages/server/src/server/agent/lifecycle-command.ts diff --git a/packages/server/src/server/agent/lifecycle-command.test.ts b/packages/server/src/server/agent/lifecycle-command.test.ts new file mode 100644 index 0000000000..f0cb68a7d8 --- /dev/null +++ b/packages/server/src/server/agent/lifecycle-command.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, test } from "vitest"; + +import { createTestLogger } from "../../test-utils/test-logger.js"; +import type { StoredAgentRecord } from "./agent-storage.js"; +import { + archiveAgentCommand, + cancelAgentRunCommand, + setAgentModeCommand, + updateAgentCommand, + type LifecycleAgentSnapshot, + type LifecycleAgentManager, + type LifecycleAgentStorage, +} from "./lifecycle-command.js"; + +class FakeLifecycleAgentStorage implements LifecycleAgentStorage { + readonly records = new Map(); + + async get(agentId: string): Promise { + return this.records.get(agentId) ?? null; + } +} + +class FakeLifecycleAgentManager implements LifecycleAgentManager { + readonly liveAgents = new Map(); + readonly cancelledAgentIds: string[] = []; + readonly clearedAttentionAgentIds: string[] = []; + readonly archivedAgentIds: string[] = []; + readonly closedAgentIds: string[] = []; + readonly metadataUpdates: Array<{ + agentId: string; + updates: { title?: string; labels?: Record }; + }> = []; + readonly modeUpdates: Array<{ agentId: string; modeId: string }> = []; + inFlightAgentIds = new Set(); + + constructor(private readonly storage: FakeLifecycleAgentStorage) {} + + getAgent(agentId: string): LifecycleAgentSnapshot | null { + return this.liveAgents.get(agentId) ?? null; + } + + hasInFlightRun(agentId: string): boolean { + return this.inFlightAgentIds.has(agentId); + } + + async cancelAgentRun(agentId: string): Promise { + this.cancelledAgentIds.push(agentId); + return this.inFlightAgentIds.delete(agentId); + } + + async clearAgentAttention(agentId: string): Promise { + this.clearedAttentionAgentIds.push(agentId); + } + + async archiveAgent(agentId: string): Promise<{ archivedAt: string }> { + this.archivedAgentIds.push(agentId); + this.liveAgents.delete(agentId); + const archivedAt = "2026-05-10T10:00:00.000Z"; + const existing = this.storage.records.get(agentId) ?? storedAgent(agentId); + this.storage.records.set(agentId, { + ...existing, + archivedAt, + }); + return { archivedAt }; + } + + async archiveSnapshot(agentId: string, archivedAt: string): Promise { + const existing = this.storage.records.get(agentId); + if (!existing) { + throw new Error(`Agent not found: ${agentId}`); + } + const archived = { + ...existing, + archivedAt, + }; + this.storage.records.set(agentId, archived); + return archived; + } + + async closeAgent(agentId: string): Promise { + this.closedAgentIds.push(agentId); + this.liveAgents.delete(agentId); + } + + async updateAgentMetadata( + agentId: string, + updates: { title?: string; labels?: Record }, + ): Promise { + this.metadataUpdates.push({ agentId, updates }); + } + + async setAgentMode(agentId: string, modeId: string): Promise { + this.modeUpdates.push({ agentId, modeId }); + } +} + +const logger = createTestLogger(); + +describe("agent lifecycle commands", () => { + test("cancels only when the agent has an in-flight run", async () => { + const storage = new FakeLifecycleAgentStorage(); + const manager = new FakeLifecycleAgentManager(storage); + manager.liveAgents.set("agent-1", managedAgent("agent-1", "running")); + manager.inFlightAgentIds.add("agent-1"); + + const result = await cancelAgentRunCommand({ agentManager: manager, logger }, "agent-1"); + + expect(result).toEqual({ + agent: manager.liveAgents.get("agent-1"), + cancelled: true, + }); + expect(manager.cancelledAgentIds).toEqual(["agent-1"]); + }); + + test("archives a live agent after canceling and clearing attention", async () => { + const storage = new FakeLifecycleAgentStorage(); + const manager = new FakeLifecycleAgentManager(storage); + manager.liveAgents.set("agent-1", managedAgent("agent-1", "running")); + manager.inFlightAgentIds.add("agent-1"); + storage.records.set("agent-1", storedAgent("agent-1")); + + const result = await archiveAgentCommand( + { agentManager: manager, agentStorage: storage, logger }, + "agent-1", + ); + + expect(result).toEqual({ + agentId: "agent-1", + archivedAt: "2026-05-10T10:00:00.000Z", + record: { + ...storedAgent("agent-1"), + archivedAt: "2026-05-10T10:00:00.000Z", + }, + }); + expect(manager.cancelledAgentIds).toEqual(["agent-1"]); + expect(manager.clearedAttentionAgentIds).toEqual(["agent-1"]); + expect(manager.archivedAgentIds).toEqual(["agent-1"]); + }); + + test("archives a stored agent when no live agent exists", async () => { + const storage = new FakeLifecycleAgentStorage(); + const manager = new FakeLifecycleAgentManager(storage); + storage.records.set("agent-1", storedAgent("agent-1")); + + const result = await archiveAgentCommand( + { agentManager: manager, agentStorage: storage, logger }, + "agent-1", + ); + + expect(result.agentId).toBe("agent-1"); + expect(result.archivedAt).toEqual(expect.any(String)); + expect(result.record.archivedAt).toBe(result.archivedAt); + expect(manager.archivedAgentIds).toEqual([]); + }); + + test("normalizes metadata updates and rejects empty updates", async () => { + const storage = new FakeLifecycleAgentStorage(); + const manager = new FakeLifecycleAgentManager(storage); + + await expect( + updateAgentCommand( + { agentManager: manager }, + { + agentId: "agent-1", + name: " Renamed agent ", + labels: { team: "infra" }, + }, + ), + ).resolves.toEqual({ accepted: true, error: null }); + await expect( + updateAgentCommand({ agentManager: manager }, { agentId: "agent-1", name: " " }), + ).resolves.toEqual({ + accepted: false, + error: "Nothing to update (provide name and/or labels)", + }); + + expect(manager.metadataUpdates).toEqual([ + { + agentId: "agent-1", + updates: { + title: "Renamed agent", + labels: { team: "infra" }, + }, + }, + ]); + }); + + test("sets an agent mode and returns the accepted mode", async () => { + const storage = new FakeLifecycleAgentStorage(); + const manager = new FakeLifecycleAgentManager(storage); + + await expect( + setAgentModeCommand({ agentManager: manager }, { agentId: "agent-1", modeId: "plan" }), + ).resolves.toEqual({ modeId: "plan" }); + + expect(manager.modeUpdates).toEqual([{ agentId: "agent-1", modeId: "plan" }]); + }); +}); + +function managedAgent( + id: string, + lifecycle: LifecycleAgentSnapshot["lifecycle"], +): LifecycleAgentSnapshot { + return { + id, + cwd: "/workspace/project", + lifecycle, + }; +} + +function storedAgent(id: string): StoredAgentRecord { + return { + id, + provider: "codex", + cwd: "/workspace/project", + createdAt: "2026-05-10T09:00:00.000Z", + updatedAt: "2026-05-10T09:00:00.000Z", + labels: {}, + lastStatus: "closed", + config: null, + persistence: null, + archivedAt: null, + }; +} diff --git a/packages/server/src/server/agent/lifecycle-command.ts b/packages/server/src/server/agent/lifecycle-command.ts new file mode 100644 index 0000000000..800c7d0c6a --- /dev/null +++ b/packages/server/src/server/agent/lifecycle-command.ts @@ -0,0 +1,186 @@ +import type { Logger } from "pino"; + +import type { ManagedAgent } from "./agent-manager.js"; +import type { StoredAgentRecord } from "./agent-storage.js"; + +export type LifecycleAgentSnapshot = Pick; + +export interface LifecycleAgentManager { + getAgent(agentId: string): LifecycleAgentSnapshot | null; + hasInFlightRun(agentId: string): boolean; + cancelAgentRun(agentId: string): Promise; + clearAgentAttention(agentId: string): Promise; + archiveAgent(agentId: string): Promise<{ archivedAt: string }>; + archiveSnapshot(agentId: string, archivedAt: string): Promise; + closeAgent(agentId: string): Promise; + updateAgentMetadata( + agentId: string, + updates: { + title?: string; + labels?: Record; + }, + ): Promise; + setAgentMode(agentId: string, modeId: string): Promise; +} + +export interface LifecycleAgentStorage { + get(agentId: string): Promise; +} + +export interface AgentLifecycleCommandDependencies { + agentManager: LifecycleAgentManager; + agentStorage: LifecycleAgentStorage; + logger: Logger; +} + +export interface CancelAgentRunResult { + agent: LifecycleAgentSnapshot; + cancelled: boolean; +} + +export async function cancelAgentRunCommand( + dependencies: Pick, + agentId: string, +): Promise { + const { agentManager, logger } = dependencies; + const agent = agentManager.getAgent(agentId); + if (!agent) { + logger.trace({ agentId }, "cancelAgentRunCommand: agent not found"); + throw new Error(`Agent ${agentId} not found`); + } + + const hasInFlightRun = agentManager.hasInFlightRun(agentId); + if (!hasInFlightRun) { + logger.trace( + { agentId, lifecycle: agent.lifecycle, hasInFlightRun }, + "cancelAgentRunCommand: skipping because agent is not running", + ); + return { agent, cancelled: false }; + } + + logger.debug( + { agentId, lifecycle: agent.lifecycle, hasInFlightRun }, + "cancelAgentRunCommand: interrupting", + ); + const startedAt = Date.now(); + const cancelled = await agentManager.cancelAgentRun(agentId); + logger.debug( + { agentId, cancelled, durationMs: Date.now() - startedAt }, + "cancelAgentRunCommand: cancelAgentRun completed", + ); + + if (!cancelled) { + logger.warn( + { agentId }, + "cancelAgentRunCommand: reported running but no active run was cancelled", + ); + } + + return { + agent, + cancelled, + }; +} + +export interface ArchiveAgentResult { + agentId: string; + archivedAt: string; + record: StoredAgentRecord; +} + +export async function archiveAgentCommand( + dependencies: AgentLifecycleCommandDependencies, + agentId: string, +): Promise { + const liveAgent = dependencies.agentManager.getAgent(agentId); + if (liveAgent) { + await cancelAgentRunCommand(dependencies, agentId); + await dependencies.agentManager.clearAgentAttention(agentId).catch(() => undefined); + await dependencies.agentManager.archiveAgent(agentId); + } else { + await archiveStoredAgent(dependencies, agentId); + } + + const record = await dependencies.agentStorage.get(agentId); + if (!record) { + throw new Error(`Agent not found in storage after archive: ${agentId}`); + } + if (!record.archivedAt) { + throw new Error(`Agent missing archivedAt after archive: ${agentId}`); + } + + return { + agentId, + archivedAt: record.archivedAt, + record, + }; +} + +export async function closeAgentCommand( + dependencies: Pick, + agentId: string, +): Promise { + await dependencies.agentManager.closeAgent(agentId); +} + +export interface UpdateAgentResult { + accepted: boolean; + error: string | null; +} + +export async function updateAgentCommand( + dependencies: Pick, + input: { + agentId: string; + name?: string; + labels?: Record; + }, +): Promise { + const title = input.name?.trim(); + const labels = input.labels && Object.keys(input.labels).length > 0 ? input.labels : undefined; + + if (!title && !labels) { + return { + accepted: false, + error: "Nothing to update (provide name and/or labels)", + }; + } + + await dependencies.agentManager.updateAgentMetadata(input.agentId, { + ...(title ? { title } : {}), + ...(labels ? { labels } : {}), + }); + + return { + accepted: true, + error: null, + }; +} + +export async function setAgentModeCommand( + dependencies: Pick, + input: { + agentId: string; + modeId: string; + }, +): Promise<{ modeId: string }> { + await dependencies.agentManager.setAgentMode(input.agentId, input.modeId); + return { modeId: input.modeId }; +} + +async function archiveStoredAgent( + dependencies: Pick, + agentId: string, +): Promise { + const existing = await dependencies.agentStorage.get(agentId); + if (!existing) { + throw new Error(`Agent not found: ${agentId}`); + } + + if (existing.archivedAt) { + return; + } + + const archivedAt = new Date().toISOString(); + await dependencies.agentManager.archiveSnapshot(agentId, archivedAt); +} diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 3c425c7757..d82badbbc2 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -69,6 +69,13 @@ import { } from "./mcp-shared.js"; import { sendPromptToAgent, setupFinishNotification } from "./agent-prompt.js"; import { respondToAgentPermission } from "./permission-response.js"; +import { + archiveAgentCommand, + cancelAgentRunCommand, + closeAgentCommand, + setAgentModeCommand, + updateAgentCommand, +} from "./lifecycle-command.js"; import type { GitHubService } from "../../services/github-service.js"; import type { WorkspaceGitService } from "../workspace-git-service.js"; import type { CreatePaseoWorktreeInput } from "../paseo-worktree-service.js"; @@ -1326,13 +1333,16 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, }, async ({ agentId }) => { - const success = await agentManager.cancelAgentRun(agentId); - if (success) { + const { cancelled } = await cancelAgentRunCommand( + { agentManager, logger: childLogger }, + agentId, + ); + if (cancelled) { waitTracker.cancel(agentId, "Agent run cancelled"); } return { content: [], - structuredContent: ensureValidJson({ success }), + structuredContent: ensureValidJson({ success: cancelled }), }; }, ); @@ -1351,7 +1361,14 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, }, async ({ agentId }) => { - await agentManager.archiveAgent(agentId); + await archiveAgentCommand( + { + agentManager, + agentStorage, + logger: childLogger, + }, + agentId, + ); waitTracker.cancel(agentId, "Agent archived"); return { content: [], @@ -1373,7 +1390,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, }, async ({ agentId }) => { - await agentManager.closeAgent(agentId); + await closeAgentCommand({ agentManager }, agentId); waitTracker.cancel(agentId, "Agent terminated"); return { content: [], @@ -1400,30 +1417,36 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, }, async ({ agentId, name, labels, settings }) => { + let appliedSettings = false; if (settings?.modeId !== undefined) { await agentManager.setAgentMode(agentId, settings.modeId); + appliedSettings = true; } if (settings?.model !== undefined) { await agentManager.setAgentModel(agentId, settings.model); + appliedSettings = true; } if (settings?.thinkingOptionId !== undefined) { await agentManager.setAgentThinkingOption(agentId, settings.thinkingOptionId); + appliedSettings = true; } if (settings?.features) { for (const [featureId, value] of Object.entries(settings.features)) { await agentManager.setAgentFeature(agentId, featureId, value); } + appliedSettings = true; } - const trimmedName = name?.trim(); - await agentManager.updateAgentMetadata(agentId, { - ...(trimmedName ? { title: trimmedName } : {}), - ...(labels ? { labels } : {}), - }); + const metadataResult = await updateAgentCommand( + { agentManager }, + { agentId, name, labels }, + ); return { content: [], - structuredContent: ensureValidJson({ success: true }), + structuredContent: ensureValidJson({ + success: metadataResult.accepted || appliedSettings, + }), }; }, ); @@ -2301,10 +2324,10 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, }, async ({ agentId, modeId }) => { - await agentManager.setAgentMode(agentId, modeId); + const result = await setAgentModeCommand({ agentManager }, { agentId, modeId }); return { content: [], - structuredContent: ensureValidJson({ success: true, newMode: modeId }), + structuredContent: ensureValidJson({ success: true, newMode: result.modeId }), }; }, ); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index d28f8b029c..94fc7daeb1 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -108,6 +108,13 @@ import type { ManagedAgent, } from "./agent/agent-manager.js"; import { createAgentCommand } from "./agent/create-agent/create.js"; +import { + archiveAgentCommand, + cancelAgentRunCommand, + closeAgentCommand, + setAgentModeCommand, + updateAgentCommand, +} from "./agent/lifecycle-command.js"; import { buildStoredAgentPayload, resolveEffectiveThinkingOptionId, @@ -2309,7 +2316,7 @@ export class Session { beginAgentDeleteIfSupported(this.agentStorage, agentId); try { - await this.agentManager.closeAgent(agentId); + await closeAgentCommand({ agentManager: this.agentManager }, agentId); } catch (error) { this.sessionLogger.warn( { err: error, agentId }, @@ -2363,43 +2370,17 @@ export class Session { }); } - private async archiveStoredAgentForClose( - agentId: string, - ): Promise<{ agentId: string; archivedAt: string }> { - const existing = await this.agentStorage.get(agentId); - if (!existing) { - throw new Error(`Agent not found: ${agentId}`); - } - - if (existing.archivedAt) { - return { - agentId, - archivedAt: existing.archivedAt, - }; - } - - const archivedAt = new Date().toISOString(); - await this.agentManager.archiveSnapshot(agentId, archivedAt); - - return { agentId, archivedAt }; - } - private async archiveAgentForClose( agentId: string, ): Promise<{ agentId: string; archivedAt: string }> { - const liveAgent = this.agentManager.getAgent(agentId); - if (liveAgent) { - await this.interruptAgentIfRunning(agentId); - await this.agentManager.clearAgentAttention(agentId).catch(() => undefined); - await this.agentManager.archiveAgent(agentId); - } else { - await this.archiveStoredAgentForClose(agentId); - } - - const archivedRecord = await this.agentStorage.get(agentId); - if (!archivedRecord) { - throw new Error(`Agent not found in storage after archive: ${agentId}`); - } + const { archivedAt, record: archivedRecord } = await archiveAgentCommand( + { + agentManager: this.agentManager, + agentStorage: this.agentStorage, + logger: this.sessionLogger, + }, + agentId, + ); if (this.agentUpdatesSubscription) { const payload = this.buildStoredAgentPayload(archivedRecord); @@ -2432,11 +2413,7 @@ export class Session { await this.emitWorkspaceUpdateForCwd(payload.cwd); } - if (!archivedRecord.archivedAt) { - throw new Error(`Agent missing archivedAt after archive: ${agentId}`); - } - - return { agentId, archivedAt: archivedRecord.archivedAt }; + return { agentId, archivedAt }; } private async handleCloseItemsRequest(msg: CloseItemsRequest): Promise { @@ -2511,27 +2488,24 @@ export class Session { "session: update_agent_request", ); - const normalizedName = name?.trim(); - const normalizedLabels = labels && Object.keys(labels).length > 0 ? labels : undefined; - - if (!normalizedName && !normalizedLabels) { - this.emit({ - type: "update_agent_response", - payload: { - requestId, - agentId, - accepted: false, - error: "Nothing to update (provide name and/or labels)", - }, - }); - return; - } - try { - await this.agentManager.updateAgentMetadata(agentId, { - ...(normalizedName ? { title: normalizedName } : {}), - ...(normalizedLabels ? { labels: normalizedLabels } : {}), - }); + const result = await updateAgentCommand( + { agentManager: this.agentManager }, + { agentId, name, labels }, + ); + + if (!result.accepted) { + this.emit({ + type: "update_agent_response", + payload: { + requestId, + agentId, + accepted: false, + error: result.error, + }, + }); + return; + } this.emit({ type: "update_agent_response", @@ -3463,7 +3437,10 @@ export class Session { this.sessionLogger.info({ agentId }, `Cancel request received for agent ${agentId}`); try { - await this.interruptAgentIfRunning(agentId); + await cancelAgentRunCommand( + { agentManager: this.agentManager, logger: this.sessionLogger }, + agentId, + ); if (requestId) { const agent = this.agentManager.getAgent(agentId); const payload = agent ? await this.buildAgentPayload(agent) : null; @@ -4333,7 +4310,7 @@ export class Session { this.sessionLogger.info({ agentId, modeId, requestId }, "session: set_agent_mode_request"); try { - await this.agentManager.setAgentMode(agentId, modeId); + await setAgentModeCommand({ agentManager: this.agentManager }, { agentId, modeId }); this.sessionLogger.info( { agentId, modeId, requestId }, "session: set_agent_mode_request success", From 5753bdfc4e13187584e7bfd8f675fd34da667094 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 10 May 2026 16:57:40 +0700 Subject: [PATCH 04/11] refactor(server): extract worktree archive command --- .../src/server/agent/mcp-server.test.ts | 71 +++++++++- .../server/src/server/agent/mcp-server.ts | 124 +++++++++++------- .../server/src/server/worktree-session.ts | 53 ++------ .../server/src/server/worktree/commands.ts | 112 ++++++++++++++++ 4 files changed, 268 insertions(+), 92 deletions(-) create mode 100644 packages/server/src/server/worktree/commands.ts diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 20342f2975..f34ecbf837 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -1,7 +1,7 @@ import { execFileSync } from "node:child_process"; import { describe, expect, it, vi } from "vitest"; import { realpathSync } from "node:fs"; -import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { access, mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { join, resolve as resolvePath } from "node:path"; import { tmpdir } from "node:os"; import Ajv from "ajv"; @@ -1282,6 +1282,75 @@ describe("create_agent MCP tool", () => { } }); + it("archives a worktree by slug", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const tempDir = realpathSync.native( + await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-slug-")), + ); + const repoDir = join(tempDir, "repo"); + const paseoHome = join(tempDir, ".paseo"); + + try { + execFileSync("git", ["init", repoDir], { stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { + cwd: repoDir, + stdio: "pipe", + }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["config", "commit.gpgsign", "false"], { + cwd: repoDir, + stdio: "pipe", + }); + await writeFile(join(repoDir, "README.md"), "hello\n"); + execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" }); + + const workspaceGitService = { + getSnapshot: vi.fn(async () => null), + }; + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + paseoHome, + createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts: [] }), + workspaceGitService: workspaceGitService as unknown as Pick< + WorkspaceGitService, + "getSnapshot" | "listWorktrees" + >, + archiveWorkspaceRecord: vi.fn(async () => undefined), + emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => undefined), + markWorkspaceArchiving: vi.fn(), + clearWorkspaceArchiving: vi.fn(), + emitSessionMessage: vi.fn(), + github: createGitHubServiceStub(), + logger, + }); + const createTool = registeredTool(server, "create_worktree"); + const archiveTool = registeredTool(server, "archive_worktree"); + const created = await createTool.handler({ + cwd: repoDir, + target: { mode: "branch-off", newBranch: "archive-slug-worktree", base: "main" }, + }); + + const response = await archiveTool.handler({ + cwd: repoDir, + worktreeSlug: "archive-slug-worktree", + }); + + expect(response.structuredContent).toEqual({ success: true }); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(repoDir, { + force: true, + reason: "archive-worktree", + }); + await expect( + access(z.string().parse(created.structuredContent.worktreePath)), + ).rejects.toThrow(); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + it("routes list_worktrees through WorkspaceGitService", async () => { const { agentManager, agentStorage } = createTestDeps(); const workspaceGitService = { diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index d82badbbc2..cab6dc63cd 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -29,9 +29,7 @@ import { selectItemsByProjectedLimit } from "./timeline-projection.js"; import type { AgentStorage } from "./agent-storage.js"; import { ensureAgentLoaded } from "./agent-loading.js"; import { isStoredAgentProviderAvailable } from "../persistence-hooks.js"; -import { getPaseoWorktreesRoot } from "../../utils/worktree.js"; import { - archivePaseoWorktree, killTerminalsUnderPath, type ArchivePaseoWorktreeDependencies, } from "../paseo-worktree-archive-service.js"; @@ -80,7 +78,10 @@ import type { GitHubService } from "../../services/github-service.js"; import type { WorkspaceGitService } from "../workspace-git-service.js"; import type { CreatePaseoWorktreeInput } from "../paseo-worktree-service.js"; import { toWorktreeRequestError } from "../worktree-errors.js"; -import { join } from "node:path"; +import { + archivePaseoWorktreeCommand, + type ArchivePaseoWorktreeCommandDependencies, +} from "../worktree/commands.js"; export interface AgentMcpServerOptions { agentManager: AgentManager; @@ -2182,64 +2183,28 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom if (!worktreePath && !worktreeSlug) { throw new Error("worktreePath or worktreeSlug is required"); } - if (!options.github) { - throw new Error("GitHub service is required to archive worktrees"); - } if (!options.workspaceGitService) { throw new Error("WorkspaceGitService is required to archive worktrees"); } - if (!options.archiveWorkspaceRecord) { - throw new Error("Workspace registry archiver is required to archive worktrees"); - } - if (!options.emitWorkspaceUpdatesForWorkspaceIds) { - throw new Error("Workspace update emitter is required to archive worktrees"); - } - if (!options.markWorkspaceArchiving) { - throw new Error("Workspace archiving marker is required to archive worktrees"); - } - if (!options.clearWorkspaceArchiving) { - throw new Error("Workspace archiving clearer is required to archive worktrees"); - } - if (!options.emitSessionMessage) { - throw new Error("Session message emitter is required to archive worktrees"); - } const repoRoot = await options.workspaceGitService.resolveRepoRoot(resolvedCwd); - const targetPath = - worktreePath ?? - join(await getPaseoWorktreesRoot(repoRoot, options.paseoHome), worktreeSlug!); - - await archivePaseoWorktree( - { - paseoHome: options.paseoHome, - github: options.github, - workspaceGitService: options.workspaceGitService, + const result = await archivePaseoWorktreeCommand( + archiveWorktreeDependencies(options, { agentManager, agentStorage, - archiveWorkspaceRecord: options.archiveWorkspaceRecord, - emit: options.emitSessionMessage, - emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds, - markWorkspaceArchiving: options.markWorkspaceArchiving, - clearWorkspaceArchiving: options.clearWorkspaceArchiving, - isPathWithinRoot: isSameOrDescendantPath, - killTerminalsUnderPath: (rootPath) => - killTerminalsUnderPath( - { - terminalManager: terminalManager ?? null, - isPathWithinRoot: isSameOrDescendantPath, - killTrackedTerminal: () => {}, - sessionLogger: childLogger, - }, - rootPath, - ), - sessionLogger: childLogger, - }, + terminalManager: terminalManager ?? null, + logger: childLogger, + }), { - targetPath, - repoRoot, requestId: "mcp:archive_worktree", + repoRoot, + worktreePath, + worktreeSlug, }, ); + if (!result.ok) { + throw new Error(result.message); + } await options.workspaceGitService.listWorktrees(repoRoot, { force: true, reason: "mcp:archive-worktree", @@ -2404,6 +2369,65 @@ type McpCreateWorktreeTarget = | { mode: "checkout-branch"; branch: string } | { mode: "checkout-pr"; prNumber: number }; +interface ArchiveWorktreeCommandContext { + agentManager: AgentManager; + agentStorage: AgentStorage; + terminalManager: TerminalManager | null; + logger: Logger; +} + +function archiveWorktreeDependencies( + options: AgentMcpServerOptions, + context: ArchiveWorktreeCommandContext, +): ArchivePaseoWorktreeCommandDependencies { + if (!options.github) { + throw new Error("GitHub service is required to archive worktrees"); + } + if (!options.workspaceGitService) { + throw new Error("WorkspaceGitService is required to archive worktrees"); + } + if (!options.archiveWorkspaceRecord) { + throw new Error("Workspace registry archiver is required to archive worktrees"); + } + if (!options.emitWorkspaceUpdatesForWorkspaceIds) { + throw new Error("Workspace update emitter is required to archive worktrees"); + } + if (!options.markWorkspaceArchiving) { + throw new Error("Workspace archiving marker is required to archive worktrees"); + } + if (!options.clearWorkspaceArchiving) { + throw new Error("Workspace archiving clearer is required to archive worktrees"); + } + if (!options.emitSessionMessage) { + throw new Error("Session message emitter is required to archive worktrees"); + } + + return { + paseoHome: options.paseoHome, + github: options.github, + workspaceGitService: options.workspaceGitService, + agentManager: context.agentManager, + agentStorage: context.agentStorage, + archiveWorkspaceRecord: options.archiveWorkspaceRecord, + emit: options.emitSessionMessage, + emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds, + markWorkspaceArchiving: options.markWorkspaceArchiving, + clearWorkspaceArchiving: options.clearWorkspaceArchiving, + isPathWithinRoot: isSameOrDescendantPath, + killTerminalsUnderPath: (rootPath: string) => + killTerminalsUnderPath( + { + terminalManager: context.terminalManager, + isPathWithinRoot: isSameOrDescendantPath, + killTrackedTerminal: () => {}, + sessionLogger: context.logger, + }, + rootPath, + ), + sessionLogger: context.logger, + }; +} + function mcpCreateWorktreeInput( repoRoot: string, target: McpCreateWorktreeTarget, diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 574381d289..4f04b6fdec 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -27,7 +27,6 @@ import type { CheckoutExistingBranchResult } from "../utils/checkout-git.js"; import { expandTilde } from "../utils/path.js"; import { getWorktreeSetupCommands, - isPaseoOwnedWorktreeCwd, resolveWorktreeRuntimeEnv, runWorktreeSetupCommands, slugify, @@ -41,11 +40,9 @@ import type { CreatePaseoWorktreeInput, CreatePaseoWorktreeResult, } from "./paseo-worktree-service.js"; -import { - archivePaseoWorktree, - type ArchivePaseoWorktreeDependencies, -} from "./paseo-worktree-archive-service.js"; +import type { ArchivePaseoWorktreeDependencies } from "./paseo-worktree-archive-service.js"; import { toWorktreeWireError } from "./worktree-errors.js"; +import { archivePaseoWorktreeCommand } from "./worktree/commands.js"; const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._/-]+$/; @@ -433,37 +430,23 @@ export async function handlePaseoWorktreeArchiveRequest( msg: Extract, ): Promise { const { requestId } = msg; - let targetPath = msg.worktreePath; - let repoRoot = msg.repoRoot ?? null; try { - if (!targetPath) { - if (!repoRoot || !msg.branchName) { - throw new Error("worktreePath or repoRoot+branchName is required"); - } - const worktrees = await dependencies.workspaceGitService.listWorktrees(repoRoot); - const match = worktrees.find((entry) => entry.branchName === msg.branchName); - if (!match) { - throw new Error(`Paseo worktree not found for branch ${msg.branchName}`); - } - targetPath = match.path; - } - if (!targetPath) { - throw new Error("worktreePath could not be resolved"); - } - - const ownership = await isPaseoOwnedWorktreeCwd(targetPath, { - paseoHome: dependencies.paseoHome, + const result = await archivePaseoWorktreeCommand(dependencies, { + requestId, + worktreePath: msg.worktreePath, + repoRoot: msg.repoRoot, + branchName: msg.branchName, }); - if (!ownership.allowed) { + if (!result.ok) { dependencies.emit({ type: "paseo_worktree_archive_response", payload: { success: false, - removedAgents: [], + removedAgents: result.removedAgents, error: { - code: "NOT_ALLOWED", - message: "Worktree is not a Paseo-owned worktree", + code: result.code, + message: result.message, }, requestId, }, @@ -471,23 +454,11 @@ export async function handlePaseoWorktreeArchiveRequest( return; } - // repoRoot is best-effort: if git has forgotten about the worktree we - // still proceed using the path-derived worktreesRoot, since the ownership - // check already proved the path lives under $PASEO_HOME/worktrees. - repoRoot = ownership.repoRoot ?? repoRoot ?? null; - - const removedAgents = await archivePaseoWorktree(dependencies, { - targetPath, - repoRoot, - worktreesRoot: ownership.worktreeRoot, - requestId, - }); - dependencies.emit({ type: "paseo_worktree_archive_response", payload: { success: true, - removedAgents, + removedAgents: result.removedAgents, error: null, requestId, }, diff --git a/packages/server/src/server/worktree/commands.ts b/packages/server/src/server/worktree/commands.ts new file mode 100644 index 0000000000..3046e7e556 --- /dev/null +++ b/packages/server/src/server/worktree/commands.ts @@ -0,0 +1,112 @@ +import { join } from "node:path"; + +import { getPaseoWorktreesRoot, isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js"; +import { + archivePaseoWorktree, + type ArchivePaseoWorktreeDependencies, +} from "../paseo-worktree-archive-service.js"; +import type { WorkspaceGitService } from "../workspace-git-service.js"; + +export interface ArchivePaseoWorktreeCommandDependencies extends Omit< + ArchivePaseoWorktreeDependencies, + "workspaceGitService" +> { + workspaceGitService: Pick; +} + +export interface ArchivePaseoWorktreeCommandInput { + requestId: string; + repoRoot?: string | null; + worktreePath?: string; + worktreeSlug?: string; + branchName?: string; +} + +export type ArchivePaseoWorktreeCommandResult = + | { + ok: true; + removedAgents: string[]; + } + | { + ok: false; + code: "NOT_ALLOWED"; + message: string; + removedAgents: []; + }; + +export async function archivePaseoWorktreeCommand( + dependencies: ArchivePaseoWorktreeCommandDependencies, + input: ArchivePaseoWorktreeCommandInput, +): Promise { + const resolvedTarget = await resolveArchiveTarget(dependencies, input); + const ownership = await isPaseoOwnedWorktreeCwd(resolvedTarget.targetPath, { + paseoHome: dependencies.paseoHome, + }); + + if (!ownership.allowed) { + return { + ok: false, + code: "NOT_ALLOWED", + message: "Worktree is not a Paseo-owned worktree", + removedAgents: [], + }; + } + + const repoRoot = ownership.repoRoot ?? resolvedTarget.repoRoot ?? null; + const removedAgents = await archivePaseoWorktree(dependencies, { + targetPath: resolvedTarget.targetPath, + repoRoot, + worktreesRoot: ownership.worktreeRoot, + requestId: input.requestId, + }); + + return { + ok: true, + removedAgents, + }; +} + +interface ResolvedArchiveTarget { + targetPath: string; + repoRoot: string | null; +} + +async function resolveArchiveTarget( + dependencies: ArchivePaseoWorktreeCommandDependencies, + input: ArchivePaseoWorktreeCommandInput, +): Promise { + const repoRoot = input.repoRoot ?? null; + if (input.worktreePath) { + return { targetPath: input.worktreePath, repoRoot }; + } + + if (input.worktreeSlug) { + if (!repoRoot) { + throw new Error("repoRoot is required when worktreeSlug is supplied"); + } + return { + targetPath: await resolveWorktreeSlugPath(dependencies, repoRoot, input.worktreeSlug), + repoRoot, + }; + } + + if (repoRoot && input.branchName) { + const worktrees = await dependencies.workspaceGitService.listWorktrees(repoRoot); + const match = worktrees.find((entry) => entry.branchName === input.branchName); + if (!match) { + throw new Error(`Paseo worktree not found for branch ${input.branchName}`); + } + return { targetPath: match.path, repoRoot }; + } + + throw new Error("worktreePath, worktreeSlug, or repoRoot+branchName is required"); +} + +async function resolveWorktreeSlugPath( + dependencies: ArchivePaseoWorktreeCommandDependencies, + repoRoot: string, + worktreeSlug: string, +): Promise { + const worktreesRoot = await getPaseoWorktreesRoot(repoRoot, dependencies.paseoHome); + return join(worktreesRoot, worktreeSlug); +} From e87fe927597154d5432ba2b331719c101ee10b2f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 10 May 2026 17:06:11 +0700 Subject: [PATCH 05/11] refactor(server): share worktree create list commands --- .../server/src/server/agent/mcp-server.ts | 91 +++++++------------ .../server/src/server/worktree-session.ts | 56 +++++++++--- .../server/src/server/worktree/commands.ts | 79 +++++++++++++++- 3 files changed, 153 insertions(+), 73 deletions(-) diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index cab6dc63cd..a751b9e474 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -38,11 +38,7 @@ import { createAgentCommand } from "./create-agent/create.js"; import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js"; import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../path-utils.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js"; -import type { - CreatePaseoWorktreeSetupContinuationInput, - CreatePaseoWorktreeWorkflowFn, - CreatePaseoWorktreeWorkflowResult, -} from "../worktree-session.js"; +import type { CreatePaseoWorktreeWorkflowFn } from "../worktree-session.js"; import type { ScheduleService } from "../schedule/service.js"; import { ScheduleRunSchema, @@ -76,11 +72,13 @@ import { } from "./lifecycle-command.js"; import type { GitHubService } from "../../services/github-service.js"; import type { WorkspaceGitService } from "../workspace-git-service.js"; -import type { CreatePaseoWorktreeInput } from "../paseo-worktree-service.js"; -import { toWorktreeRequestError } from "../worktree-errors.js"; +import { WorktreeRequestError } from "../worktree-errors.js"; import { archivePaseoWorktreeCommand, type ArchivePaseoWorktreeCommandDependencies, + createPaseoWorktreeCommand, + type CreatePaseoWorktreeCommandInput, + listPaseoWorktreesCommand, } from "../worktree/commands.js"; export interface AgentMcpServerOptions { @@ -2085,9 +2083,13 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom if (!options.workspaceGitService) { throw new Error("WorkspaceGitService is required to list worktrees"); } - const worktrees = await options.workspaceGitService.listWorktrees(resolvedCwd, { - reason: "mcp:list-worktrees", - }); + const worktrees = await listPaseoWorktreesCommand( + { workspaceGitService: options.workspaceGitService }, + { + cwd: resolvedCwd, + reason: "mcp:list-worktrees", + }, + ); return { content: [], @@ -2139,13 +2141,17 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, async ({ cwd, target }) => { const repoRoot = resolveScopedCwd(cwd, { required: true }); - const mcpInput = mcpCreateWorktreeInput(repoRoot, target, options.paseoHome); - const createdWorktree = await createMcpWorktree({ - input: mcpInput.input, - createPaseoWorktree: options.createPaseoWorktree, - resolveDefaultBranch: mcpInput.resolveDefaultBranch, - }); - const { worktree } = createdWorktree; + const commandResult = await createPaseoWorktreeCommand( + { + paseoHome: options.paseoHome, + createPaseoWorktreeWorkflow: options.createPaseoWorktree, + }, + createMcpWorktreeCommandInput(repoRoot, target), + ); + if (!commandResult.ok) { + throw new WorktreeRequestError(commandResult.error); + } + const { worktree } = commandResult.createdWorktree; await options.workspaceGitService?.listWorktrees?.(repoRoot, { force: true, reason: "mcp:create-worktree", @@ -2428,57 +2434,24 @@ function archiveWorktreeDependencies( }; } -function mcpCreateWorktreeInput( +function createMcpWorktreeCommandInput( repoRoot: string, target: McpCreateWorktreeTarget, - paseoHome: string | undefined, -): { input: CreatePaseoWorktreeInput; resolveDefaultBranch?: (root: string) => Promise } { - const base = { cwd: repoRoot, runSetup: false, paseoHome } as const; +): CreatePaseoWorktreeCommandInput { + const base = { cwd: repoRoot } as const; switch (target.mode) { case "branch-off": return { - input: { - ...base, - worktreeSlug: target.newBranch, - action: "branch-off", - ...(target.base ? { refName: target.base } : {}), - }, + ...base, + worktreeSlug: target.newBranch, + action: "branch-off", + ...(target.base ? { refName: target.base } : {}), }; case "checkout-branch": - return { - input: { ...base, action: "checkout", refName: target.branch }, - }; + return { ...base, action: "checkout", refName: target.branch }; case "checkout-pr": - return { - input: { ...base, action: "checkout", githubPrNumber: target.prNumber }, - }; + return { ...base, action: "checkout", githubPrNumber: target.prNumber }; default: throw new Error("unreachable"); } } - -interface CreateMcpWorktreeOptions { - input: CreatePaseoWorktreeInput; - createPaseoWorktree: CreatePaseoWorktreeWorkflowFn | undefined; - resolveDefaultBranch?: (repoRoot: string) => Promise; - setupContinuation?: CreatePaseoWorktreeSetupContinuationInput; -} - -async function createMcpWorktree( - options: CreateMcpWorktreeOptions, -): Promise { - try { - if (!options.createPaseoWorktree) { - throw new Error("Paseo worktree service is not configured"); - } - const result = await options.createPaseoWorktree(options.input, { - ...(options.resolveDefaultBranch - ? { resolveDefaultBranch: options.resolveDefaultBranch } - : {}), - ...(options.setupContinuation ? { setupContinuation: options.setupContinuation } : {}), - }); - return result; - } catch (error) { - throw toWorktreeRequestError(error); - } -} diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 4f04b6fdec..3b6a74d371 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -42,7 +42,11 @@ import type { } from "./paseo-worktree-service.js"; import type { ArchivePaseoWorktreeDependencies } from "./paseo-worktree-archive-service.js"; import { toWorktreeWireError } from "./worktree-errors.js"; -import { archivePaseoWorktreeCommand } from "./worktree/commands.js"; +import { + archivePaseoWorktreeCommand, + createPaseoWorktreeCommand, + listPaseoWorktreesCommand, +} from "./worktree/commands.js"; const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._/-]+$/; @@ -392,7 +396,10 @@ export async function handlePaseoWorktreeListRequest( } try { - const worktrees = await dependencies.workspaceGitService.listWorktrees(cwd); + const worktrees = await listPaseoWorktreesCommand( + { workspaceGitService: dependencies.workspaceGitService }, + { cwd }, + ); dependencies.emit({ type: "paseo_worktree_list_response", payload: { @@ -481,18 +488,41 @@ export async function handleCreatePaseoWorktreeRequest( request: Extract, ): Promise { try { - const createdWorktree = await dependencies.createPaseoWorktreeWorkflow({ - cwd: request.cwd, - projectId: request.projectId, - worktreeSlug: request.worktreeSlug, - firstAgentContext: normalizeFirstAgentContext(request), - refName: request.refName, - action: request.action, - githubPrNumber: request.githubPrNumber, - runSetup: false, - paseoHome: dependencies.paseoHome, - }); + const commandResult = await createPaseoWorktreeCommand( + { + paseoHome: dependencies.paseoHome, + createPaseoWorktreeWorkflow: dependencies.createPaseoWorktreeWorkflow, + }, + { + cwd: request.cwd, + projectId: request.projectId, + worktreeSlug: request.worktreeSlug, + firstAgentContext: normalizeFirstAgentContext(request), + refName: request.refName, + action: request.action, + githubPrNumber: request.githubPrNumber, + }, + ); + + if (!commandResult.ok) { + dependencies.sessionLogger.error( + { err: commandResult.cause, cwd: request.cwd, worktreeSlug: request.worktreeSlug }, + "Failed to create worktree", + ); + dependencies.emit({ + type: "create_paseo_worktree_response", + payload: { + workspace: null, + error: commandResult.error.message, + errorCode: commandResult.error.code, + setupTerminalId: null, + requestId: request.requestId, + }, + }); + return; + } + const createdWorktree = commandResult.createdWorktree; const descriptor = await dependencies.describeWorkspaceRecord(createdWorktree); dependencies.emit({ type: "create_paseo_worktree_response", diff --git a/packages/server/src/server/worktree/commands.ts b/packages/server/src/server/worktree/commands.ts index 3046e7e556..ec4e3eb525 100644 --- a/packages/server/src/server/worktree/commands.ts +++ b/packages/server/src/server/worktree/commands.ts @@ -5,7 +5,84 @@ import { archivePaseoWorktree, type ArchivePaseoWorktreeDependencies, } from "../paseo-worktree-archive-service.js"; -import type { WorkspaceGitService } from "../workspace-git-service.js"; +import type { + CreatePaseoWorktreeInput, + CreatePaseoWorktreeResult, +} from "../paseo-worktree-service.js"; +import { toWorktreeWireError, type WorktreeWireError } from "../worktree-errors.js"; +import type { WorkspaceGitService, WorkspaceGitWorktreeInfo } from "../workspace-git-service.js"; + +export interface ListPaseoWorktreesCommandDependencies { + workspaceGitService: Pick; +} + +export interface ListPaseoWorktreesCommandInput { + cwd: string; + reason?: string; +} + +export async function listPaseoWorktreesCommand( + dependencies: ListPaseoWorktreesCommandDependencies, + input: ListPaseoWorktreesCommandInput, +): Promise { + if (input.reason) { + return dependencies.workspaceGitService.listWorktrees(input.cwd, { reason: input.reason }); + } + return dependencies.workspaceGitService.listWorktrees(input.cwd); +} + +type CreatePaseoWorktreeWorkflow = ( + input: CreatePaseoWorktreeInput, +) => Promise; + +export interface CreatePaseoWorktreeCommandDependencies< + Result extends CreatePaseoWorktreeResult = CreatePaseoWorktreeResult, +> { + paseoHome?: string; + createPaseoWorktreeWorkflow?: CreatePaseoWorktreeWorkflow; +} + +export type CreatePaseoWorktreeCommandInput = Omit< + CreatePaseoWorktreeInput, + "paseoHome" | "runSetup" +> & { + paseoHome?: string; +}; + +export type CreatePaseoWorktreeCommandResult = + | { + ok: true; + createdWorktree: Result; + } + | { + ok: false; + error: WorktreeWireError; + cause: unknown; + }; + +export async function createPaseoWorktreeCommand( + dependencies: CreatePaseoWorktreeCommandDependencies, + input: CreatePaseoWorktreeCommandInput, +): Promise> { + try { + if (!dependencies.createPaseoWorktreeWorkflow) { + throw new Error("Paseo worktree service is not configured"); + } + + const createdWorktree = await dependencies.createPaseoWorktreeWorkflow({ + ...input, + runSetup: false, + paseoHome: input.paseoHome ?? dependencies.paseoHome, + }); + return { ok: true, createdWorktree }; + } catch (error) { + return { + ok: false, + error: toWorktreeWireError(error), + cause: error, + }; + } +} export interface ArchivePaseoWorktreeCommandDependencies extends Omit< ArchivePaseoWorktreeDependencies, From 05cb4436443bae0dd8ed0fcb7bd48b236251112f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 10 May 2026 18:52:18 +0700 Subject: [PATCH 06/11] test(server): update close items lifecycle fakes --- .../server/src/server/session.workspaces.test.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index f2598a59ad..770dfc53c9 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -1071,6 +1071,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy archivedAt: null, }; const killTerminal = vi.fn(); + const cancelAgentRun = vi.fn(async () => true); const session = asTestSession( new Session({ clientId: "test-client", @@ -1083,6 +1084,8 @@ test("close_items_request archives agents and kills terminals in one batch", asy subscribe: () => () => {}, listAgents: () => [], getAgent: (agentId: string) => (agentId === "agent-1" ? { id: agentId } : null), + hasInFlightRun: (agentId: string) => agentId === "agent-1", + cancelAgentRun, archiveAgent: async () => ({ archivedAt }), clearAgentAttention: async () => {}, notifyAgentState: () => {}, @@ -1175,8 +1178,6 @@ test("close_items_request archives agents and kills terminals in one batch", asy isBootstrapping: false, pendingUpdatesByAgentId: new Map(), }; - const interruptAgentIfRunning = vi.fn(); - session.interruptAgentIfRunning = interruptAgentIfRunning; await session.handleMessage({ type: "close_items_request", @@ -1185,7 +1186,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy requestId: "req-close-items", }); - expect(interruptAgentIfRunning).toHaveBeenCalledWith("agent-1"); + expect(cancelAgentRun).toHaveBeenCalledWith("agent-1"); expect(killTerminal).toHaveBeenCalledWith("term-1"); expect(emitted.find((message) => message.type === "close_items_response")?.payload).toEqual({ agents: [{ agentId: "agent-1", archivedAt }], @@ -1254,6 +1255,7 @@ test("close_items_request archives stored agents that are not currently loaded", subscribe: () => () => {}, listAgents: () => [], getAgent: (agentId: string) => (agentId === "agent-live" ? { id: agentId } : null), + hasInFlightRun: () => false, archiveAgent: async (agentId: string) => { if (agentId !== "agent-live") { throw new Error(`Unexpected live archive: ${agentId}`); @@ -1361,7 +1363,6 @@ test("close_items_request archives stored agents that are not currently loaded", isBootstrapping: false, pendingUpdatesByAgentId: new Map(), }; - session.interruptAgentIfRunning = vi.fn(); await session.handleMessage({ type: "close_items_request", @@ -1416,6 +1417,7 @@ test("close_items_request continues after an archive failure", async () => { listAgents: () => [], getAgent: (agentId: string) => agentId === "agent-bad" || agentId === "agent-good" ? { id: agentId } : null, + hasInFlightRun: () => false, archiveAgent: async (agentId: string) => { if (agentId === "agent-bad") { throw new Error("archive failed"); @@ -1513,8 +1515,6 @@ test("close_items_request continues after an archive failure", async () => { isBootstrapping: false, pendingUpdatesByAgentId: new Map(), }; - const interruptAgentIfRunningBestEffort = vi.fn(); - session.interruptAgentIfRunning = interruptAgentIfRunningBestEffort; await session.handleMessage({ type: "close_items_request", @@ -1523,8 +1523,6 @@ test("close_items_request continues after an archive failure", async () => { requestId: "req-close-best-effort", }); - expect(interruptAgentIfRunningBestEffort).toHaveBeenCalledWith("agent-bad"); - expect(interruptAgentIfRunningBestEffort).toHaveBeenCalledWith("agent-good"); expect(killTerminalBestEffort).toHaveBeenCalledWith("term-1"); expect(emitted.find((message) => message.type === "close_items_response")?.payload).toEqual({ agents: [{ agentId: "agent-good", archivedAt }], From a4a773a71aa04ba39bced50685208d3f018c907e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 16 May 2026 10:14:40 +0700 Subject: [PATCH 07/11] Fix MCP command stack CI regressions --- .../src/server/agent/create-agent/create.ts | 6 +-- .../server/agent/lifecycle-command.test.ts | 47 +++++++++++-------- .../src/server/agent/lifecycle-command.ts | 32 ++++++++----- .../src/server/agent/mcp-server.test.ts | 10 +++- .../server/src/server/agent/mcp-server.ts | 2 +- .../server/agent/permission-response.test.ts | 4 ++ packages/server/src/server/session.ts | 2 +- 7 files changed, 63 insertions(+), 40 deletions(-) diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index 424a3c6207..bfdf18aa3f 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -25,10 +25,7 @@ import type { import type { AgentStorage } from "../agent-storage.js"; import { getAgentProviderDefinition } from "../provider-manifest.js"; import type { ProviderDefinition } from "../provider-registry.js"; -import { - setupFinishNotification, - startCreatedAgentInitialPrompt, -} from "../agent-prompt.js"; +import { setupFinishNotification, startCreatedAgentInitialPrompt } from "../agent-prompt.js"; import { resolveAndValidateCreateAgentMode } from "../create-agent-mode.js"; import { resolveClientMessageId } from "../../client-message-id.js"; import { resolveRequiredProviderModel } from "../mcp-shared.js"; @@ -612,4 +609,3 @@ function isParentInUnattendedMode( } return modes.some((mode) => mode.id === modeId && mode.isUnattended === true); } - diff --git a/packages/server/src/server/agent/lifecycle-command.test.ts b/packages/server/src/server/agent/lifecycle-command.test.ts index f0cb68a7d8..b6fd5ed062 100644 --- a/packages/server/src/server/agent/lifecycle-command.test.ts +++ b/packages/server/src/server/agent/lifecycle-command.test.ts @@ -14,10 +14,16 @@ import { class FakeLifecycleAgentStorage implements LifecycleAgentStorage { readonly records = new Map(); + readonly upserts: StoredAgentRecord[] = []; async get(agentId: string): Promise { return this.records.get(agentId) ?? null; } + + async upsert(record: StoredAgentRecord): Promise { + this.upserts.push(record); + this.records.set(record.id, record); + } } class FakeLifecycleAgentManager implements LifecycleAgentManager { @@ -26,10 +32,8 @@ class FakeLifecycleAgentManager implements LifecycleAgentManager { readonly clearedAttentionAgentIds: string[] = []; readonly archivedAgentIds: string[] = []; readonly closedAgentIds: string[] = []; - readonly metadataUpdates: Array<{ - agentId: string; - updates: { title?: string; labels?: Record }; - }> = []; + readonly labelUpdates: Array<{ agentId: string; labels: Record }> = []; + readonly notifiedAgentIds: string[] = []; readonly modeUpdates: Array<{ agentId: string; modeId: string }> = []; inFlightAgentIds = new Set(); @@ -82,11 +86,12 @@ class FakeLifecycleAgentManager implements LifecycleAgentManager { this.liveAgents.delete(agentId); } - async updateAgentMetadata( - agentId: string, - updates: { title?: string; labels?: Record }, - ): Promise { - this.metadataUpdates.push({ agentId, updates }); + async setLabels(agentId: string, labels: Record): Promise { + this.labelUpdates.push({ agentId, labels }); + } + + notifyAgentState(agentId: string): void { + this.notifiedAgentIds.push(agentId); } async setAgentMode(agentId: string, modeId: string): Promise { @@ -155,11 +160,12 @@ describe("agent lifecycle commands", () => { test("normalizes metadata updates and rejects empty updates", async () => { const storage = new FakeLifecycleAgentStorage(); + storage.records.set("agent-1", storedAgent("agent-1")); const manager = new FakeLifecycleAgentManager(storage); await expect( updateAgentCommand( - { agentManager: manager }, + { agentManager: manager, agentStorage: storage }, { agentId: "agent-1", name: " Renamed agent ", @@ -168,21 +174,22 @@ describe("agent lifecycle commands", () => { ), ).resolves.toEqual({ accepted: true, error: null }); await expect( - updateAgentCommand({ agentManager: manager }, { agentId: "agent-1", name: " " }), + updateAgentCommand( + { agentManager: manager, agentStorage: storage }, + { agentId: "agent-1", name: " " }, + ), ).resolves.toEqual({ accepted: false, error: "Nothing to update (provide name and/or labels)", }); - expect(manager.metadataUpdates).toEqual([ - { - agentId: "agent-1", - updates: { - title: "Renamed agent", - labels: { team: "infra" }, - }, - }, - ]); + expect(storage.upserts).toHaveLength(1); + expect(storage.upserts[0]).toMatchObject({ + id: "agent-1", + title: "Renamed agent", + }); + expect(manager.notifiedAgentIds).toEqual(["agent-1"]); + expect(manager.labelUpdates).toEqual([{ agentId: "agent-1", labels: { team: "infra" } }]); }); test("sets an agent mode and returns the accepted mode", async () => { diff --git a/packages/server/src/server/agent/lifecycle-command.ts b/packages/server/src/server/agent/lifecycle-command.ts index 800c7d0c6a..b9a7a6f6d6 100644 --- a/packages/server/src/server/agent/lifecycle-command.ts +++ b/packages/server/src/server/agent/lifecycle-command.ts @@ -13,18 +13,14 @@ export interface LifecycleAgentManager { archiveAgent(agentId: string): Promise<{ archivedAt: string }>; archiveSnapshot(agentId: string, archivedAt: string): Promise; closeAgent(agentId: string): Promise; - updateAgentMetadata( - agentId: string, - updates: { - title?: string; - labels?: Record; - }, - ): Promise; + setLabels(agentId: string, labels: Record): Promise; + notifyAgentState(agentId: string): void; setAgentMode(agentId: string, modeId: string): Promise; } export interface LifecycleAgentStorage { get(agentId: string): Promise; + upsert(record: StoredAgentRecord): Promise; } export interface AgentLifecycleCommandDependencies { @@ -129,7 +125,7 @@ export interface UpdateAgentResult { } export async function updateAgentCommand( - dependencies: Pick, + dependencies: Pick, input: { agentId: string; name?: string; @@ -146,10 +142,22 @@ export async function updateAgentCommand( }; } - await dependencies.agentManager.updateAgentMetadata(input.agentId, { - ...(title ? { title } : {}), - ...(labels ? { labels } : {}), - }); + if (title) { + const record = await dependencies.agentStorage.get(input.agentId); + if (!record) { + throw new Error(`Agent ${input.agentId} not found`); + } + await dependencies.agentStorage.upsert({ + ...record, + title, + updatedAt: new Date().toISOString(), + }); + dependencies.agentManager.notifyAgentState(input.agentId); + } + + if (labels) { + await dependencies.agentManager.setLabels(input.agentId, labels); + } return { accepted: true, diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index f34ecbf837..63bc2e5726 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -560,6 +560,7 @@ describe("create_agent MCP tool", () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.createAgent.mockResolvedValue({ id: "mode-agent", + provider: "codex", cwd: REPO_CWD, lifecycle: "idle", currentModeId: "build", @@ -1308,6 +1309,8 @@ describe("create_agent MCP tool", () => { const workspaceGitService = { getSnapshot: vi.fn(async () => null), + listWorktrees: vi.fn(async () => []), + resolveRepoRoot: vi.fn(async () => repoDir), }; const server = await createAgentMcpServer({ agentManager, @@ -1316,7 +1319,7 @@ describe("create_agent MCP tool", () => { createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts: [] }), workspaceGitService: workspaceGitService as unknown as Pick< WorkspaceGitService, - "getSnapshot" | "listWorktrees" + "getSnapshot" | "listWorktrees" | "resolveRepoRoot" >, archiveWorkspaceRecord: vi.fn(async () => undefined), emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => undefined), @@ -1343,6 +1346,11 @@ describe("create_agent MCP tool", () => { force: true, reason: "archive-worktree", }); + expect(workspaceGitService.resolveRepoRoot).toHaveBeenCalledWith(repoDir); + expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(repoDir, { + force: true, + reason: "mcp:archive-worktree", + }); await expect( access(z.string().parse(created.structuredContent.worktreePath)), ).rejects.toThrow(); diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index a751b9e474..1a218a4b68 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -1437,7 +1437,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom } const metadataResult = await updateAgentCommand( - { agentManager }, + { agentManager, agentStorage }, { agentId, name, labels }, ); diff --git a/packages/server/src/server/agent/permission-response.test.ts b/packages/server/src/server/agent/permission-response.test.ts index 380f2f6dbe..89b320b00e 100644 --- a/packages/server/src/server/agent/permission-response.test.ts +++ b/packages/server/src/server/agent/permission-response.test.ts @@ -36,6 +36,10 @@ class FakePermissionAgentManager { return this.outOfBandHandled; } + getAgent() { + return undefined; + } + hasInFlightRun(): boolean { return this.hasRunInFlight; } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 94fc7daeb1..f5291ce10e 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -2490,7 +2490,7 @@ export class Session { try { const result = await updateAgentCommand( - { agentManager: this.agentManager }, + { agentManager: this.agentManager, agentStorage: this.agentStorage }, { agentId, name, labels }, ); From 9243cead6e8e790db153e1272426d46cb11a4133 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 27 May 2026 00:00:15 +0700 Subject: [PATCH 08/11] Restore MCP update_agent no-op success semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the rebase, MCP `update_agent` was returning `success: false` for empty/no-op calls (no name, no labels, no settings). Origin/main returned `success: true` unconditionally. The audit flagged the change as out of scope for this stack — restore the old behavior at the MCP boundary. Session WS path keeps its accepted/rejected semantics (it surfaces an error when nothing was provided so the client can prompt the user). --- .../server/src/server/agent/mcp-server.test.ts | 16 ++++++++++++++++ packages/server/src/server/agent/mcp-server.ts | 14 ++------------ 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 63bc2e5726..d9c3969ae3 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -1788,6 +1788,22 @@ describe("update_agent MCP tool", () => { 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, logger }); + const tool = registeredTool(server, "update_agent"); + + const response = await tool.handler({ agentId: "agent-1" }); + + expect(response.structuredContent).toEqual({ success: true }); + expect(spies.agentStorage.upsert).not.toHaveBeenCalled(); + expect(spies.agentManager.setLabels).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")); diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 1a218a4b68..221f7ca0c7 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -1416,36 +1416,26 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, }, async ({ agentId, name, labels, settings }) => { - let appliedSettings = false; if (settings?.modeId !== undefined) { await agentManager.setAgentMode(agentId, settings.modeId); - appliedSettings = true; } if (settings?.model !== undefined) { await agentManager.setAgentModel(agentId, settings.model); - appliedSettings = true; } if (settings?.thinkingOptionId !== undefined) { await agentManager.setAgentThinkingOption(agentId, settings.thinkingOptionId); - appliedSettings = true; } if (settings?.features) { for (const [featureId, value] of Object.entries(settings.features)) { await agentManager.setAgentFeature(agentId, featureId, value); } - appliedSettings = true; } - const metadataResult = await updateAgentCommand( - { agentManager, agentStorage }, - { agentId, name, labels }, - ); + await updateAgentCommand({ agentManager, agentStorage }, { agentId, name, labels }); return { content: [], - structuredContent: ensureValidJson({ - success: metadataResult.accepted || appliedSettings, - }), + structuredContent: ensureValidJson({ success: true }), }; }, ); From 4262affe7627221df9c0f7bc38b2325b62dd28fc Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 27 May 2026 00:04:38 +0700 Subject: [PATCH 09/11] Fix agent metadata delegation and mode persistence --- .../server/agent/lifecycle-command.test.ts | 38 +++++++++++++------ .../src/server/agent/lifecycle-command.ts | 29 ++++++-------- .../server/src/server/agent/mcp-server.ts | 2 +- packages/server/src/server/session.ts | 2 +- 4 files changed, 40 insertions(+), 31 deletions(-) diff --git a/packages/server/src/server/agent/lifecycle-command.test.ts b/packages/server/src/server/agent/lifecycle-command.test.ts index b6fd5ed062..fe7bba93d7 100644 --- a/packages/server/src/server/agent/lifecycle-command.test.ts +++ b/packages/server/src/server/agent/lifecycle-command.test.ts @@ -32,6 +32,10 @@ class FakeLifecycleAgentManager implements LifecycleAgentManager { readonly clearedAttentionAgentIds: string[] = []; readonly archivedAgentIds: string[] = []; readonly closedAgentIds: string[] = []; + readonly metadataUpdates: Array<{ + agentId: string; + updates: { title?: string; labels?: Record }; + }> = []; readonly labelUpdates: Array<{ agentId: string; labels: Record }> = []; readonly notifiedAgentIds: string[] = []; readonly modeUpdates: Array<{ agentId: string; modeId: string }> = []; @@ -97,6 +101,16 @@ class FakeLifecycleAgentManager implements LifecycleAgentManager { async setAgentMode(agentId: string, modeId: string): Promise { this.modeUpdates.push({ agentId, modeId }); } + + async updateAgentMetadata( + agentId: string, + updates: { + title?: string; + labels?: Record; + }, + ): Promise { + this.metadataUpdates.push({ agentId, updates }); + } } const logger = createTestLogger(); @@ -165,7 +179,7 @@ describe("agent lifecycle commands", () => { await expect( updateAgentCommand( - { agentManager: manager, agentStorage: storage }, + { agentManager: manager }, { agentId: "agent-1", name: " Renamed agent ", @@ -174,22 +188,22 @@ describe("agent lifecycle commands", () => { ), ).resolves.toEqual({ accepted: true, error: null }); await expect( - updateAgentCommand( - { agentManager: manager, agentStorage: storage }, - { agentId: "agent-1", name: " " }, - ), + updateAgentCommand({ agentManager: manager }, { agentId: "agent-1", name: " " }), ).resolves.toEqual({ accepted: false, error: "Nothing to update (provide name and/or labels)", }); - expect(storage.upserts).toHaveLength(1); - expect(storage.upserts[0]).toMatchObject({ - id: "agent-1", - title: "Renamed agent", - }); - expect(manager.notifiedAgentIds).toEqual(["agent-1"]); - expect(manager.labelUpdates).toEqual([{ agentId: "agent-1", labels: { team: "infra" } }]); + expect(storage.upserts).toHaveLength(0); + expect(manager.metadataUpdates).toEqual([ + { + agentId: "agent-1", + updates: { + title: "Renamed agent", + labels: { team: "infra" }, + }, + }, + ]); }); test("sets an agent mode and returns the accepted mode", async () => { diff --git a/packages/server/src/server/agent/lifecycle-command.ts b/packages/server/src/server/agent/lifecycle-command.ts index b9a7a6f6d6..1a88c7e45d 100644 --- a/packages/server/src/server/agent/lifecycle-command.ts +++ b/packages/server/src/server/agent/lifecycle-command.ts @@ -16,6 +16,13 @@ export interface LifecycleAgentManager { setLabels(agentId: string, labels: Record): Promise; notifyAgentState(agentId: string): void; setAgentMode(agentId: string, modeId: string): Promise; + updateAgentMetadata( + agentId: string, + updates: { + title?: string; + labels?: Record; + }, + ): Promise; } export interface LifecycleAgentStorage { @@ -125,7 +132,7 @@ export interface UpdateAgentResult { } export async function updateAgentCommand( - dependencies: Pick, + dependencies: Pick, input: { agentId: string; name?: string; @@ -142,22 +149,10 @@ export async function updateAgentCommand( }; } - if (title) { - const record = await dependencies.agentStorage.get(input.agentId); - if (!record) { - throw new Error(`Agent ${input.agentId} not found`); - } - await dependencies.agentStorage.upsert({ - ...record, - title, - updatedAt: new Date().toISOString(), - }); - dependencies.agentManager.notifyAgentState(input.agentId); - } - - if (labels) { - await dependencies.agentManager.setLabels(input.agentId, labels); - } + await dependencies.agentManager.updateAgentMetadata(input.agentId, { + ...(title ? { title } : {}), + ...(labels ? { labels } : {}), + }); return { accepted: true, diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 221f7ca0c7..f879896968 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -1431,7 +1431,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom } } - await updateAgentCommand({ agentManager, agentStorage }, { agentId, name, labels }); + await updateAgentCommand({ agentManager }, { agentId, name, labels }); return { content: [], diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index f5291ce10e..94fc7daeb1 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -2490,7 +2490,7 @@ export class Session { try { const result = await updateAgentCommand( - { agentManager: this.agentManager, agentStorage: this.agentStorage }, + { agentManager: this.agentManager }, { agentId, name, labels }, ); From 9d32d582cc1af1fa8de657dd26d55bebf993baa2 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 27 May 2026 00:16:00 +0700 Subject: [PATCH 10/11] Update MCP update_agent test for metadata delegation --- .../src/server/agent/mcp-server.test.ts | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index d9c3969ae3..79d448daf4 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -146,6 +146,7 @@ function buildAgentManagerSpies() { 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(), @@ -1754,7 +1755,6 @@ describe("update_agent MCP tool", () => { it("updates runtime settings before metadata", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); - spies.agentStorage.get.mockResolvedValue(createStoredRecord({ id: "agent-1" })); const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); const tool = registeredTool(server, "update_agent"); const input = { @@ -1778,13 +1778,10 @@ describe("update_agent MCP tool", () => { 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.agentStorage.upsert).toHaveBeenCalledWith( - expect.objectContaining({ - id: "agent-1", - title: "Updated agent", - }), - ); - expect(spies.agentManager.setLabels).toHaveBeenCalledWith("agent-1", { role: "worker" }); + expect(spies.agentManager.updateAgentMetadata).toHaveBeenCalledWith("agent-1", { + title: "Updated agent", + labels: { role: "worker" }, + }); expect(response.structuredContent).toEqual({ success: true }); }); @@ -1796,8 +1793,7 @@ describe("update_agent MCP tool", () => { const response = await tool.handler({ agentId: "agent-1" }); expect(response.structuredContent).toEqual({ success: true }); - expect(spies.agentStorage.upsert).not.toHaveBeenCalled(); - expect(spies.agentManager.setLabels).not.toHaveBeenCalled(); + expect(spies.agentManager.updateAgentMetadata).not.toHaveBeenCalled(); expect(spies.agentManager.setAgentMode).not.toHaveBeenCalled(); expect(spies.agentManager.setAgentModel).not.toHaveBeenCalled(); expect(spies.agentManager.setAgentThinkingOption).not.toHaveBeenCalled(); @@ -1820,8 +1816,7 @@ describe("update_agent MCP tool", () => { ).rejects.toThrow("unsupported feature"); expect(spies.agentStorage.get).not.toHaveBeenCalled(); - expect(spies.agentStorage.upsert).not.toHaveBeenCalled(); - expect(spies.agentManager.setLabels).not.toHaveBeenCalled(); + expect(spies.agentManager.updateAgentMetadata).not.toHaveBeenCalled(); }); }); From b950153e2e60645945e75ac281200348a306ca96 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 27 May 2026 00:18:34 +0700 Subject: [PATCH 11/11] Fix stored agent metadata timestamps --- .../src/server/agent/agent-manager.test.ts | 34 +++++++++++++++++++ .../server/src/server/agent/agent-manager.ts | 8 +++++ 2 files changed, 42 insertions(+) diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 711fb91108..0d1e443461 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -1638,6 +1638,40 @@ test("setTitle bumps updatedAt and persists title in the same snapshot write", a expect(live!.updatedAt.getTime()).toBeGreaterThan(Date.parse(before!.updatedAt)); }); +test("updateAgentMetadata bumps updatedAt for stored agents", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-stored-metadata-updated-at-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000128", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + await manager.closeAgent(snapshot.id); + + const before = await storage.get(snapshot.id); + expect(before).not.toBeNull(); + expect(manager.getAgent(snapshot.id)).toBeNull(); + + await manager.updateAgentMetadata(snapshot.id, { + title: "Stored title", + labels: { role: "worker" }, + }); + + const after = await storage.get(snapshot.id); + expect(after?.title).toBe("Stored title"); + expect(after?.labels).toEqual({ role: "worker" }); + expect(Date.parse(after!.updatedAt)).toBeGreaterThan(Date.parse(before!.updatedAt)); +}); + test("setGeneratedTitle persists generated title when no title exists", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-generated-title-empty-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 91a43cda36..01db032d2d 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -539,6 +539,13 @@ export class AgentManager { return next; } + private nextStoredUpdatedAt(record: StoredAgentRecord): string { + const previousMs = Date.parse(record.updatedAt); + const nowMs = Date.now(); + const nextMs = nowMs > previousMs ? nowMs : previousMs + 1; + return new Date(nextMs).toISOString(); + } + hasInFlightRun(agentId: string): boolean { const agent = this.agents.get(agentId); if (!agent) { @@ -1360,6 +1367,7 @@ export class AgentManager { ...existing, ...(updates.title ? { title: updates.title } : {}), ...(updates.labels ? { labels: { ...existing.labels, ...updates.labels } } : {}), + updatedAt: this.nextStoredUpdatedAt(existing), }); }