refactor(server): integrate session MCP command stack (#893)
* Extract permission response command * Extract create agent command * Extract agent lifecycle commands * refactor(server): extract worktree archive command * refactor(server): share worktree create list commands * test(server): update close items lifecycle fakes * Fix MCP command stack CI regressions * Restore MCP update_agent no-op success semantics 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). * Fix agent metadata delegation and mode persistence * Update MCP update_agent test for metadata delegation * Fix stored agent metadata timestamps
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,611 @@
|
||||
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<string, unknown> | undefined,
|
||||
enabled: boolean,
|
||||
): Record<string, unknown> {
|
||||
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<AgentProvider, ProviderDefinition> | null;
|
||||
createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn;
|
||||
}
|
||||
|
||||
export interface CreateAgentFromSessionInput {
|
||||
kind: "session";
|
||||
config: AgentSessionConfig;
|
||||
workspaceId?: string;
|
||||
worktreeName?: string;
|
||||
initialPrompt?: string;
|
||||
clientMessageId?: string;
|
||||
outputSchema?: Record<string, unknown>;
|
||||
images?: Array<{ data: string; mimeType: string }>;
|
||||
attachments?: AgentAttachment[];
|
||||
git?: GitSetupOptions;
|
||||
labels: Record<string, string>;
|
||||
env?: Record<string, string>;
|
||||
provisionalTitle: string | null;
|
||||
explicitTitle: string | null;
|
||||
firstAgentContext: FirstAgentContext;
|
||||
buildSessionConfig: (
|
||||
config: AgentSessionConfig,
|
||||
gitOptions?: GitSetupOptions,
|
||||
legacyWorktreeName?: string,
|
||||
firstAgentContext?: FirstAgentContext,
|
||||
) => Promise<CreateAgentSessionWorktreeResult>;
|
||||
resolveWorkspace: (input: { cwd: string; workspaceId?: string }) => Promise<CreateAgentWorkspace>;
|
||||
}
|
||||
|
||||
export interface CreateAgentFromMcpInput {
|
||||
kind: "mcp";
|
||||
provider: string;
|
||||
title: string;
|
||||
initialPrompt: string;
|
||||
cwd?: string;
|
||||
thinking?: string;
|
||||
features?: Record<string, unknown>;
|
||||
labels?: Record<string, string>;
|
||||
mode?: string;
|
||||
background: boolean;
|
||||
notifyOnFinish: boolean;
|
||||
callerAgentId?: string;
|
||||
callerContext?: {
|
||||
lockedCwd?: string;
|
||||
allowCustomCwd?: boolean;
|
||||
childAgentDefaultLabels?: Record<string, string>;
|
||||
} | 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<string, string>;
|
||||
workspaceId?: string;
|
||||
initialPrompt?: string;
|
||||
env?: Record<string, string>;
|
||||
initialTitle?: string | null;
|
||||
}
|
||||
|
||||
export async function createAgentCommand(
|
||||
dependencies: CreateAgentCommandDependencies,
|
||||
input: CreateAgentCommandInput,
|
||||
): Promise<CreateAgentCommandResult> {
|
||||
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<ResolvedCreateAgent> {
|
||||
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<ResolvedCreateAgent> {
|
||||
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<string, unknown> | undefined;
|
||||
},
|
||||
): { mode: string | undefined; features: Record<string, unknown> | 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<string>;
|
||||
setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
|
||||
}
|
||||
|
||||
async function createMcpWorktree(
|
||||
options: CreateMcpWorktreeOptions,
|
||||
): Promise<CreatePaseoWorktreeWorkflowResult> {
|
||||
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<string, string> | undefined,
|
||||
labels: Record<string, string> | undefined,
|
||||
): Record<string, string> | 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);
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
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<string, StoredAgentRecord>();
|
||||
readonly upserts: StoredAgentRecord[] = [];
|
||||
|
||||
async get(agentId: string): Promise<StoredAgentRecord | null> {
|
||||
return this.records.get(agentId) ?? null;
|
||||
}
|
||||
|
||||
async upsert(record: StoredAgentRecord): Promise<void> {
|
||||
this.upserts.push(record);
|
||||
this.records.set(record.id, record);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeLifecycleAgentManager implements LifecycleAgentManager {
|
||||
readonly liveAgents = new Map<string, LifecycleAgentSnapshot>();
|
||||
readonly cancelledAgentIds: string[] = [];
|
||||
readonly clearedAttentionAgentIds: string[] = [];
|
||||
readonly archivedAgentIds: string[] = [];
|
||||
readonly closedAgentIds: string[] = [];
|
||||
readonly metadataUpdates: Array<{
|
||||
agentId: string;
|
||||
updates: { title?: string; labels?: Record<string, string> };
|
||||
}> = [];
|
||||
readonly labelUpdates: Array<{ agentId: string; labels: Record<string, string> }> = [];
|
||||
readonly notifiedAgentIds: string[] = [];
|
||||
readonly modeUpdates: Array<{ agentId: string; modeId: string }> = [];
|
||||
inFlightAgentIds = new Set<string>();
|
||||
|
||||
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<boolean> {
|
||||
this.cancelledAgentIds.push(agentId);
|
||||
return this.inFlightAgentIds.delete(agentId);
|
||||
}
|
||||
|
||||
async clearAgentAttention(agentId: string): Promise<void> {
|
||||
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<StoredAgentRecord> {
|
||||
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<void> {
|
||||
this.closedAgentIds.push(agentId);
|
||||
this.liveAgents.delete(agentId);
|
||||
}
|
||||
|
||||
async setLabels(agentId: string, labels: Record<string, string>): Promise<void> {
|
||||
this.labelUpdates.push({ agentId, labels });
|
||||
}
|
||||
|
||||
notifyAgentState(agentId: string): void {
|
||||
this.notifiedAgentIds.push(agentId);
|
||||
}
|
||||
|
||||
async setAgentMode(agentId: string, modeId: string): Promise<void> {
|
||||
this.modeUpdates.push({ agentId, modeId });
|
||||
}
|
||||
|
||||
async updateAgentMetadata(
|
||||
agentId: string,
|
||||
updates: {
|
||||
title?: string;
|
||||
labels?: Record<string, string>;
|
||||
},
|
||||
): Promise<void> {
|
||||
this.metadataUpdates.push({ agentId, updates });
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
storage.records.set("agent-1", storedAgent("agent-1"));
|
||||
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(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 () => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type { ManagedAgent } from "./agent-manager.js";
|
||||
import type { StoredAgentRecord } from "./agent-storage.js";
|
||||
|
||||
export type LifecycleAgentSnapshot = Pick<ManagedAgent, "id" | "cwd" | "lifecycle">;
|
||||
|
||||
export interface LifecycleAgentManager {
|
||||
getAgent(agentId: string): LifecycleAgentSnapshot | null;
|
||||
hasInFlightRun(agentId: string): boolean;
|
||||
cancelAgentRun(agentId: string): Promise<boolean>;
|
||||
clearAgentAttention(agentId: string): Promise<void>;
|
||||
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
|
||||
archiveSnapshot(agentId: string, archivedAt: string): Promise<StoredAgentRecord>;
|
||||
closeAgent(agentId: string): Promise<void>;
|
||||
setLabels(agentId: string, labels: Record<string, string>): Promise<void>;
|
||||
notifyAgentState(agentId: string): void;
|
||||
setAgentMode(agentId: string, modeId: string): Promise<void>;
|
||||
updateAgentMetadata(
|
||||
agentId: string,
|
||||
updates: {
|
||||
title?: string;
|
||||
labels?: Record<string, string>;
|
||||
},
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
export interface LifecycleAgentStorage {
|
||||
get(agentId: string): Promise<StoredAgentRecord | null>;
|
||||
upsert(record: StoredAgentRecord): Promise<void>;
|
||||
}
|
||||
|
||||
export interface AgentLifecycleCommandDependencies {
|
||||
agentManager: LifecycleAgentManager;
|
||||
agentStorage: LifecycleAgentStorage;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export interface CancelAgentRunResult {
|
||||
agent: LifecycleAgentSnapshot;
|
||||
cancelled: boolean;
|
||||
}
|
||||
|
||||
export async function cancelAgentRunCommand(
|
||||
dependencies: Pick<AgentLifecycleCommandDependencies, "agentManager" | "logger">,
|
||||
agentId: string,
|
||||
): Promise<CancelAgentRunResult> {
|
||||
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<ArchiveAgentResult> {
|
||||
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<AgentLifecycleCommandDependencies, "agentManager">,
|
||||
agentId: string,
|
||||
): Promise<void> {
|
||||
await dependencies.agentManager.closeAgent(agentId);
|
||||
}
|
||||
|
||||
export interface UpdateAgentResult {
|
||||
accepted: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export async function updateAgentCommand(
|
||||
dependencies: Pick<AgentLifecycleCommandDependencies, "agentManager">,
|
||||
input: {
|
||||
agentId: string;
|
||||
name?: string;
|
||||
labels?: Record<string, string>;
|
||||
},
|
||||
): Promise<UpdateAgentResult> {
|
||||
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<AgentLifecycleCommandDependencies, "agentManager">,
|
||||
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<AgentLifecycleCommandDependencies, "agentManager" | "agentStorage">,
|
||||
agentId: string,
|
||||
): Promise<void> {
|
||||
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);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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(),
|
||||
@@ -560,6 +561,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",
|
||||
@@ -1282,6 +1284,82 @@ 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),
|
||||
listWorktrees: vi.fn(async () => []),
|
||||
resolveRepoRoot: vi.fn(async () => repoDir),
|
||||
};
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
paseoHome,
|
||||
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts: [] }),
|
||||
workspaceGitService: workspaceGitService as unknown as Pick<
|
||||
WorkspaceGitService,
|
||||
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
|
||||
>,
|
||||
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",
|
||||
});
|
||||
expect(workspaceGitService.resolveRepoRoot).toHaveBeenCalledWith(repoDir);
|
||||
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(repoDir, {
|
||||
force: true,
|
||||
reason: "mcp:archive-worktree",
|
||||
});
|
||||
await expect(
|
||||
access(z.string().parse(created.structuredContent.worktreePath)),
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("routes list_worktrees through WorkspaceGitService", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const workspaceGitService = {
|
||||
@@ -1677,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 = {
|
||||
@@ -1701,16 +1778,28 @@ 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 });
|
||||
});
|
||||
|
||||
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.agentManager.updateAgentMetadata).not.toHaveBeenCalled();
|
||||
expect(spies.agentManager.setAgentMode).not.toHaveBeenCalled();
|
||||
expect(spies.agentManager.setAgentModel).not.toHaveBeenCalled();
|
||||
expect(spies.agentManager.setAgentThinkingOption).not.toHaveBeenCalled();
|
||||
expect(spies.agentManager.setAgentFeature).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not update metadata when runtime settings fail", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.setAgentFeature.mockRejectedValue(new Error("unsupported feature"));
|
||||
@@ -1727,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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -30,27 +30,15 @@ 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,
|
||||
killTerminalsUnderPath,
|
||||
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,
|
||||
} from "../worktree-session.js";
|
||||
import type { CreatePaseoWorktreeWorkflowFn } from "../worktree-session.js";
|
||||
import type { ScheduleService } from "../schedule/service.js";
|
||||
import {
|
||||
ScheduleRunSchema,
|
||||
@@ -59,8 +47,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,16 +61,25 @@ import {
|
||||
toScheduleSummary,
|
||||
waitForAgentWithTimeout,
|
||||
} from "./mcp-shared.js";
|
||||
import { sendPromptToAgent, setupFinishNotification } from "./agent-prompt.js";
|
||||
import { respondToAgentPermission } from "./permission-response.js";
|
||||
import {
|
||||
sendPromptToAgent,
|
||||
setupFinishNotification,
|
||||
startCreatedAgentInitialPrompt,
|
||||
} from "./agent-prompt.js";
|
||||
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";
|
||||
import { toWorktreeRequestError } from "../worktree-errors.js";
|
||||
import { join } from "node:path";
|
||||
import { WorktreeRequestError } from "../worktree-errors.js";
|
||||
import {
|
||||
archivePaseoWorktreeCommand,
|
||||
type ArchivePaseoWorktreeCommandDependencies,
|
||||
createPaseoWorktreeCommand,
|
||||
type CreatePaseoWorktreeCommandInput,
|
||||
listPaseoWorktreesCommand,
|
||||
} from "../worktree/commands.js";
|
||||
|
||||
export interface AgentMcpServerOptions {
|
||||
agentManager: AgentManager;
|
||||
@@ -134,42 +129,6 @@ const CODEX_TO_CLAUDE_MODE: Record<string, string> = {
|
||||
"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<string, unknown> | undefined,
|
||||
enabled: boolean,
|
||||
): Record<string, unknown> {
|
||||
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,
|
||||
@@ -894,6 +853,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
"Draft provider settings used to compute available features.",
|
||||
),
|
||||
};
|
||||
type TopLevelCreateAgentArgs = z.infer<typeof topLevelCreateAgentArgsSchema>;
|
||||
|
||||
if (options.voiceOnly || options.enableVoiceTools || callerContext?.enableVoiceTools) {
|
||||
registerTool(
|
||||
@@ -938,208 +898,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<string, unknown> | undefined;
|
||||
labels: Record<string, string> | 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<string, unknown> | undefined;
|
||||
}): { mode: string | undefined; features: Record<string, unknown> | 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<ResolvedCreateAgentArgs> => {
|
||||
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",
|
||||
{
|
||||
@@ -1159,88 +917,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,
|
||||
@@ -1262,12 +978,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,
|
||||
@@ -1280,6 +996,43 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
);
|
||||
|
||||
function resolveCreateAgentToolArgs(args: unknown): {
|
||||
parsedArgs:
|
||||
| z.infer<typeof agentToAgentCreateAgentArgsSchema>
|
||||
| z.infer<typeof topLevelCreateAgentArgsSchema>;
|
||||
worktree: ReturnType<typeof resolveTopLevelCreateAgentWorktree>;
|
||||
} {
|
||||
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",
|
||||
{
|
||||
@@ -1579,13 +1332,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 }),
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -1604,7 +1360,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: [],
|
||||
@@ -1626,7 +1389,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: [],
|
||||
@@ -1668,23 +1431,7 @@ 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 updateAgentCommand({ agentManager }, { agentId, name, labels });
|
||||
|
||||
return {
|
||||
content: [],
|
||||
@@ -2326,9 +2073,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: [],
|
||||
@@ -2380,13 +2131,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",
|
||||
@@ -2424,64 +2179,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",
|
||||
@@ -2566,10 +2285,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 }),
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -2624,7 +2343,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 }),
|
||||
@@ -2640,57 +2365,83 @@ type McpCreateWorktreeTarget =
|
||||
| { mode: "checkout-branch"; branch: string }
|
||||
| { mode: "checkout-pr"; prNumber: number };
|
||||
|
||||
function mcpCreateWorktreeInput(
|
||||
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 createMcpWorktreeCommandInput(
|
||||
repoRoot: string,
|
||||
target: McpCreateWorktreeTarget,
|
||||
paseoHome: string | undefined,
|
||||
): { input: CreatePaseoWorktreeInput; resolveDefaultBranch?: (root: string) => Promise<string> } {
|
||||
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<string>;
|
||||
setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
|
||||
}
|
||||
|
||||
async function createMcpWorktree(
|
||||
options: CreateMcpWorktreeOptions,
|
||||
): Promise<CreatePaseoWorktreeWorkflowResult> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
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<AgentPermissionResult | void> {
|
||||
this.permissionResponses.push({ agentId, requestId, response });
|
||||
return this.permissionResult;
|
||||
}
|
||||
|
||||
tryRunOutOfBand(): boolean {
|
||||
return this.outOfBandHandled;
|
||||
}
|
||||
|
||||
getAgent() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
hasInFlightRun(): boolean {
|
||||
return this.hasRunInFlight;
|
||||
}
|
||||
|
||||
streamAgent(
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
options?: AgentRunOptions,
|
||||
): AsyncGenerator<AgentStreamEvent> {
|
||||
this.streamRuns.push({ agentId, prompt, options });
|
||||
return emptyAgentStream();
|
||||
}
|
||||
|
||||
replaceAgentRun(
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
options?: AgentRunOptions,
|
||||
): AsyncGenerator<AgentStreamEvent> {
|
||||
this.replacementRuns.push({ agentId, prompt, options });
|
||||
return emptyAgentStream();
|
||||
}
|
||||
}
|
||||
|
||||
async function* emptyAgentStream(): AsyncGenerator<AgentStreamEvent> {}
|
||||
|
||||
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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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<AgentPermissionResult | void>;
|
||||
}
|
||||
|
||||
export interface RespondToAgentPermissionParams {
|
||||
agentManager: PermissionResponseAgentManager;
|
||||
agentId: string;
|
||||
requestId: string;
|
||||
response: AgentPermissionResponse;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export async function respondToAgentPermission(
|
||||
params: RespondToAgentPermissionParams,
|
||||
): Promise<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -68,10 +68,11 @@ 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";
|
||||
import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js";
|
||||
@@ -106,8 +107,14 @@ 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 {
|
||||
archiveAgentCommand,
|
||||
cancelAgentRunCommand,
|
||||
closeAgentCommand,
|
||||
setAgentModeCommand,
|
||||
updateAgentCommand,
|
||||
} from "./agent/lifecycle-command.js";
|
||||
import {
|
||||
buildStoredAgentPayload,
|
||||
resolveEffectiveThinkingOptionId,
|
||||
@@ -138,7 +145,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";
|
||||
@@ -213,7 +219,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,
|
||||
@@ -1170,52 +1175,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<AgentStreamEvent>;
|
||||
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}`);
|
||||
@@ -2357,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 },
|
||||
@@ -2411,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);
|
||||
@@ -2480,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<void> {
|
||||
@@ -2559,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",
|
||||
@@ -3173,7 +3099,6 @@ export class Session {
|
||||
configTitle: config.title,
|
||||
initialPrompt: trimmedPrompt,
|
||||
});
|
||||
const resolvedConfig: AgentSessionConfig = config;
|
||||
|
||||
const firstAgentContext: FirstAgentContext = {
|
||||
...(trimmedPrompt ? { prompt: trimmedPrompt } : {}),
|
||||
@@ -3187,28 +3112,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({
|
||||
@@ -3217,16 +3153,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({
|
||||
@@ -3240,10 +3166,6 @@ export class Session {
|
||||
});
|
||||
}
|
||||
|
||||
setupContinuation?.startAfterAgentCreate({
|
||||
agentId: snapshot.id,
|
||||
});
|
||||
|
||||
this.sessionLogger.info(
|
||||
{ agentId: snapshot.id, provider: snapshot.provider },
|
||||
`Created agent ${snapshot.id} (${snapshot.provider})`,
|
||||
@@ -3278,44 +3200,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<string, unknown> | undefined;
|
||||
explicitTitle: string | null;
|
||||
}): Promise<ManagedAgent> {
|
||||
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;
|
||||
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}`);
|
||||
}
|
||||
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),
|
||||
}),
|
||||
});
|
||||
return { workspaceId: resolvedWorkspace.workspaceId };
|
||||
}
|
||||
|
||||
private async handleResumeAgentRequest(
|
||||
@@ -3541,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;
|
||||
@@ -4411,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",
|
||||
@@ -4732,22 +4631,14 @@ export class Session {
|
||||
requestId: string,
|
||||
response: AgentPermissionResponse,
|
||||
): Promise<void> {
|
||||
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 },
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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,13 @@ 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,
|
||||
createPaseoWorktreeCommand,
|
||||
listPaseoWorktreesCommand,
|
||||
} from "./worktree/commands.js";
|
||||
|
||||
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._/-]+$/;
|
||||
|
||||
@@ -395,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: {
|
||||
@@ -433,37 +437,23 @@ export async function handlePaseoWorktreeArchiveRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_archive_request" }>,
|
||||
): Promise<void> {
|
||||
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 +461,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,
|
||||
},
|
||||
@@ -510,18 +488,41 @@ export async function handleCreatePaseoWorktreeRequest(
|
||||
request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>,
|
||||
): Promise<void> {
|
||||
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",
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import { getPaseoWorktreesRoot, isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js";
|
||||
import {
|
||||
archivePaseoWorktree,
|
||||
type ArchivePaseoWorktreeDependencies,
|
||||
} from "../paseo-worktree-archive-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<WorkspaceGitService, "listWorktrees">;
|
||||
}
|
||||
|
||||
export interface ListPaseoWorktreesCommandInput {
|
||||
cwd: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export async function listPaseoWorktreesCommand(
|
||||
dependencies: ListPaseoWorktreesCommandDependencies,
|
||||
input: ListPaseoWorktreesCommandInput,
|
||||
): Promise<WorkspaceGitWorktreeInfo[]> {
|
||||
if (input.reason) {
|
||||
return dependencies.workspaceGitService.listWorktrees(input.cwd, { reason: input.reason });
|
||||
}
|
||||
return dependencies.workspaceGitService.listWorktrees(input.cwd);
|
||||
}
|
||||
|
||||
type CreatePaseoWorktreeWorkflow<Result extends CreatePaseoWorktreeResult> = (
|
||||
input: CreatePaseoWorktreeInput,
|
||||
) => Promise<Result>;
|
||||
|
||||
export interface CreatePaseoWorktreeCommandDependencies<
|
||||
Result extends CreatePaseoWorktreeResult = CreatePaseoWorktreeResult,
|
||||
> {
|
||||
paseoHome?: string;
|
||||
createPaseoWorktreeWorkflow?: CreatePaseoWorktreeWorkflow<Result>;
|
||||
}
|
||||
|
||||
export type CreatePaseoWorktreeCommandInput = Omit<
|
||||
CreatePaseoWorktreeInput,
|
||||
"paseoHome" | "runSetup"
|
||||
> & {
|
||||
paseoHome?: string;
|
||||
};
|
||||
|
||||
export type CreatePaseoWorktreeCommandResult<Result extends CreatePaseoWorktreeResult> =
|
||||
| {
|
||||
ok: true;
|
||||
createdWorktree: Result;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
error: WorktreeWireError;
|
||||
cause: unknown;
|
||||
};
|
||||
|
||||
export async function createPaseoWorktreeCommand<Result extends CreatePaseoWorktreeResult>(
|
||||
dependencies: CreatePaseoWorktreeCommandDependencies<Result>,
|
||||
input: CreatePaseoWorktreeCommandInput,
|
||||
): Promise<CreatePaseoWorktreeCommandResult<Result>> {
|
||||
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,
|
||||
"workspaceGitService"
|
||||
> {
|
||||
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot" | "listWorktrees">;
|
||||
}
|
||||
|
||||
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<ArchivePaseoWorktreeCommandResult> {
|
||||
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<ResolvedArchiveTarget> {
|
||||
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<string> {
|
||||
const worktreesRoot = await getPaseoWorktreesRoot(repoRoot, dependencies.paseoHome);
|
||||
return join(worktreesRoot, worktreeSlug);
|
||||
}
|
||||
Reference in New Issue
Block a user