diff --git a/src/agents/acp-spawn.test.ts b/src/agents/acp-spawn.test.ts index fa94536fe4b..66bc5587669 100644 --- a/src/agents/acp-spawn.test.ts +++ b/src/agents/acp-spawn.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as acpSessionManager from "../acp/control-plane/manager.js"; import type { AcpInitializeSessionInput } from "../acp/control-plane/manager.types.js"; @@ -538,6 +541,111 @@ describe("spawnAcpDirect", () => { }); }); + it("uses the target agent workspace for cross-agent ACP spawns when cwd is omitted", async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-acp-spawn-")); + try { + const mainWorkspace = path.join(workspaceRoot, "main"); + const targetWorkspace = path.join(workspaceRoot, "claude-code"); + await fs.mkdir(mainWorkspace, { recursive: true }); + await fs.mkdir(targetWorkspace, { recursive: true }); + + replaceSpawnConfig({ + ...hoisted.state.cfg, + acp: { + ...hoisted.state.cfg.acp, + allowedAgents: ["codex", "claude-code"], + }, + agents: { + list: [ + { + id: "main", + default: true, + workspace: mainWorkspace, + }, + { + id: "claude-code", + workspace: targetWorkspace, + }, + ], + }, + }); + + const result = await spawnAcpDirect( + { + task: "Inspect the queue owner state", + agentId: "claude-code", + mode: "run", + }, + { + agentSessionKey: "agent:main:main", + }, + ); + + expect(result.status).toBe("accepted"); + expect(hoisted.initializeSessionMock).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: expect.stringMatching(/^agent:claude-code:acp:/), + agent: "claude-code", + cwd: targetWorkspace, + }), + ); + } finally { + await fs.rm(workspaceRoot, { recursive: true, force: true }); + } + }); + + it("falls back to backend default cwd when the inherited target workspace does not exist", async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-acp-spawn-")); + try { + const mainWorkspace = path.join(workspaceRoot, "main"); + const missingTargetWorkspace = path.join(workspaceRoot, "claude-code-missing"); + await fs.mkdir(mainWorkspace, { recursive: true }); + + replaceSpawnConfig({ + ...hoisted.state.cfg, + acp: { + ...hoisted.state.cfg.acp, + allowedAgents: ["codex", "claude-code"], + }, + agents: { + list: [ + { + id: "main", + default: true, + workspace: mainWorkspace, + }, + { + id: "claude-code", + workspace: missingTargetWorkspace, + }, + ], + }, + }); + + const result = await spawnAcpDirect( + { + task: "Inspect the queue owner state", + agentId: "claude-code", + mode: "run", + }, + { + agentSessionKey: "agent:main:main", + }, + ); + + expect(result.status).toBe("accepted"); + expect(hoisted.initializeSessionMock).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: expect.stringMatching(/^agent:claude-code:acp:/), + agent: "claude-code", + cwd: undefined, + }), + ); + } finally { + await fs.rm(workspaceRoot, { recursive: true, force: true }); + } + }); + it("binds LINE ACP sessions to the current conversation when the channel has no native threads", async () => { enableLineCurrentConversationBindings(); hoisted.sessionBindingBindMock.mockImplementationOnce( diff --git a/src/agents/acp-spawn.ts b/src/agents/acp-spawn.ts index 5ea8300c155..7d7bee561eb 100644 --- a/src/agents/acp-spawn.ts +++ b/src/agents/acp-spawn.ts @@ -1,4 +1,5 @@ import crypto from "node:crypto"; +import fs from "node:fs/promises"; import { getAcpSessionManager } from "../acp/control-plane/manager.js"; import { cleanupFailedAcpSpawn, @@ -58,6 +59,7 @@ import { } from "./acp-spawn-parent-stream.js"; import { resolveAgentConfig, resolveDefaultAgentId } from "./agent-scope.js"; import { resolveSandboxRuntimeStatus } from "./sandbox/runtime-status.js"; +import { resolveSpawnedWorkspaceInheritance } from "./spawned-context.js"; import { resolveInternalSessionKey, resolveMainSessionAlias } from "./tools/sessions-helpers.js"; const log = createSubsystemLogger("agents/acp-spawn"); @@ -367,6 +369,24 @@ function summarizeError(err: unknown): string { return "error"; } +async function resolveRuntimeCwdForAcpSpawn(params: { + resolvedCwd?: string; + explicitCwd?: string; +}): Promise { + if (!params.resolvedCwd) { + return undefined; + } + if (typeof params.explicitCwd === "string" && params.explicitCwd.trim()) { + return params.resolvedCwd; + } + try { + await fs.access(params.resolvedCwd); + return params.resolvedCwd; + } catch { + return undefined; + } +} + function resolveRequesterInternalSessionKey(params: { cfg: OpenClawConfig; requesterSessionKey?: string; @@ -918,6 +938,16 @@ export async function spawnAcpDirect( const sessionKey = `agent:${targetAgentId}:acp:${crypto.randomUUID()}`; const runtimeMode = resolveAcpSessionMode(spawnMode); + const resolvedCwd = resolveSpawnedWorkspaceInheritance({ + config: cfg, + targetAgentId, + requesterSessionKey: ctx.agentSessionKey, + explicitWorkspaceDir: params.cwd, + }); + const runtimeCwd = await resolveRuntimeCwdForAcpSpawn({ + resolvedCwd, + explicitCwd: params.cwd, + }); let preparedBinding: PreparedAcpThreadBinding | null = null; if (requestThreadBinding) { @@ -958,7 +988,7 @@ export async function spawnAcpDirect( targetAgentId, runtimeMode, resumeSessionId: params.resumeSessionId, - cwd: params.cwd, + cwd: runtimeCwd, }); initializedRuntime = initializedSession.runtimeCloseHandle;