fix(acp): fall back when inherited target workspace is missing

This commit is contained in:
zssggle-rgb 2026-04-04 13:21:24 +08:00 committed by Peter Steinberger
parent 6507f54965
commit d718d17b5b
2 changed files with 139 additions and 1 deletions

View File

@ -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(

View File

@ -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<string | undefined> {
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;