mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
improve(anthropic): reduce session catalog startup memory (#119619)
* perf(anthropic): lazy-load session catalog runtime Punchcard-Session: coral-workshop-workshop-3f * fix(anthropic): keep session catalog helpers private Punchcard-Session: coral-workshop-workshop-3f * docs(plugin-sdk): document catalog create target Punchcard-Session: coral-workshop-workshop-3f * fix(plugins): continue catalog target selection Punchcard-Session: coral-workshop-workshop-3f
This commit is contained in:
@@ -150,6 +150,15 @@ two-party event loops that do not go through the shared inbound reply runner.
|
||||
// pass level to an embedded run
|
||||
}
|
||||
|
||||
// Resolve a synchronous create target for a session catalog
|
||||
const target = api.runtime.agent.resolveSessionCatalogCreateTarget({
|
||||
config: api.runtime.config.current(),
|
||||
requestedAgentId: agentId,
|
||||
provider: "example",
|
||||
modelIds: ["example-model"],
|
||||
agentRuntime: "example-cli",
|
||||
});
|
||||
|
||||
// Get agent timeout
|
||||
const timeoutMs = api.runtime.agent.resolveAgentTimeoutMs(cfg);
|
||||
|
||||
@@ -176,6 +185,8 @@ two-party event loops that do not go through the shared inbound reply runner.
|
||||
|
||||
`normalizeThinkingLevel(...)` converts user text such as `on`, `x-high`, or `extra high` to the canonical stored level before checking it against the resolved policy.
|
||||
|
||||
`resolveSessionCatalogCreateTarget(...)` is the supported synchronous policy seam for trusted native plugins that implement `SessionCatalogProvider.resolveCreateSession`. It selects the first candidate model routed to the requested runtime and allowed for the requested or default agent. It returns `undefined` when no candidate satisfies both policies. Use this helper instead of importing or duplicating core model-selection policy in a plugin.
|
||||
|
||||
**Session store helpers** are under `api.runtime.agent.session`:
|
||||
|
||||
```typescript
|
||||
|
||||
@@ -99,6 +99,7 @@ describe("anthropic provider replay hooks", () => {
|
||||
it("lets native session discovery be disabled without disabling Anthropic", () => {
|
||||
const registerCliBackend = vi.fn();
|
||||
const registerNodeHostCommand = vi.fn();
|
||||
const registerNodeInvokePolicy = vi.fn();
|
||||
const registerProvider = vi.fn();
|
||||
const registerSessionCatalog = vi.fn();
|
||||
anthropicPlugin.register(
|
||||
@@ -110,12 +111,14 @@ describe("anthropic provider replay hooks", () => {
|
||||
pluginConfig: { sessionCatalog: { enabled: false } },
|
||||
registerCliBackend,
|
||||
registerNodeHostCommand,
|
||||
registerNodeInvokePolicy,
|
||||
registerProvider,
|
||||
registerSessionCatalog,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(registerCliBackend).toHaveBeenCalledOnce();
|
||||
expect(registerNodeInvokePolicy).toHaveBeenCalledOnce();
|
||||
expect(registerProvider).toHaveBeenCalledOnce();
|
||||
expect(registerNodeHostCommand).not.toHaveBeenCalled();
|
||||
expect(registerSessionCatalog).not.toHaveBeenCalled();
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import type {
|
||||
OpenClawPluginNodeHostCommand,
|
||||
OpenClawPluginNodeInvokePolicy,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
|
||||
import { createPluginRuntimeMock } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import type { SessionCatalogProvider } from "openclaw/plugin-sdk/session-catalog";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("anthropic session catalog lazy imports", () => {
|
||||
afterEach(() => {
|
||||
vi.doUnmock("./session-catalog.js");
|
||||
vi.doUnmock("./session-catalog-node-commands.js");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("loads catalog and node handlers only on first use", async () => {
|
||||
let catalogImports = 0;
|
||||
let nodeCommandImports = 0;
|
||||
vi.doMock("./session-catalog.js", () => {
|
||||
catalogImports += 1;
|
||||
return {
|
||||
createClaudeSessionCatalogRuntime: () => ({
|
||||
list: async () => [],
|
||||
read: async () => ({ hostId: "gateway:local", label: "Local", threadId: "", items: [] }),
|
||||
continueSession: async () => ({ sessionKey: "agent:main:test" }),
|
||||
openTerminal: async () => ({ kind: "local", argv: ["claude"] }),
|
||||
checkUpstreamActivity: async () => [],
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.doMock("./session-catalog-node-commands.js", () => {
|
||||
nodeCommandImports += 1;
|
||||
return {
|
||||
listClaudeSessions: async () => "[]",
|
||||
readClaudeSession: async () => "{}",
|
||||
resumeClaudeSession: async () => "{}",
|
||||
};
|
||||
});
|
||||
|
||||
const { default: anthropicPlugin } = await import("./index.js");
|
||||
const catalogs: SessionCatalogProvider[] = [];
|
||||
const nodeCommands: OpenClawPluginNodeHostCommand[] = [];
|
||||
const nodePolicies: OpenClawPluginNodeInvokePolicy[] = [];
|
||||
anthropicPlugin.register(
|
||||
createTestPluginApi({
|
||||
id: "anthropic",
|
||||
name: "Anthropic",
|
||||
source: "test",
|
||||
config: {},
|
||||
runtime: createPluginRuntimeMock(),
|
||||
registerSessionCatalog: (provider) => catalogs.push(provider),
|
||||
registerNodeHostCommand: (command) => nodeCommands.push(command),
|
||||
registerNodeInvokePolicy: (policy) => nodePolicies.push(policy),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(catalogImports).toBe(0);
|
||||
expect(nodeCommandImports).toBe(0);
|
||||
expect(catalogs).toHaveLength(1);
|
||||
expect(nodeCommands).toHaveLength(3);
|
||||
expect(nodePolicies).toHaveLength(1);
|
||||
|
||||
await expect(catalogs[0]?.list({})).resolves.toEqual([]);
|
||||
await expect(catalogs[0]?.list({})).resolves.toEqual([]);
|
||||
await expect(nodeCommands[0]?.handle()).resolves.toBe("[]");
|
||||
await expect(nodeCommands[0]?.handle()).resolves.toBe("[]");
|
||||
expect(catalogImports).toBe(1);
|
||||
expect(nodeCommandImports).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -66,8 +66,10 @@ import { acceptsAnthropicLiveModelContract } from "./live-model-contract-gate.js
|
||||
import { anthropicMediaUnderstandingProvider } from "./media-understanding-provider.js";
|
||||
import manifest from "./openclaw.plugin.json" with { type: "json" };
|
||||
import { resolveClaudeCliSyntheticAuth } from "./provider-discovery.js";
|
||||
import { createClaudeSessionNodeInvokePolicies } from "./session-catalog-node-commands.js";
|
||||
import { registerClaudeSessionDiscovery } from "./session-catalog-registration.js";
|
||||
import {
|
||||
createClaudeSessionNodeInvokePolicies,
|
||||
registerClaudeSessionDiscovery,
|
||||
} from "./session-catalog-registration.js";
|
||||
import { isAnthropicOAuthApiKey, wrapAnthropicProviderStream } from "./stream-wrappers.js";
|
||||
import { fetchAnthropicUsage, resolveAnthropicUsageAuth } from "./usage.js";
|
||||
|
||||
|
||||
@@ -1,41 +1,17 @@
|
||||
import { statSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
decodeNodePtyResumeParams,
|
||||
type OpenClawPluginNodeHostCommandIo,
|
||||
runNodePtyCommand,
|
||||
validateClaudeSessionId,
|
||||
} from "openclaw/plugin-sdk/node-host";
|
||||
import type {
|
||||
OpenClawPluginNodeHostCommand,
|
||||
OpenClawPluginNodeInvokePolicy,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { isExactClaudeSessionCursor } from "./session-catalog-cursor.js";
|
||||
import { resolveClaudeTerminalExecutable } from "./session-catalog-executable.js";
|
||||
import {
|
||||
CLAUDE_CLI_NODE_RUN_COMMAND,
|
||||
CLAUDE_SESSION_READ_COMMAND,
|
||||
CLAUDE_SESSIONS_LIST_COMMAND,
|
||||
CLAUDE_TERMINAL_RESUME_COMMAND,
|
||||
isResumableClaudeSource,
|
||||
} from "./session-catalog-shared.js";
|
||||
import { isResumableClaudeSource } from "./session-catalog-shared.js";
|
||||
import type { ClaudeSessionCatalogSession } from "./session-catalog-types.js";
|
||||
import { listLocalClaudeSessionPage, readLocalClaudeTranscriptPage } from "./session-catalog.js";
|
||||
|
||||
const CLAUDE_SESSIONS_CAPABILITY = "claude-sessions";
|
||||
const CLAUDE_NODE_LOOKUP_PAGE_LIMIT = 100;
|
||||
|
||||
// Nodes advertise the catalog commands only when this machine has a Claude
|
||||
// Code session store; without it the gateway skips the node entirely.
|
||||
function claudeProjectsAvailable(env: NodeJS.ProcessEnv): boolean {
|
||||
const homeDir = env.HOME?.trim() || env.USERPROFILE?.trim() || os.homedir();
|
||||
try {
|
||||
return statSync(path.join(homeDir, ".claude", "projects")).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function parseNodeParams(paramsJSON?: string | null): unknown {
|
||||
if (!paramsJSON) {
|
||||
return undefined;
|
||||
@@ -77,71 +53,38 @@ async function requireLocalResumableClaudeSession(
|
||||
throw new Error("Claude session cannot be resumed in a terminal");
|
||||
}
|
||||
|
||||
export function createClaudeSessionNodeHostCommands(): OpenClawPluginNodeHostCommand[] {
|
||||
return [
|
||||
{
|
||||
command: CLAUDE_SESSIONS_LIST_COMMAND,
|
||||
cap: CLAUDE_SESSIONS_CAPABILITY,
|
||||
dangerous: false,
|
||||
isAvailable: ({ env }) => claudeProjectsAvailable(env),
|
||||
handle: async (paramsJSON) =>
|
||||
JSON.stringify(await listLocalClaudeSessionPage(parseNodeParams(paramsJSON))),
|
||||
},
|
||||
{
|
||||
command: CLAUDE_SESSION_READ_COMMAND,
|
||||
cap: CLAUDE_SESSIONS_CAPABILITY,
|
||||
dangerous: false,
|
||||
isAvailable: ({ env }) => claudeProjectsAvailable(env),
|
||||
handle: async (paramsJSON) =>
|
||||
JSON.stringify(await readLocalClaudeTranscriptPage(parseNodeParams(paramsJSON))),
|
||||
},
|
||||
{
|
||||
command: CLAUDE_TERMINAL_RESUME_COMMAND,
|
||||
cap: CLAUDE_SESSIONS_CAPABILITY,
|
||||
dangerous: false,
|
||||
duplex: true,
|
||||
isAvailable: ({ env }) =>
|
||||
claudeProjectsAvailable(env) && Boolean(resolveClaudeTerminalExecutable(env)),
|
||||
handle: async (paramsJSON, io) => {
|
||||
if (!io) {
|
||||
throw new Error("Claude terminal command requires duplex transport");
|
||||
}
|
||||
const params = decodeNodePtyResumeParams(paramsJSON, validateClaudeSessionId);
|
||||
const record = await requireLocalResumableClaudeSession(params.threadId);
|
||||
const resolution = resolveClaudeTerminalExecutable();
|
||||
if (!resolution) {
|
||||
throw new Error("Claude CLI is unavailable");
|
||||
}
|
||||
return JSON.stringify(
|
||||
await runNodePtyCommand(
|
||||
{
|
||||
file: resolution.executable,
|
||||
args: ["--resume", params.threadId],
|
||||
cwd: record.cwd,
|
||||
...(resolution.pathEnv ? { pathEnv: resolution.pathEnv } : {}),
|
||||
cols: params.cols,
|
||||
rows: params.rows,
|
||||
},
|
||||
io,
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
export async function listClaudeSessions(paramsJSON?: string | null): Promise<string> {
|
||||
return JSON.stringify(await listLocalClaudeSessionPage(parseNodeParams(paramsJSON)));
|
||||
}
|
||||
|
||||
export function createClaudeSessionNodeInvokePolicies(): OpenClawPluginNodeInvokePolicy[] {
|
||||
return [
|
||||
{
|
||||
commands: [
|
||||
CLAUDE_SESSIONS_LIST_COMMAND,
|
||||
CLAUDE_SESSION_READ_COMMAND,
|
||||
CLAUDE_CLI_NODE_RUN_COMMAND,
|
||||
CLAUDE_TERMINAL_RESUME_COMMAND,
|
||||
],
|
||||
defaultPlatforms: ["macos", "linux", "windows"],
|
||||
handle: (context) =>
|
||||
context.command === CLAUDE_TERMINAL_RESUME_COMMAND ? { ok: true } : context.invokeNode(),
|
||||
},
|
||||
];
|
||||
export async function readClaudeSession(paramsJSON?: string | null): Promise<string> {
|
||||
return JSON.stringify(await readLocalClaudeTranscriptPage(parseNodeParams(paramsJSON)));
|
||||
}
|
||||
|
||||
export async function resumeClaudeSession(
|
||||
paramsJSON: string | null | undefined,
|
||||
io: OpenClawPluginNodeHostCommandIo | undefined,
|
||||
): Promise<string> {
|
||||
if (!io) {
|
||||
throw new Error("Claude terminal command requires duplex transport");
|
||||
}
|
||||
const params = decodeNodePtyResumeParams(paramsJSON, validateClaudeSessionId);
|
||||
const record = await requireLocalResumableClaudeSession(params.threadId);
|
||||
const resolution = resolveClaudeTerminalExecutable();
|
||||
if (!resolution) {
|
||||
throw new Error("Claude CLI is unavailable");
|
||||
}
|
||||
return JSON.stringify(
|
||||
await runNodePtyCommand(
|
||||
{
|
||||
file: resolution.executable,
|
||||
args: ["--resume", params.threadId],
|
||||
cwd: record.cwd,
|
||||
...(resolution.pathEnv ? { pathEnv: resolution.pathEnv } : {}),
|
||||
cols: params.cols,
|
||||
rows: params.rows,
|
||||
},
|
||||
io,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,31 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createClaudeSessionNodeHostCommands } from "./session-catalog-node-commands.js";
|
||||
import { registerClaudeSessionCatalog } from "./session-catalog.js";
|
||||
import { statSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
createLazyRuntimeModule,
|
||||
createLazyRuntimeSurface,
|
||||
} from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import type {
|
||||
OpenClawPluginApi,
|
||||
OpenClawPluginNodeHostCommand,
|
||||
OpenClawPluginNodeInvokePolicy,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import type { SessionCatalogProvider } from "openclaw/plugin-sdk/session-catalog";
|
||||
import { CLAUDE_CLI_BACKEND_ID, CLAUDE_CLI_ROUTE_PROBE_MODEL_IDS } from "./cli-constants.js";
|
||||
import { resolveClaudeTerminalExecutable } from "./session-catalog-executable.js";
|
||||
import {
|
||||
CLAUDE_CLI_NODE_RUN_COMMAND,
|
||||
CLAUDE_SESSION_READ_COMMAND,
|
||||
CLAUDE_SESSIONS_LIST_COMMAND,
|
||||
CLAUDE_TERMINAL_RESUME_COMMAND,
|
||||
} from "./session-catalog-shared.js";
|
||||
|
||||
const CLAUDE_SESSIONS_CAPABILITY = "claude-sessions";
|
||||
|
||||
const loadClaudeSessionNodeCommands = createLazyRuntimeModule(
|
||||
() => import("./session-catalog-node-commands.js"),
|
||||
);
|
||||
|
||||
function isClaudeSessionCatalogEnabled(pluginConfig: unknown): boolean {
|
||||
if (!pluginConfig || typeof pluginConfig !== "object") {
|
||||
@@ -14,6 +39,94 @@ function isClaudeSessionCatalogEnabled(pluginConfig: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// Node declarations expose catalog commands only when this machine owns a
|
||||
// Claude session store; otherwise the gateway must skip the node capability.
|
||||
function claudeProjectsAvailable(env: NodeJS.ProcessEnv): boolean {
|
||||
const homeDir = env.HOME?.trim() || env.USERPROFILE?.trim() || os.homedir();
|
||||
try {
|
||||
return statSync(path.join(homeDir, ".claude", "projects")).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function currentConfig(api: OpenClawPluginApi): OpenClawConfig {
|
||||
return (api.runtime.config?.current?.() ?? api.config ?? {}) as OpenClawConfig;
|
||||
}
|
||||
|
||||
function registerClaudeSessionCatalog(api: OpenClawPluginApi): void {
|
||||
const loadCatalogRuntime = createLazyRuntimeSurface(
|
||||
() => import("./session-catalog.js"),
|
||||
(module) => module.createClaudeSessionCatalogRuntime(api),
|
||||
);
|
||||
const provider: SessionCatalogProvider = {
|
||||
id: "claude",
|
||||
label: "Claude Code",
|
||||
resolveCreateSession: ({ agentId }) =>
|
||||
api.runtime.agent.resolveSessionCatalogCreateTarget({
|
||||
config: currentConfig(api),
|
||||
requestedAgentId: agentId,
|
||||
provider: "anthropic",
|
||||
modelIds: CLAUDE_CLI_ROUTE_PROBE_MODEL_IDS,
|
||||
agentRuntime: CLAUDE_CLI_BACKEND_ID,
|
||||
}),
|
||||
list: async (query) => await (await loadCatalogRuntime()).list(query),
|
||||
read: async (request) => await (await loadCatalogRuntime()).read(request),
|
||||
continueSession: async (request) => await (await loadCatalogRuntime()).continueSession(request),
|
||||
openTerminal: async (request) => await (await loadCatalogRuntime()).openTerminal(request),
|
||||
checkUpstreamActivity: async (probes) =>
|
||||
await (await loadCatalogRuntime()).checkUpstreamActivity(probes),
|
||||
};
|
||||
api.registerSessionCatalog(provider);
|
||||
}
|
||||
|
||||
function createClaudeSessionNodeHostCommands(): OpenClawPluginNodeHostCommand[] {
|
||||
return [
|
||||
{
|
||||
command: CLAUDE_SESSIONS_LIST_COMMAND,
|
||||
cap: CLAUDE_SESSIONS_CAPABILITY,
|
||||
dangerous: false,
|
||||
isAvailable: ({ env }) => claudeProjectsAvailable(env),
|
||||
handle: async (paramsJSON) =>
|
||||
await (await loadClaudeSessionNodeCommands()).listClaudeSessions(paramsJSON),
|
||||
},
|
||||
{
|
||||
command: CLAUDE_SESSION_READ_COMMAND,
|
||||
cap: CLAUDE_SESSIONS_CAPABILITY,
|
||||
dangerous: false,
|
||||
isAvailable: ({ env }) => claudeProjectsAvailable(env),
|
||||
handle: async (paramsJSON) =>
|
||||
await (await loadClaudeSessionNodeCommands()).readClaudeSession(paramsJSON),
|
||||
},
|
||||
{
|
||||
command: CLAUDE_TERMINAL_RESUME_COMMAND,
|
||||
cap: CLAUDE_SESSIONS_CAPABILITY,
|
||||
dangerous: false,
|
||||
duplex: true,
|
||||
isAvailable: ({ env }) =>
|
||||
claudeProjectsAvailable(env) && Boolean(resolveClaudeTerminalExecutable(env)),
|
||||
handle: async (paramsJSON, io) =>
|
||||
await (await loadClaudeSessionNodeCommands()).resumeClaudeSession(paramsJSON, io),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function createClaudeSessionNodeInvokePolicies(): OpenClawPluginNodeInvokePolicy[] {
|
||||
return [
|
||||
{
|
||||
commands: [
|
||||
CLAUDE_SESSIONS_LIST_COMMAND,
|
||||
CLAUDE_SESSION_READ_COMMAND,
|
||||
CLAUDE_CLI_NODE_RUN_COMMAND,
|
||||
CLAUDE_TERMINAL_RESUME_COMMAND,
|
||||
],
|
||||
defaultPlatforms: ["macos", "linux", "windows"],
|
||||
handle: (context) =>
|
||||
context.command === CLAUDE_TERMINAL_RESUME_COMMAND ? { ok: true } : context.invokeNode(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function registerClaudeSessionDiscovery(api: OpenClawPluginApi): void {
|
||||
if (!isClaudeSessionCatalogEnabled(api.pluginConfig)) {
|
||||
return;
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
import {
|
||||
resolveAllowedModelRef,
|
||||
resolveDefaultAgentId,
|
||||
resolveDefaultModelForAgent,
|
||||
} from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { resolveEffectiveAgentRuntime } from "openclaw/plugin-sdk/command-auth-native";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
||||
@@ -89,31 +84,3 @@ export function resolveClaudeCliRoutedModelId(
|
||||
}) === CLAUDE_CLI_BACKEND_ID,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveClaudeCatalogCreateSession(
|
||||
api: OpenClawPluginApi,
|
||||
requestedAgentId?: string,
|
||||
): { model: string; agentRuntime: string } | undefined {
|
||||
const config = currentClaudeSessionCatalogConfig(api);
|
||||
const agentId = requestedAgentId ?? resolveDefaultAgentId(config);
|
||||
const routedModelId = resolveClaudeCliRoutedModelId(config, agentId);
|
||||
if (!routedModelId) {
|
||||
return undefined;
|
||||
}
|
||||
const routedModelRef = `anthropic/${routedModelId}`;
|
||||
const defaultModel = resolveDefaultModelForAgent({ cfg: config, agentId });
|
||||
const allowed = resolveAllowedModelRef({
|
||||
cfg: config,
|
||||
catalog: [],
|
||||
raw: routedModelRef,
|
||||
defaultProvider: defaultModel.provider,
|
||||
defaultModel: defaultModel.model,
|
||||
agentId,
|
||||
});
|
||||
return "error" in allowed
|
||||
? undefined
|
||||
: {
|
||||
model: routedModelRef,
|
||||
agentRuntime: CLAUDE_CLI_BACKEND_ID,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,15 +2,19 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import type {
|
||||
OpenClawPluginApi,
|
||||
OpenClawPluginNodeHostCommand,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import { createPluginRuntimeMock } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import type { SessionCatalogProvider } from "openclaw/plugin-sdk/session-catalog";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { adoptedSourceKey } from "./session-catalog-adoption.js";
|
||||
import {
|
||||
createClaudeSessionNodeHostCommands,
|
||||
createClaudeSessionNodeInvokePolicies,
|
||||
} from "./session-catalog-node-commands.js";
|
||||
registerClaudeSessionDiscovery,
|
||||
} from "./session-catalog-registration.js";
|
||||
import { listBoundClaudeSessions } from "./session-catalog-runtime.js";
|
||||
import {
|
||||
CLAUDE_CLI_NODE_RUN_COMMAND,
|
||||
@@ -19,9 +23,29 @@ import {
|
||||
CLAUDE_TERMINAL_RESUME_COMMAND,
|
||||
listLocalClaudeSessionPage,
|
||||
readLocalClaudeTranscriptPage,
|
||||
registerClaudeSessionCatalog,
|
||||
} from "./session-catalog.js";
|
||||
|
||||
function registerClaudeSessionCatalog(api: OpenClawPluginApi): void {
|
||||
registerClaudeSessionDiscovery({
|
||||
...api,
|
||||
registerNodeHostCommand: api.registerNodeHostCommand ?? (() => {}),
|
||||
});
|
||||
}
|
||||
|
||||
function createClaudeSessionNodeHostCommands(): OpenClawPluginNodeHostCommand[] {
|
||||
const commands: OpenClawPluginNodeHostCommand[] = [];
|
||||
registerClaudeSessionDiscovery({
|
||||
id: "anthropic",
|
||||
config: {},
|
||||
runtime: createPluginRuntimeMock(),
|
||||
registerSessionCatalog: () => {},
|
||||
registerNodeHostCommand: (command: OpenClawPluginNodeHostCommand) => {
|
||||
commands.push(command);
|
||||
},
|
||||
} as unknown as OpenClawPluginApi);
|
||||
return commands;
|
||||
}
|
||||
|
||||
function captureCatalogProvider(runtime: PluginRuntime): SessionCatalogProvider {
|
||||
let provider: SessionCatalogProvider | undefined;
|
||||
const runtimeWithSession = {
|
||||
@@ -509,29 +533,28 @@ describe("Claude session catalog", () => {
|
||||
sessionId: "openclaw-adopted",
|
||||
entry: { sessionId: "openclaw-adopted", updatedAt: Date.now() },
|
||||
}));
|
||||
const config = {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"anthropic/claude-opus-4-8": { agentRuntime: { id: "claude-cli" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
let provider: SessionCatalogProvider | undefined;
|
||||
const api = {
|
||||
id: "anthropic",
|
||||
config: {},
|
||||
runtime: {
|
||||
config: {
|
||||
current: () => ({
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"anthropic/claude-opus-4-8": { agentRuntime: { id: "claude-cli" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
runtime: createPluginRuntimeMock({
|
||||
config: { current: () => config },
|
||||
agent: {
|
||||
session: {
|
||||
listSessionEntries: () => [],
|
||||
createSessionEntry,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
registerSessionCatalog: (candidate: SessionCatalogProvider) => {
|
||||
provider = candidate;
|
||||
},
|
||||
@@ -581,9 +604,7 @@ describe("Claude session catalog", () => {
|
||||
const api = {
|
||||
id: "anthropic",
|
||||
config: {},
|
||||
runtime: {
|
||||
config: { current: () => config },
|
||||
},
|
||||
runtime: createPluginRuntimeMock({ config: { current: () => config } }),
|
||||
registerSessionCatalog: (candidate: SessionCatalogProvider) => {
|
||||
provider = candidate;
|
||||
},
|
||||
@@ -623,7 +644,7 @@ describe("Claude session catalog", () => {
|
||||
const api = {
|
||||
id: "anthropic",
|
||||
config,
|
||||
runtime: { config: { current: () => config } },
|
||||
runtime: createPluginRuntimeMock({ config: { current: () => config } }),
|
||||
registerSessionCatalog: (candidate: SessionCatalogProvider) => {
|
||||
provider = candidate;
|
||||
},
|
||||
@@ -661,7 +682,7 @@ describe("Claude session catalog", () => {
|
||||
const api = {
|
||||
id: "anthropic",
|
||||
config,
|
||||
runtime: { config: { current: () => config } },
|
||||
runtime: createPluginRuntimeMock({ config: { current: () => config } }),
|
||||
registerSessionCatalog: (candidate: SessionCatalogProvider) => {
|
||||
provider = candidate;
|
||||
},
|
||||
@@ -698,7 +719,7 @@ describe("Claude session catalog", () => {
|
||||
const api = {
|
||||
id: "anthropic",
|
||||
config,
|
||||
runtime: { config: { current: () => config } },
|
||||
runtime: createPluginRuntimeMock({ config: { current: () => config } }),
|
||||
registerSessionCatalog: (candidate: SessionCatalogProvider) => {
|
||||
provider = candidate;
|
||||
},
|
||||
@@ -736,7 +757,7 @@ describe("Claude session catalog", () => {
|
||||
const api = {
|
||||
id: "anthropic",
|
||||
config,
|
||||
runtime: { config: { current: () => config } },
|
||||
runtime: createPluginRuntimeMock({ config: { current: () => config } }),
|
||||
registerSessionCatalog: (candidate: SessionCatalogProvider) => {
|
||||
provider = candidate;
|
||||
},
|
||||
|
||||
@@ -26,7 +26,6 @@ import { createNodeListFailedError, resolveNodeLabel } from "./session-catalog-n
|
||||
import {
|
||||
currentClaudeSessionCatalogConfig,
|
||||
listBoundClaudeSessions,
|
||||
resolveClaudeCatalogCreateSession,
|
||||
resolveClaudeCliRoutedModelId,
|
||||
} from "./session-catalog-runtime.js";
|
||||
import {
|
||||
@@ -1904,11 +1903,17 @@ function toGenericClaudeHost(
|
||||
};
|
||||
}
|
||||
|
||||
export function registerClaudeSessionCatalog(api: OpenClawPluginApi): void {
|
||||
const provider: SessionCatalogProvider = {
|
||||
id: "claude",
|
||||
label: "Claude Code",
|
||||
resolveCreateSession: ({ agentId }) => resolveClaudeCatalogCreateSession(api, agentId),
|
||||
type ClaudeSessionCatalogRuntime = Required<
|
||||
Pick<
|
||||
SessionCatalogProvider,
|
||||
"list" | "read" | "continueSession" | "openTerminal" | "checkUpstreamActivity"
|
||||
>
|
||||
>;
|
||||
|
||||
export function createClaudeSessionCatalogRuntime(
|
||||
api: OpenClawPluginApi,
|
||||
): ClaudeSessionCatalogRuntime {
|
||||
return {
|
||||
list: async (query) => {
|
||||
const adopted = listBoundClaudeSessions(api, query.sessionEntries);
|
||||
const localCliAvailable = catalogTerminal.isClaudeCliAvailable();
|
||||
@@ -1954,6 +1959,5 @@ export function registerClaudeSessionCatalog(api: OpenClawPluginApi): void {
|
||||
).items;
|
||||
}),
|
||||
};
|
||||
api.registerSessionCatalog(provider);
|
||||
}
|
||||
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { createChannelReplyPipeline } from "../../channels/message/reply-pipeline.js";
|
||||
import { resolveSessionEntryResetFreshness } from "../../config/sessions/entry-freshness.js";
|
||||
import { createChannelRuntimeContextRegistry } from "../../plugins/runtime/channel-runtime-contexts.js";
|
||||
import { resolveSessionCatalogCreateTarget } from "../../plugins/runtime/runtime-agent-session-catalog.js";
|
||||
import type { PluginRuntime } from "../../plugins/runtime/types.js";
|
||||
import {
|
||||
implicitMentionKindWhen,
|
||||
@@ -527,6 +528,9 @@ export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> =
|
||||
resolveAgentIdentity: vi.fn(() => ({
|
||||
name: "test-agent",
|
||||
})) as unknown as PluginRuntime["agent"]["resolveAgentIdentity"],
|
||||
resolveSessionCatalogCreateTarget: vi.fn(
|
||||
resolveSessionCatalogCreateTarget,
|
||||
) as unknown as PluginRuntime["agent"]["resolveSessionCatalogCreateTarget"],
|
||||
resolveThinkingDefault: vi.fn(
|
||||
() => "off",
|
||||
) as unknown as PluginRuntime["agent"]["resolveThinkingDefault"],
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import {
|
||||
resolveAllowedModelRef,
|
||||
resolveDefaultModelForAgent,
|
||||
} from "../../agents/model-selection.js";
|
||||
import { resolveEffectiveAgentRuntime } from "../../agents/thinking-runtime.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import type { SessionCatalogCreateTarget } from "../session-catalog.js";
|
||||
|
||||
type RuntimeSessionCatalogCreateTargetParams = {
|
||||
config: OpenClawConfig;
|
||||
requestedAgentId?: string;
|
||||
provider: string;
|
||||
modelIds: readonly string[];
|
||||
agentRuntime: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a synchronous catalog create target through the same model/runtime
|
||||
* policy used by agent turns, without making plugins import that policy graph.
|
||||
*/
|
||||
export function resolveSessionCatalogCreateTarget(
|
||||
params: RuntimeSessionCatalogCreateTargetParams,
|
||||
): SessionCatalogCreateTarget | undefined {
|
||||
const agentId = params.requestedAgentId ?? resolveDefaultAgentId(params.config);
|
||||
const defaultModel = resolveDefaultModelForAgent({ cfg: params.config, agentId });
|
||||
for (const modelId of params.modelIds) {
|
||||
if (
|
||||
resolveEffectiveAgentRuntime({
|
||||
cfg: params.config,
|
||||
provider: params.provider,
|
||||
modelId,
|
||||
agentId,
|
||||
}) !== params.agentRuntime
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const model = `${params.provider}/${modelId}`;
|
||||
const allowed = resolveAllowedModelRef({
|
||||
cfg: params.config,
|
||||
catalog: [],
|
||||
raw: model,
|
||||
defaultProvider: defaultModel.provider,
|
||||
defaultModel: defaultModel.model,
|
||||
agentId,
|
||||
});
|
||||
if (!("error" in allowed)) {
|
||||
return { model, agentRuntime: params.agentRuntime };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -21,6 +21,59 @@ function createDeferred(): { promise: Promise<void>; resolve: () => void } {
|
||||
}
|
||||
|
||||
describe("plugin runtime session creation", () => {
|
||||
it("resolves synchronous session catalog targets through agent model policy", () => {
|
||||
const runtime = createRuntimeAgent();
|
||||
const config = {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"anthropic/claude-opus-4-8": { agentRuntime: { id: "claude-cli" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
runtime.resolveSessionCatalogCreateTarget({
|
||||
config,
|
||||
provider: "anthropic",
|
||||
modelIds: ["claude-opus-5", "claude-opus-4-8"],
|
||||
agentRuntime: "claude-cli",
|
||||
}),
|
||||
).toEqual({
|
||||
model: "anthropic/claude-opus-4-8",
|
||||
agentRuntime: "claude-cli",
|
||||
});
|
||||
});
|
||||
|
||||
it("skips routed catalog targets denied by agent model policy", () => {
|
||||
const runtime = createRuntimeAgent();
|
||||
const config = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "anthropic/claude-opus-4-8" },
|
||||
models: {
|
||||
"anthropic/claude-opus-5": { agentRuntime: { id: "claude-cli" } },
|
||||
"anthropic/claude-opus-4-8": { agentRuntime: { id: "claude-cli" } },
|
||||
},
|
||||
modelPolicy: { allow: ["anthropic/claude-opus-4-8"] },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
runtime.resolveSessionCatalogCreateTarget({
|
||||
config,
|
||||
provider: "anthropic",
|
||||
modelIds: ["claude-opus-5", "claude-opus-4-8"],
|
||||
agentRuntime: "claude-cli",
|
||||
}),
|
||||
).toEqual({
|
||||
model: "anthropic/claude-opus-4-8",
|
||||
agentRuntime: "claude-cli",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires recovery initialization to return the final trusted patch", () => {
|
||||
type CreateSessionParams = Parameters<
|
||||
ReturnType<typeof createRuntimeAgent>["session"]["createSessionEntry"]
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
runExclusiveSessionLifecycleMutation,
|
||||
} from "../../sessions/session-lifecycle-admission.js";
|
||||
import { createLazyRuntimeMethod, createLazyRuntimeModule } from "../../shared/lazy-runtime.js";
|
||||
import { resolveSessionCatalogCreateTarget } from "./runtime-agent-session-catalog.js";
|
||||
import { resolveRuntimeThinkingCatalog } from "./runtime-agent-thinking.js";
|
||||
import { defineCachedValue } from "./runtime-cache.js";
|
||||
import type { PluginRuntime } from "./types.js";
|
||||
@@ -580,6 +581,7 @@ export function createRuntimeAgent(): PluginRuntime["agent"] {
|
||||
resolveAgentDir,
|
||||
resolveAgentWorkspaceDir,
|
||||
resolveAgentIdentity,
|
||||
resolveSessionCatalogCreateTarget,
|
||||
resolveThinkingDefault,
|
||||
normalizeThinkingLevel: normalizeThinkLevel,
|
||||
resolveThinkingPolicy: (params) => {
|
||||
|
||||
@@ -327,6 +327,8 @@ export type PluginRuntimeCore = {
|
||||
resolveAgentDir: typeof import("../../agents/agent-scope.js").resolveAgentDir;
|
||||
resolveAgentWorkspaceDir: typeof import("../../agents/agent-scope.js").resolveAgentWorkspaceDir;
|
||||
resolveAgentIdentity: typeof import("../../agents/identity.js").resolveAgentIdentity;
|
||||
/** Resolve an allowed catalog create target through canonical agent model/runtime policy. */
|
||||
resolveSessionCatalogCreateTarget: typeof import("./runtime-agent-session-catalog.js").resolveSessionCatalogCreateTarget;
|
||||
resolveThinkingDefault: (params: {
|
||||
cfg: import("../../config/types.openclaw.js").OpenClawConfig;
|
||||
provider: string;
|
||||
|
||||
Reference in New Issue
Block a user