Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions src/acp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -622,18 +622,38 @@ export class AcpClient {
return { readable, writable };
}

async createSession(cwd = this.options.cwd): Promise<SessionCreateResult> {
async createSession(
cwd = this.options.cwd,
hints?: { proposedSessionId?: string },
): Promise<SessionCreateResult> {
const connection = this.getConnection();
const { command, args } = splitCommandLine(this.options.agentCommand);
const claudeAcp = isClaudeAcpCommand(command, args);

let sessionMeta = buildClaudeCodeOptionsMeta(this.options.sessionOptions);
if (hints?.proposedSessionId) {
sessionMeta = sessionMeta ?? {};
sessionMeta = {
...sessionMeta,
claudeCode: {
...(sessionMeta.claudeCode as Record<string, unknown> | undefined),
options: {
...((sessionMeta.claudeCode as Record<string, unknown> | undefined)?.options as
| Record<string, unknown>
| undefined),
proposedSessionId: hints.proposedSessionId,
},
},
};
}

let result: Awaited<ReturnType<typeof connection.newSession>>;
try {
const createPromise = this.runConnectionRequest(() =>
connection.newSession({
cwd: asAbsoluteCwd(cwd),
mcpServers: this.options.mcpServers ?? [],
_meta: buildClaudeCodeOptionsMeta(this.options.sessionOptions),
_meta: sessionMeta,
}),
);
result = claudeAcp
Expand Down
44 changes: 43 additions & 1 deletion src/runtime/engine/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,45 @@ export class AcpRuntimeManager {
return existing;
}

// For oneshot sessions with a resumeSessionId, skip client.start() + loadSession entirely.
// Running start+loadSession+close writes a closed/interrupted marker to the JSONL, which
// causes connectAndLoadSession (phase 2) to fail when it tries to resume the session.
// Instead, save a minimal record with the resume UUID and return early.
// Phase 2 handles the actual session/load on its own.
if (input.mode === "oneshot" && input.resumeSessionId) {
const resumeUUID = input.resumeSessionId.split(":").pop()!;
const record = createInitialRecord({
recordId: createRecordId(input.sessionKey, input.mode),
sessionName: input.sessionKey,
sessionId: resumeUUID,
agentCommand,
cwd,
agentSessionId: undefined,
});
await this.options.sessionStore.save(record);
return record;
}

// For oneshot second turns arriving without an explicit resumeSessionId (e.g. a follow-up
// message via dispatch-acp after a completed turn), the caller does not know to pass
// resumeSessionId because persistedResumeSessionId is undefined in oneshot mode.
// The normal path would call createSession(cwd, { proposedSessionId: UUID }) but the JSONL
// already exists from the first turn, causing Claude Code to fail with a session-init error.
// Fix: if the existing record has an acpSessionId (set during the first turn), use it as an
// implicit resume — same early-return pattern as above, without requiring an explicit hint.
if (input.mode === "oneshot" && !input.resumeSessionId && existing?.acpSessionId) {
const record = createInitialRecord({
recordId: createRecordId(input.sessionKey, input.mode),
sessionName: input.sessionKey,
sessionId: existing.acpSessionId,
agentCommand,
cwd,
agentSessionId: undefined,
});
await this.options.sessionStore.save(record);
return record;
}

const client = this.createClient({
agentCommand,
cwd,
Expand All @@ -273,7 +312,10 @@ export class AcpRuntimeManager {
sessionId = input.resumeSessionId;
agentSessionId = loaded.agentSessionId;
} else {
const created = await client.createSession(cwd);
// Pass sessionKey UUID as proposedSessionId so Claude Code uses the same UUID
// as OpenClaw's childSessionKey, making session files predictable and resumable.
const sessionKeyUUID = input.sessionKey.split(":").pop()!;
const created = await client.createSession(cwd, { proposedSessionId: sessionKeyUUID });
sessionId = created.sessionId;
agentSessionId = created.agentSessionId;
}
Expand Down
14 changes: 12 additions & 2 deletions src/runtime/engine/reconnect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,12 @@ export async function connectAndLoadSession(
if (!shouldFallbackToNewSession(error, record)) {
throw error;
}
const createdSession = await withTimeout(client.createSession(record.cwd), options.timeoutMs);
// Pass acpSessionId as proposedSessionId so the UUID stays stable when
// falling back from session/load to session/new.
const createdSession = await withTimeout(
client.createSession(record.cwd, { proposedSessionId: record.acpSessionId }),
options.timeoutMs,
);
sessionId = createdSession.sessionId;
createdFreshSession = true;
pendingAgentSessionId = createdSession.agentSessionId;
Expand All @@ -275,7 +280,12 @@ export async function connectAndLoadSession(
reason: "agent does not support session/load",
});
}
const createdSession = await withTimeout(client.createSession(record.cwd), options.timeoutMs);
// Pass acpSessionId as proposedSessionId so the UUID stays stable when
// the agent does not support session/load.
const createdSession = await withTimeout(
client.createSession(record.cwd, { proposedSessionId: record.acpSessionId }),
options.timeoutMs,
);
sessionId = createdSession.sessionId;
createdFreshSession = true;
pendingAgentSessionId = createdSession.agentSessionId;
Expand Down
66 changes: 66 additions & 0 deletions test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,72 @@ test("AcpClient createSession forwards codex model metadata without setting it e
assert.equal(setConfigCalled, false);
});

test("AcpClient createSession includes proposedSessionId in _meta when provided via hints", async () => {
const proposedId = "22222222-2222-2222-2222-222222222222";
const client = makeClient({
sessionOptions: { model: "sonnet" },
});

let capturedParams: Record<string, unknown> | undefined;
asInternals(client).connection = {
newSession: async (params: Record<string, unknown>) => {
capturedParams = params;
return { sessionId: "session-hints" };
},
};

const result = await client.createSession("/tmp/acpx-hints", { proposedSessionId: proposedId });
assert.equal(result.sessionId, "session-hints");
assert.deepEqual(capturedParams, {
cwd: "/tmp/acpx-hints",
mcpServers: [],
_meta: {
claudeCode: {
options: {
model: "sonnet",
proposedSessionId: proposedId,
},
},
},
});
});

test("AcpClient createSession does not include proposedSessionId in _meta when hints are absent", async () => {
const client = makeClient({
sessionOptions: { model: "sonnet" },
});

let capturedParams: Record<string, unknown> | undefined;
asInternals(client).connection = {
newSession: async (params: Record<string, unknown>) => {
capturedParams = params;
return { sessionId: "session-no-hints" };
},
};

const result = await client.createSession("/tmp/acpx-no-hints");
assert.equal(result.sessionId, "session-no-hints");
assert.deepEqual(capturedParams, {
cwd: "/tmp/acpx-no-hints",
mcpServers: [],
_meta: {
claudeCode: {
options: {
model: "sonnet",
},
},
},
});
// proposedSessionId must not appear anywhere in the captured params
const metaOptions = (capturedParams?._meta as Record<string, unknown> | undefined)?.claudeCode as
| Record<string, unknown>
| undefined;
assert.equal(
Object.prototype.hasOwnProperty.call(metaOptions?.options, "proposedSessionId"),
false,
);
});

test("AcpClient setSessionModel uses session/set_model", async () => {
const client = makeClient();

Expand Down
96 changes: 95 additions & 1 deletion test/runtime-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ type FakeClient = {
};
start: () => Promise<void>;
close: () => Promise<void>;
createSession: (cwd: string) => Promise<{ sessionId: string; agentSessionId?: string }>;
createSession: (
cwd: string,
hints?: { proposedSessionId?: string },
) => Promise<{ sessionId: string; agentSessionId?: string }>;
loadSession: (sessionId: string, cwd: string) => Promise<{ agentSessionId?: string }>;
hasReusableSession: (sessionId: string) => boolean;
supportsLoadSession: () => boolean;
Expand Down Expand Up @@ -1332,3 +1335,94 @@ test("AcpRuntimeManager falls back when a kept-open persistent client is no long
assert.equal(secondClientPromptCalls, 1);
assert.equal(constructed, 2);
});

test("AcpRuntimeManager passes proposedSessionId derived from session key when creating a new session", async () => {
const store = new InMemorySessionStore();
let capturedHints: { proposedSessionId?: string } | undefined;
const manager = new AcpRuntimeManager(
createRuntimeOptions({ cwd: "/workspace", sessionStore: store }),
{
clientFactory: () =>
({
initializeResult: { protocolVersion: 1, agentCapabilities: {} },
start: async () => {},
close: async () => {},
createSession: async (_cwd: string, hints?: { proposedSessionId?: string }) => {
capturedHints = hints;
return { sessionId: "new-session", agentSessionId: "agent-session" };
},
loadSession: async () => ({ agentSessionId: "unused" }),
hasReusableSession: () => false,
supportsLoadSession: () => true,
loadSessionWithOptions: async () => ({ agentSessionId: "unused" }),
getAgentLifecycleSnapshot: () => ({ running: true }),
prompt: async () => ({ stopReason: "end_turn" }),
requestCancelActivePrompt: async () => false,
hasActivePrompt: () => false,
setSessionMode: async () => {},
setSessionConfigOption: async () => {},
clearEventHandlers: () => {},
setEventHandlers: () => {},
}) as never,
},
);

await manager.ensureSession({
sessionKey: "agent:claude:acp:33333333-3333-3333-3333-333333333333",
agent: "codex",
mode: "persistent",
cwd: "/workspace",
});

assert.deepEqual(capturedHints, {
proposedSessionId: "33333333-3333-3333-3333-333333333333",
});
});

test("AcpRuntimeManager does not start a Claude process for oneshot resume", async () => {
const store = new InMemorySessionStore();
let startCalls = 0;
let loadSessionCalls = 0;
const resumeUUID = "44444444-4444-4444-4444-444444444444";
const manager = new AcpRuntimeManager(
createRuntimeOptions({ cwd: "/tmp", sessionStore: store }),
{
clientFactory: () =>
({
initializeResult: { protocolVersion: 1, agentCapabilities: {} },
start: async () => {
startCalls += 1;
},
close: async () => {},
createSession: async () => ({ sessionId: "unused", agentSessionId: "unused" }),
loadSession: async () => {
loadSessionCalls += 1;
return { agentSessionId: "unused" };
},
hasReusableSession: () => false,
supportsLoadSession: () => true,
loadSessionWithOptions: async () => ({ agentSessionId: "unused" }),
getAgentLifecycleSnapshot: () => ({ running: true }),
prompt: async () => ({ stopReason: "end_turn" }),
requestCancelActivePrompt: async () => false,
hasActivePrompt: () => false,
setSessionMode: async () => {},
setSessionConfigOption: async () => {},
clearEventHandlers: () => {},
setEventHandlers: () => {},
}) as never,
},
);

const record = await manager.ensureSession({
sessionKey: `agent:claude:acp:${resumeUUID}`,
agent: "codex",
mode: "oneshot",
resumeSessionId: resumeUUID,
cwd: "/tmp",
});

assert.equal(startCalls, 0);
assert.equal(loadSessionCalls, 0);
assert.equal(record.acpSessionId, resumeUUID);
});