mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
e5999c7316
commit
e19841c725
@@ -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 {}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -326,7 +326,7 @@ function parseOpenCodeSession(value: unknown): SessionCatalogSession | undefined
|
||||
source: "opencode-cli",
|
||||
modelProvider: "opencode",
|
||||
archived: false,
|
||||
canContinue: false,
|
||||
canContinue: true,
|
||||
canArchive: false,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user