diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 5b77522473..02e1798789 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -3286,6 +3286,78 @@ test.each([ }, ); +test("createAgent and resume give the agent its workspace id", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-workspace-env-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class CaptureClient extends TestAgentClient { + createLaunchContext: AgentLaunchContext | undefined; + resumeLaunchContext: AgentLaunchContext | undefined; + + override async createSession( + config: AgentSessionConfig, + launchContext?: AgentLaunchContext, + ): Promise { + this.createLaunchContext = launchContext; + return new TestAgentSession(config); + } + + override async resumeSession( + handle: AgentPersistenceHandle, + overrides?: Partial, + launchContext?: AgentLaunchContext, + ): Promise { + this.resumeLaunchContext = launchContext; + const metadata = (handle.metadata ?? {}) as Partial; + return new TestAgentSession({ ...metadata, ...overrides, provider: "codex", cwd: workdir }); + } + } + + const client = new CaptureClient(); + const manager = new AgentManager({ + clients: { + codex: client, + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000140", + }); + + const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: "ws-agent-home", + }); + expect(client.createLaunchContext?.env).toEqual({ + PASEO_AGENT_ID: snapshot.id, + PASEO_AGENT_CWD: workdir, + PASEO_WORKSPACE_ID: "ws-agent-home", + }); + + // The stored record supplies the workspace when the caller doesn't. + const handle: AgentPersistenceHandle = { + provider: "codex", + sessionId: "workspace-env-session", + metadata: { provider: "codex", cwd: workdir }, + }; + const expectedEnv = { + PASEO_AGENT_ID: snapshot.id, + PASEO_AGENT_CWD: workdir, + PASEO_WORKSPACE_ID: "ws-agent-home", + }; + + await manager.closeAgent(snapshot.id); + const resumed = await manager.resumeAgentFromPersistence(handle, { cwd: workdir }, snapshot.id); + expect(client.resumeLaunchContext?.env).toEqual(expectedEnv); + expect(resumed.workspaceId).toBe("ws-agent-home"); + expect((await storage.get(snapshot.id))?.workspaceId).toBe("ws-agent-home"); + + // Each restart reads what the previous resume stored. + client.resumeLaunchContext = undefined; + await manager.closeAgent(snapshot.id); + await manager.resumeAgentFromPersistence(handle, { cwd: workdir }, snapshot.id); + expect(client.resumeLaunchContext?.env).toEqual(expectedEnv); +}); + test("createAgent passes persistSession to provider create options", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storagePath = join(workdir, "agents"); @@ -4731,6 +4803,7 @@ test("importProviderSession imports the selected session without listing and pub env: { PASEO_AGENT_ID: imported.id, PASEO_AGENT_CWD: workdir, + PASEO_WORKSPACE_ID: "ws-imported", }, }); expect(imported.lifecycle).toBe("idle"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index fdecc4ad1c..b47e12e558 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -664,6 +664,36 @@ function createAbortError(signal: AbortSignal | undefined, fallbackMessage: stri return Object.assign(new Error(message), { name: "AbortError" }); } +// PASEO_WORKSPACE_ID lets an agent rename or archive its own workspace; several workspaces can +// share one cwd, so the agent can't work it out from PASEO_AGENT_CWD. +function agentIdentityEnv({ + agentId, + cwd, + opening, +}: { + agentId: string; + cwd: string; + opening: { workspaceId?: string | null } | undefined; +}): Record { + const workspaceId = opening?.workspaceId; + return { + PASEO_AGENT_ID: agentId, + PASEO_AGENT_CWD: cwd, + ...(workspaceId ? { PASEO_WORKSPACE_ID: workspaceId } : {}), + }; +} + +// Launch env and the registered agent must agree, or the next snapshot erases the stored workspace. +function resolveResumeWorkspaceId({ + options, + record, +}: { + options: { workspaceId?: string } | undefined; + record: Pick | null; +}): string | undefined { + return options?.workspaceId ?? record?.workspaceId; +} + function validateAgentId(agentId: string, source: string): string { const result = AgentIdSchema.safeParse(agentId); if (!result.success) { @@ -1571,6 +1601,7 @@ export class AgentManager { const currentResumeOptions = { purpose } satisfies AgentResumeSessionOptions; const launchEnv = record?.launchEnv; + const workspaceId = resolveResumeWorkspaceId({ options, record }); const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig( mergedConfig, resolvedAgentId, @@ -1593,7 +1624,7 @@ export class AgentManager { { reason: "resume", purpose, - workspaceId: options?.workspaceId ?? null, + workspaceId, title: record?.title ?? mergedConfig.title ?? null, labels: record?.labels ?? options?.labels ?? {}, }, @@ -1609,6 +1640,7 @@ export class AgentManager { await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext); return this.registerSession(session, storedConfig, resolvedAgentId, { ...options, + workspaceId, launchEnv, persistence: handle, restoring: true, @@ -5774,8 +5806,7 @@ export class AgentManager { mcpServerOwners, env: { ...env, - PASEO_AGENT_ID: agentId, - PASEO_AGENT_CWD: cwd, + ...agentIdentityEnv({ agentId, cwd, opening }), }, }; const useNativeTools = diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 6fef290d91..4feef0a46b 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -349,7 +349,11 @@ type OpenCodeAgentConfig = Omit & { providerOptions: OpenCodeProviderOptions; }; -const OPENCODE_SESSION_ENV_KEYS = new Set(["PASEO_AGENT_ID", "PASEO_AGENT_CWD"]); +const OPENCODE_SESSION_ENV_KEYS = new Set([ + "PASEO_AGENT_ID", + "PASEO_AGENT_CWD", + "PASEO_WORKSPACE_ID", +]); function requiresDedicatedOpenCodeServer( config: OpenCodeAgentConfig, diff --git a/packages/server/src/server/agent/providers/opencode/v2/configuration.test.ts b/packages/server/src/server/agent/providers/opencode/v2/configuration.test.ts index 6479ca3851..27ddeef4f3 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/configuration.test.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/configuration.test.ts @@ -24,7 +24,14 @@ describe("OpenCode v2 helper isolation", () => { expect( requiresDedicatedV2Server( { provider: "opencode", cwd: "/tmp/project" }, - { agentId: "agent", env: { PASEO_AGENT_ID: "agent", PASEO_AGENT_CWD: "/tmp/project" } }, + { + agentId: "agent", + env: { + PASEO_AGENT_ID: "agent", + PASEO_AGENT_CWD: "/tmp/project", + PASEO_WORKSPACE_ID: "ws-1", + }, + }, ), ).toBe(false); }); diff --git a/packages/server/src/server/agent/providers/opencode/v2/configuration.ts b/packages/server/src/server/agent/providers/opencode/v2/configuration.ts index 9a565e6fca..ab4ae1e2f6 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/configuration.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/configuration.ts @@ -72,7 +72,7 @@ export async function applyResumeOverrides( // it must not force a dedicated server. Custom MCP is location-scoped, and any // other env key belongs to the process, so both need isolation. Mirrors the v1 // adapter's `requiresDedicatedOpenCodeServer`. -const V2_SESSION_ENV_KEYS = new Set(["PASEO_AGENT_ID", "PASEO_AGENT_CWD"]); +const V2_SESSION_ENV_KEYS = new Set(["PASEO_AGENT_ID", "PASEO_AGENT_CWD", "PASEO_WORKSPACE_ID"]); export function requiresDedicatedV2Server( config: AgentSessionConfig, diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index a442dfe6a9..55aa4537c1 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -18,6 +18,7 @@ import { getFullAccessConfig, getAskModeConfig } from "./daemon-e2e/agent-config import { parsePcm16MonoWav, wordSimilarity } from "./test-utils/dictation-e2e.js"; import type { AgentClient, + AgentLaunchContext, AgentPersistenceHandle, AgentRunResult, AgentSession, @@ -324,6 +325,7 @@ class RecordingMcpResumeClient implements AgentClient { readonly provider = "codex" as const; readonly capabilities; readonly resumeOverrides: Array | undefined> = []; + readonly resumeLaunchContexts: Array = []; private readonly inner = createTestAgentClients({ supportsMcpServers: true }).codex; constructor() { @@ -341,8 +343,10 @@ class RecordingMcpResumeClient implements AgentClient { resumeSession( handle: AgentPersistenceHandle, overrides?: Partial, + launchContext?: AgentLaunchContext, ): Promise { this.resumeOverrides.push(overrides); + this.resumeLaunchContexts.push(launchContext); return this.inner.resumeSession(handle, overrides); } @@ -402,7 +406,7 @@ class RejectingMcpResumeClient implements AgentClient { } } -test("createAgent fails when the initial turn cannot start", async () => { +test("createAgent preserves the error snapshot when the initial turn cannot start", async () => { const testAgent = new StubAgentClient({ sessionId: "start-turn-failure-session", supportsStreaming: false, @@ -421,16 +425,18 @@ test("createAgent fails when the initial turn cannot start", async () => { await client.connect(); await client.fetchAgents({ subscribe: {} }); - await expect( - client.createAgent({ - provider: "codex", - cwd: tmpCwd(), - title: "Start failure agent", - modeId: "full-access", - model: "gpt-5.4-mini", - initialPrompt: "Run exactly: sleep 30", - }), - ).rejects.toThrow("Initial turn failed to start"); + const created = await client.createAgent({ + provider: "codex", + cwd: tmpCwd(), + title: "Start failure agent", + modeId: "full-access", + model: "gpt-5.4-mini", + initialPrompt: "Run exactly: sleep 30", + }); + expect(created.status).toBe("error"); + expect(created.lastError).toBe("Initial turn failed to start"); + const fetched = await client.fetchAgent({ agentId: created.id }); + expect(fetched?.agent.lastError).toBe("Initial turn failed to start"); } finally { await client.close(); await daemon.close(); @@ -1034,6 +1040,38 @@ test("resume_agent rehydrates the newest private MCP config from a redacted pers } }); +test("resume_agent keeps the workspace of the agent it matches", async () => { + const cwd = tmpCwd(); + const provider = new RecordingMcpResumeClient(); + const localCtx = await createDaemonTestContext({ + agentClients: { codex: provider }, + }); + + try { + const opened = await localCtx.client.openProject(cwd); + if (!opened.workspace) { + throw new Error(opened.error ?? "Workspace did not open"); + } + const workspaceId = opened.workspace.id; + const created = await localCtx.client.createAgent({ + config: { provider: "codex", cwd }, + workspaceId, + }); + if (!created.persistence) { + throw new Error("Expected a persistence handle"); + } + + await localCtx.client.archiveAgent(created.id); + const resumed = await localCtx.client.resumeAgent(created.persistence); + + expect(provider.resumeLaunchContexts[0]?.env?.PASEO_WORKSPACE_ID).toBe(workspaceId); + expect(resumed.workspaceId).toBe(workspaceId); + } finally { + await localCtx.cleanup(); + rmSync(cwd, { recursive: true, force: true }); + } +}); + test("resume_agent restores archive state when an MCP-capable provider returns an unsupported session", async () => { const cwd = tmpCwd(); const provider = new RejectingMcpResumeClient(); @@ -1369,27 +1407,6 @@ test("creates agent and exercises lifecycle", async () => { }); const createRequestId = `create-${Date.now()}`; - const createdStatusPromise = waitForSignal(15000, (resolve) => { - const unsubscribe = ctx.client.on("status", (message) => { - if (message.type !== "status") { - return; - } - const payload = message.payload as { - status?: string; - agentId?: string; - requestId?: string; - }; - if (payload.status !== "agent_created") { - return; - } - if (payload.requestId !== createRequestId) { - return; - } - resolve(message); - }); - return unsubscribe; - }); - const agent = await ctx.client.createAgent({ ...getFullAccessConfig("codex"), cwd, @@ -1404,30 +1421,8 @@ test("creates agent and exercises lifecycle", async () => { const agentUpdate = await agentUpdatePromise; expect(agentUpdate.payload.agent.id).toBe(agent.id); - const createdStatus = await createdStatusPromise; - expect((createdStatus.payload as { agentId?: string }).agentId).toBe(agent.id); const failRequestId = `fail-${Date.now()}`; - const failedStatusPromise = waitForSignal(15000, (resolve) => { - const unsubscribe = ctx.client.on("status", (message) => { - if (message.type !== "status") { - return; - } - const payload = message.payload as { - status?: string; - requestId?: string; - }; - if (payload.status !== "agent_create_failed") { - return; - } - if (payload.requestId !== failRequestId) { - return; - } - resolve(message); - }); - return unsubscribe; - }); - await expect( ctx.client.createAgent({ ...getFullAccessConfig("codex"), @@ -1436,7 +1431,6 @@ test("creates agent and exercises lifecycle", async () => { requestId: failRequestId, }), ).rejects.toThrow("Working directory does not exist"); - await failedStatusPromise; let sawRefresh = false; const unsubscribe = ctx.client.subscribe((event) => { @@ -1615,6 +1609,11 @@ test("creates agent and exercises lifecycle", async () => { }, 300000); test("handles permission flow", async () => { + const permissionEvents = ctx.client.observeEvents([ + "agent_permission_request", + "agent_permission_resolved", + ]); + await permissionEvents.ready; const cwd = tmpCwd(); const filePath = path.join(cwd, "permission.txt"); @@ -1683,6 +1682,7 @@ test("handles permission flow", async () => { // Prevent unhandled rejections if the test fails before promises resolve. await permissionRequestPromise.catch(() => {}); await permissionResolvedPromise.catch(() => {}); + await permissionEvents.release(); await ctx.client.deleteAgent(agent.id); rmSync(cwd, { recursive: true, force: true }); } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 83f030d971..55d6c58401 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -4856,7 +4856,12 @@ export class Session { : overrides; let snapshot: ManagedAgent; try { - snapshot = await this.agentManager.resumeAgentFromPersistence(handle, effectiveOverrides); + snapshot = await this.agentManager.resumeAgentFromPersistence( + handle, + effectiveOverrides, + undefined, + { workspaceId: matched?.record.workspaceId }, + ); } catch (error) { if (matched?.didUnarchive && matched.originalArchivedAt) { await this.agentManager.archiveSnapshot(matched.record.id, matched.originalArchivedAt); diff --git a/public-docs/cli.md b/public-docs/cli.md index f28911ac5a..700206e5a5 100644 --- a/public-docs/cli.md +++ b/public-docs/cli.md @@ -211,6 +211,8 @@ paseo workspace rename --reset # back to the branch or director paseo workspace archive ``` +Inside an agent session, `PASEO_WORKSPACE_ID` holds the agent's own workspace ID, so an agent can run `paseo workspace rename "$PASEO_WORKSPACE_ID" "Auth rework"` or `paseo workspace archive "$PASEO_WORKSPACE_ID"` even when other workspaces share its directory. + Add `--forge ` to PR checkout when Paseo cannot infer the forge from the source checkout. See [Git worktrees](/docs/worktrees) for setup hooks and services. ## Terminals diff --git a/public-docs/cli.zh-CN.md b/public-docs/cli.zh-CN.md index 6e47391771..29b8aec65f 100644 --- a/public-docs/cli.zh-CN.md +++ b/public-docs/cli.zh-CN.md @@ -202,6 +202,8 @@ paseo workspace rename --reset # back to the branch or director paseo workspace archive ``` +在 Agent 会话中,`PASEO_WORKSPACE_ID` 保存该 Agent 自己的工作区 ID。即使其他工作区共享同一个目录,Agent 也可以运行 `paseo workspace rename "$PASEO_WORKSPACE_ID" "Auth rework"` 或 `paseo workspace archive "$PASEO_WORKSPACE_ID"`。 + 当 Paseo 无法从源检出推断代码托管平台时,为 PR 检出添加 `--forge `。setup 钩子和服务见 [Git worktree](/docs/worktrees)。 ## 终端 {#terminals} diff --git a/public-docs/plugins/hooks.md b/public-docs/plugins/hooks.md index 25c8cf0527..17340a00a9 100644 --- a/public-docs/plugins/hooks.md +++ b/public-docs/plugins/hooks.md @@ -598,7 +598,7 @@ Creation request → resolve defaults and validate provider configuration → derive launch configuration with Paseo runtime tools and daemon prompt → agent.open.before hooks (same ordering; env and plugin-local tools and MCP servers) - → set PASEO_AGENT_ID and PASEO_AGENT_CWD + → set PASEO_AGENT_ID, PASEO_AGENT_CWD, and PASEO_WORKSPACE_ID when the agent has a workspace → open provider session and save agent configuration ``` diff --git a/public-docs/plugins/hooks.zh-CN.md b/public-docs/plugins/hooks.zh-CN.md index 5469582b3f..0c35f814b3 100644 --- a/public-docs/plugins/hooks.zh-CN.md +++ b/public-docs/plugins/hooks.zh-CN.md @@ -581,7 +581,7 @@ Creation request → resolve defaults and validate provider configuration → derive launch configuration with Paseo runtime tools and daemon prompt → agent.open.before hooks (same ordering; env and plugin-local tools and MCP servers) - → set PASEO_AGENT_ID and PASEO_AGENT_CWD + → set PASEO_AGENT_ID, PASEO_AGENT_CWD, and PASEO_WORKSPACE_ID when the agent has a workspace → open provider session and save agent configuration ```