feat: continue Pi and OpenCode sessions from the session catalog (#113718)

* feat(plugins): add acpSessionBinding seam for ACP session adoption

* feat(acpx,opencode): adopt existing Pi and OpenCode sessions from the catalog

* fix(ci): satisfy dead-code and temp-path guards

* docs(acpx): record why pi-acp session-root resolution stays strict
This commit is contained in:
Peter Steinberger
2026-07-25 08:35:47 -07:00
committed by GitHub
parent e5999c7316
commit e19841c725
25 changed files with 1447 additions and 210 deletions
+121 -7
View File
@@ -1,6 +1,9 @@
import { accessSync, constants, statSync } from "node:fs";
import path from "node:path";
import process from "node:process";
import { resolveAcpSessionAvailability } from "openclaw/plugin-sdk/acp-runtime";
import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveNodeHostExecutable } from "openclaw/plugin-sdk/node-host";
import type {
OpenClawPluginApi,
@@ -15,6 +18,12 @@ import type {
SessionCatalogTranscriptItem,
SessionsCatalogReadResult,
} from "openclaw/plugin-sdk/session-catalog";
import {
createSessionCatalogAdoptionCoordinator,
listAdoptedSessionCatalogSessions,
sessionCatalogAdoptedSessionKey,
sessionCatalogAdoptedSourceKey,
} from "openclaw/plugin-sdk/session-catalog";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
OPENCODE_LOCAL_SESSION_HOST_ID as LOCAL_HOST_ID,
@@ -25,6 +34,7 @@ import {
OPENCODE_SESSION_ID_PATTERN as SESSION_ID_PATTERN,
OPENCODE_SESSION_READ_COMMAND,
OPENCODE_TERMINAL_RESUME_COMMAND,
OpenCodeCatalogParamsError,
} from "./session-catalog-shared.js";
import {
createOpenCodeTerminalNodeHostCommand,
@@ -46,6 +56,11 @@ const TRANSCRIPT_ITEM_TYPES = new Set([
"toolResult",
"other",
]);
const ACPX_BACKEND_ID = "acpx";
const OPENCODE_ACP_AGENT_ID = "opencode";
const OPENCODE_ADOPTED_SESSION_KEY_PREFIX = "plugin:opencode:catalog-adopt:";
const continueAdoption = createSessionCatalogAdoptionCoordinator();
function isOptionalString(value: unknown): boolean {
return value === undefined || typeof value === "string";
@@ -199,12 +214,13 @@ function unwrapNodePayload(value: unknown): unknown {
type CatalogNode = Awaited<ReturnType<PluginRuntime["nodes"]["list"]>>["nodes"][number];
function setTerminalCapability(
function setCatalogCapabilities(
page: OpenCodeSessionPage,
canOpenTerminal: boolean,
capabilities: { canContinue: boolean; canOpenTerminal: boolean },
): OpenCodeSessionPage {
for (const session of page.sessions) {
session.canOpenTerminal = canOpenTerminal;
session.canContinue = capabilities.canContinue;
session.canOpenTerminal = capabilities.canOpenTerminal;
}
return page;
}
@@ -250,7 +266,7 @@ async function listOpenCodeNodeHost(
const canOpenTerminal = commands?.includes(OPENCODE_TERMINAL_RESUME_COMMAND) === true;
return {
...common,
...setTerminalCapability(page, canOpenTerminal),
...setCatalogCapabilities(page, { canContinue: false, canOpenTerminal }),
};
} catch {
return {
@@ -306,9 +322,15 @@ function parseNodeTranscriptPage(value: unknown, threadId: string): SessionsCata
}
async function listOpenCodeHosts(
runtime: PluginRuntime,
api: OpenClawPluginApi,
query: Parameters<SessionCatalogProvider["list"]>[0],
): Promise<SessionCatalogHost[]> {
const runtime = api.runtime;
const canContinue = resolveAcpSessionAvailability({
config: currentOpenCodeCatalogConfig(api),
backendId: ACPX_BACKEND_ID,
agentId: OPENCODE_ACP_AGENT_ID,
}).available;
const requested = query.hostIds ? new Set(query.hostIds) : undefined;
const hosts: SessionCatalogHost[] = [];
if (
@@ -329,7 +351,7 @@ async function listOpenCodeHosts(
limit: query.limitPerHost,
...(query.search ? { searchTerm: query.search } : {}),
cursor: query.cursors?.[LOCAL_HOST_ID],
}).then((page) => setTerminalCapability(page, true))),
}).then((page) => setCatalogCapabilities(page, { canContinue, canOpenTerminal: true }))),
});
} catch {
hosts.push({
@@ -408,6 +430,96 @@ async function readOpenCodeTranscript(
};
}
function currentOpenCodeCatalogConfig(api: OpenClawPluginApi): OpenClawConfig {
return (api.runtime.config?.current?.() ?? api.config ?? {}) as OpenClawConfig;
}
function listAdoptedOpenCodeSessions(api: OpenClawPluginApi): Map<string, string> {
return listAdoptedSessionCatalogSessions({
config: currentOpenCodeCatalogConfig(api),
pluginId: api.id,
runtime: api.runtime,
sourceFromEntry: (entry) => {
const opencode = isRecord(entry.pluginExtensions?.opencode)
? entry.pluginExtensions.opencode
: undefined;
const marker =
opencode && isRecord(opencode.sessionCatalog) ? opencode.sessionCatalog : undefined;
return marker && typeof marker.sourceThreadId === "string"
? { hostId: LOCAL_HOST_ID, threadId: marker.sourceThreadId }
: undefined;
},
});
}
async function continueOpenCodeSession(
api: OpenClawPluginApi,
hostId: string,
threadId: string,
): Promise<{ sessionKey: string }> {
if (hostId.startsWith("node:")) {
throw new OpenCodeCatalogParamsError("paired-node OpenCode session rows are view-only");
}
if (hostId !== LOCAL_HOST_ID) {
throw new OpenCodeCatalogParamsError("OpenCode session catalog hostId is invalid");
}
const availability = resolveAcpSessionAvailability({
config: currentOpenCodeCatalogConfig(api),
backendId: ACPX_BACKEND_ID,
agentId: OPENCODE_ACP_AGENT_ID,
});
if (!availability.available) {
throw new OpenCodeCatalogParamsError(availability.message);
}
const sourceKey = sessionCatalogAdoptedSourceKey(hostId, threadId);
return await continueAdoption({
sourceKey,
findExisting: () => listAdoptedOpenCodeSessions(api).get(sourceKey),
create: async () => {
const page = await listLocalOpenCodeSessionPage({
searchTerm: threadId,
limit: MAX_PAGE_LIMIT,
}).catch(() => undefined);
const record = page?.sessions.find((session) => session.threadId === threadId);
if (!record) {
throw new OpenCodeCatalogParamsError("OpenCode session is unavailable");
}
const config = currentOpenCodeCatalogConfig(api);
const currentAvailability = resolveAcpSessionAvailability({
config,
backendId: ACPX_BACKEND_ID,
agentId: OPENCODE_ACP_AGENT_ID,
});
if (!currentAvailability.available) {
throw new OpenCodeCatalogParamsError(currentAvailability.message);
}
const marker = { sourceThreadId: threadId };
// ACPX binds the native session before OpenClaw turn handlers attach, so
// the OpenClaw transcript starts empty while OpenCode retains server context.
const created = await api.runtime.agent.session.createSessionEntry({
cfg: config,
key: sessionCatalogAdoptedSessionKey(OPENCODE_ADOPTED_SESSION_KEY_PREFIX, threadId),
agentId: resolveDefaultAgentId(config),
recoverMatchingInitialEntry: true,
...(record.name ? { label: record.name } : {}),
...(record.cwd ? { spawnedCwd: record.cwd } : {}),
initialEntry: {
acpBackendId: ACPX_BACKEND_ID,
acpSessionBinding: {
acpAgentId: OPENCODE_ACP_AGENT_ID,
agentSessionId: threadId,
},
pluginExtensions: { opencode: { sessionCatalog: marker } },
},
afterCreate: async () => ({
pluginExtensions: { opencode: { sessionCatalog: marker } },
}),
});
return { sessionKey: created.key };
},
});
}
export function registerOpenCodeSessionCatalog(api: OpenClawPluginApi): void {
if (!isOpenCodeSessionCatalogEnabled(api.pluginConfig)) {
return;
@@ -415,8 +527,10 @@ export function registerOpenCodeSessionCatalog(api: OpenClawPluginApi): void {
api.registerSessionCatalog({
id: "opencode",
label: "OpenCode",
list: async (query) => await listOpenCodeHosts(api.runtime, query),
list: async (query) => await listOpenCodeHosts(api, query),
read: async (request) => await readOpenCodeTranscript(api.runtime, request),
continueSession: async (request) =>
await continueOpenCodeSession(api, request.hostId, request.threadId),
openTerminal: async (request) =>
await openOpenCodeCatalogTerminal({
runtime: api.runtime,
@@ -7,3 +7,5 @@ export const OPENCODE_LOCAL_SESSION_HOST_ID = "gateway";
export const OPENCODE_SESSION_CATALOG_MAX_PAGE_LIMIT = 100;
export const OPENCODE_NODE_INVOKE_TIMEOUT_MS = 35_000;
export const OPENCODE_SESSION_ID_PATTERN = /^(?!-)[A-Za-z0-9._:-]{1,256}$/u;
export class OpenCodeCatalogParamsError extends Error {}
+140 -3
View File
@@ -6,9 +6,15 @@ import path from "node:path";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { afterEach, describe, expect, it, vi } from "vitest";
type ResolveAcpSessionAvailability =
(typeof import("openclaw/plugin-sdk/acp-runtime"))["resolveAcpSessionAvailability"];
const nodeHostMocks = vi.hoisted(() => ({
runNodePtyCommand: vi.fn(async () => ({ exitCode: 0 })),
}));
const acpRuntimeMocks = vi.hoisted(() => ({
resolveAcpSessionAvailability: vi.fn<ResolveAcpSessionAvailability>(() => ({ available: true })),
}));
const childProcessMocks = vi.hoisted(() => ({
children: [] as ChildProcess[],
spawn: vi.fn(),
@@ -24,6 +30,11 @@ vi.mock("node:child_process", async (importOriginal) => {
return { ...actual, spawn: childProcessMocks.spawn };
});
vi.mock("openclaw/plugin-sdk/acp-runtime", async (importOriginal) => ({
...(await importOriginal<typeof import("openclaw/plugin-sdk/acp-runtime")>()),
resolveAcpSessionAvailability: acpRuntimeMocks.resolveAcpSessionAvailability,
}));
vi.mock("openclaw/plugin-sdk/node-host", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/node-host")>();
return {
@@ -83,6 +94,54 @@ function captureOpenCodeSessionRegistrations(pluginConfig: unknown = {}) {
return { catalogs, commands, policies };
}
function captureOpenCodeContinuationCatalog() {
let provider: Parameters<OpenClawPluginApi["registerSessionCatalog"]>[0] | undefined;
const entries: Array<{ sessionKey: string; entry: Record<string, unknown> }> = [];
const createSessionEntry = vi.fn(
async (
params: Parameters<OpenClawPluginApi["runtime"]["agent"]["session"]["createSessionEntry"]>[0],
) => {
const sessionKey = `agent:${params.agentId ?? "main"}:${params.key}`;
const entry = {
sessionId: "adopted-opencode-session",
updatedAt: Date.now(),
pluginOwnerId: "opencode",
...(params.label ? { label: params.label } : {}),
...(params.spawnedCwd ? { spawnedCwd: params.spawnedCwd } : {}),
pluginExtensions: params.initialEntry.pluginExtensions,
};
entries.push({ sessionKey, entry });
return {
key: sessionKey,
agentId: params.agentId ?? "main",
sessionId: entry.sessionId,
entry,
};
},
);
registerOpenCodeSessionCatalog({
id: "opencode",
pluginConfig: {},
config: {},
runtime: {
config: { current: () => ({}) },
nodes: { list: vi.fn().mockResolvedValue({ nodes: [] }) },
agent: {
session: {
createSessionEntry,
listSessionEntries: vi.fn(() => entries),
},
},
},
registerSessionCatalog: (value: NonNullable<typeof provider>) => {
provider = value;
},
registerNodeHostCommand: vi.fn(),
registerNodeInvokePolicy: vi.fn(),
} as unknown as OpenClawPluginApi);
return { createSessionEntry, entries, provider: provider! };
}
async function installFakeOpenCode(
assistantText = "hi",
sessionTitle = "Catalog session",
@@ -195,6 +254,7 @@ async function stopChild(child: ChildProcess | undefined): Promise<void> {
}
afterEach(async () => {
acpRuntimeMocks.resolveAcpSessionAvailability.mockReset().mockReturnValue({ available: true });
nodeHostMocks.runNodePtyCommand.mockClear();
childProcessMocks.spawn.mockClear();
await Promise.all(childProcessMocks.children.splice(0).map((child) => stopChild(child)));
@@ -229,7 +289,7 @@ describe("OpenCode session catalog", () => {
name: "Catalog session",
cwd: "/workspace",
source: "opencode-cli",
canContinue: false,
canContinue: true,
}),
],
});
@@ -307,6 +367,27 @@ describe("OpenCode session catalog", () => {
},
);
it.runIf(process.platform !== "win32")(
"hides and rejects Continue when ACP cannot resume OpenCode",
async () => {
await installFakeOpenCode();
acpRuntimeMocks.resolveAcpSessionAvailability.mockReturnValue({
available: false,
message: "ACP runtime backend is unavailable",
});
const { provider } = captureOpenCodeContinuationCatalog();
await expect(provider.list({ hostIds: ["gateway"] })).resolves.toEqual([
expect.objectContaining({
sessions: [expect.objectContaining({ threadId: "ses_test", canContinue: false })],
}),
]);
await expect(
provider.continueSession!({ hostId: "gateway", threadId: "ses_test" }),
).rejects.toThrow("ACP runtime backend is unavailable");
},
);
it.runIf(process.platform !== "win32")(
"keeps oversized transcript items below the node payload budget",
async () => {
@@ -321,6 +402,56 @@ describe("OpenCode session catalog", () => {
},
);
it.runIf(process.platform !== "win32")(
"adopts local OpenCode sessions once with the native ACP resume binding",
async () => {
await installFakeOpenCode();
const { createSessionEntry, provider } = captureOpenCodeContinuationCatalog();
const [first, concurrent] = await Promise.all([
provider.continueSession!({ hostId: "gateway", threadId: "ses_test" }),
provider.continueSession!({ hostId: "gateway", threadId: "ses_test" }),
]);
const second = await provider.continueSession!({
hostId: "gateway",
threadId: "ses_test",
});
expect(first).toEqual(concurrent);
expect(second).toEqual(first);
expect(createSessionEntry).toHaveBeenCalledTimes(1);
expect(createSessionEntry).toHaveBeenCalledWith(
expect.objectContaining({
label: "Catalog session",
spawnedCwd: "/workspace",
initialEntry: {
acpBackendId: "acpx",
acpSessionBinding: { acpAgentId: "opencode", agentSessionId: "ses_test" },
pluginExtensions: {
opencode: { sessionCatalog: { sourceThreadId: "ses_test" } },
},
},
}),
);
},
);
it.runIf(process.platform !== "win32")(
"rejects paired-node and unknown OpenCode session continuation",
async () => {
await installFakeOpenCode();
const { createSessionEntry, provider } = captureOpenCodeContinuationCatalog();
await expect(
provider.continueSession!({ hostId: "node:remote", threadId: "ses_test" }),
).rejects.toThrow("paired-node OpenCode session rows are view-only");
await expect(
provider.continueSession!({ hostId: "gateway", threadId: "missing" }),
).rejects.toThrow("OpenCode session is unavailable");
expect(createSessionEntry).not.toHaveBeenCalled();
},
);
it.runIf(process.platform !== "win32")(
"keeps truncated tool input on a valid UTF-16 boundary",
async () => {
@@ -469,7 +600,7 @@ describe("OpenCode session catalog", () => {
cwd: "/remote/workspace",
status: "stored",
archived: false,
canContinue: false,
canContinue: true,
canArchive: false,
},
],
@@ -501,7 +632,13 @@ describe("OpenCode session catalog", () => {
await expect(provider!.list({ hostIds: ["node:node-1"], search: "remote" })).resolves.toEqual([
expect.objectContaining({
sessions: [expect.objectContaining({ threadId: "ses_remote", canOpenTerminal: true })],
sessions: [
expect.objectContaining({
threadId: "ses_remote",
canContinue: false,
canOpenTerminal: true,
}),
],
}),
]);
expect(invoke).toHaveBeenNthCalledWith(1, {
+1 -1
View File
@@ -326,7 +326,7 @@ function parseOpenCodeSession(value: unknown): SessionCatalogSession | undefined
source: "opencode-cli",
modelProvider: "opencode",
archived: false,
canContinue: false,
canContinue: true,
canArchive: false,
};
}