mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-31 06:34:54 +00:00
* refactor: extract agent core package Introduce packages/agent-core as the OpenClaw-owned home for reusable agent loop, harness, session, prompt, and runtime dependency contracts. * refactor: extract shared llm runtime Move provider model registries, stream wrappers, OAuth helpers, and LLM utilities into src/llm with plugin-sdk barrels instead of depending on the old embedded runtime layout. * refactor: remove pi runtime internals Rename remaining Pi-shaped agent surfaces to OpenClaw agent runtime names, delete obsolete Pi docs and package graph checks, and add the third-party notice for incorporated code. * refactor: tighten agent session runtime Make agent-core/runtime dependencies explicit, consolidate compaction and session transcript helpers, and move model/session helpers behind OpenClaw-owned contracts. * refactor: remove static model and pi auth paths Drop static model catalogs and Pi auth bridges, move model/provider facts to manifest-owned runtime contracts, and harden internal embedded-agent utilities. * refactor: remove legacy provider compat paths * docs: remove agent parity notes * fix: skip provider wildcard metadata parsing * refactor: share session extension sdk loading * refactor: inline acpx proxy error formatter * refactor: fold edit recovery into edit tool * fix: accept extension batch separator * test: align startup provider plugin expectations * fix: restore provider-scoped release discovery * test: align static asset packaging expectations * fix: run static provider catalogs during scoped discovery * fix: add provider entry catalogs for scoped live discovery * fix: load lightweight provider catalog entries * fix: refresh provider-scoped plugin metadata * fix: keep provider catalog entries on release live path * fix: keep static manifest models in release live checks * fix: harden release model discovery * fix: reduce OpenAI live cache probe reasoning * fix: disable OpenAI cache probe reasoning * ci: extend OpenAI gateway live timeout * fix: extend live gateway model budget * fix: stabilize release validation regressions * fix: honor provider aliases in model rows * fix: stabilize release validation lanes * fix: stabilize release memory qa * ci: stabilize release validation lanes * ci: prefer ipv4 for live docker node calls * fix: restore shared tool-call stream wrapper * ci: remove legacy pi test shard alias * fix: clean up embedded agent test drift * fix: stabilize runtime alias status * fix: clean up embedded agent ci drift * fix: restore release ci invariants * fix: clean up post-rebase runtime drift * fix: restore release ci checks * fix: restore release ci after rebase * fix: remove stale pi runtime path * test: align compaction runtime expectations * test: update plugin prerelease expectations * fix: handle claude live tool approvals * fix: stabilize release validation gates * fix: finish agent runtime import * test: finish post-rebase agent runtime mocks * fix: keep codex compaction native * fix: stabilize codex app-server hook tests * test: isolate codex diagnostic active run * test: remove codex diagnostic completion race # Conflicts: # extensions/codex/src/app-server/run-attempt.test.ts * ci: fix full release manifest performance run id * refactor: narrow llm plugin sdk boundary * chore: drop generated google boundary stamps * fix: repair rebase fallout * fix: clean up rebased runtime references * fix: decode codex jwt payloads as base64url * fix: preserve shipped pi runtime alias * fix: add scoped sdk virtual modules * fix: decode llm codex oauth jwt as base64url * fix: avoid stale vertex adc negative cache * fix: harden tool arg decoding and codeql path * fix: keep vertex adc negative checks live * refactor: consolidate codex jwt and edit helpers * fix: await codex oauth node runtime imports * fix: preserve sdk tool and notice contracts * fix: preserve shipped compat config boundaries * fix: align codex oauth callback host * fix: terminate agent-core loop streams on failure * fix: keep codex oauth callback alive during fallback * ci: include session tools in critical codeql scans * fix: keep Cloudflare Anthropic provider auth header * docs: redirect legacy pi runtime pages * fix: honor bundled web provider compat discovery * fix: protect session output spill files * fix: keep legacy agent dir env blocked * fix: contain auto-discovered skill symlinks * fix: harden agent core sdk proxy surfaces * fix: restore approval reaction sdk compat * fix: keep live docker runs bounded * fix: keep codex oauth redirect host aligned * fix: resolve post-rebase agent runtime drift * fix: redact anthropic oauth parse failures * fix: preserve responses strict tool shaping * fix: repair agent runtime rebase cleanup * docs: redirect retired parity pages * fix: bound auto-discovered resources to roots * fix: repair post-rebase agent test drift * fix: preserve bundled provider allowlist migration * fix: preserve manifest-owned provider aliases * fix: declare photon image dependency * fix: keep provider headers out of proxy body * fix: preserve shipped env aliases * fix: refresh control ui i18n generated state * fix: quote read fallback paths * fix: preview edits through configured backend * test: satisfy core test typecheck * fix: preserve ZAI usage auth fallback * test: repair codex diagnostic test * fix: repair agent runtime rebase drift * test: finish embedded runner import rename * fix: repair agent runtime rebase integrations * test: align compaction oauth fallback expectations * fix: allow sdk-auth session models * fix: update doctor tool schema import * fix: preserve bedrock plugin region * fix: stream harmony-like prose immediately * ci: include session runtime in codeql shards * fix: repair latest rebase integrations * fix: honor explicit codex websocket transport * fix: keep openai-compatible credentials provider-scoped * fix: refresh sdk api baseline after rebase * fix: route cli runtime aliases through openclaw harness * test: rename stale harness mock expectation * test: rename embedded agent overflow calls * test: clean embedded auth test wording * test: use openclaw stream types in deepinfra cache test * fix: refresh sdk api baseline on latest main * fix: honor bundled discovery compat allowlists * fix: refresh sdk api baseline after latest rebase * fix: remove stale rebase imports * test: rename stale model catalog mock * test: mock renamed doctor runtime modules * fix: map canonical kimi env auth * fix: use internal model registry in bench script * fix: migrate deepinfra provider catalog entry * fix: enforce builtin tool suppression * fix: route compaction auth and proxy payloads safely * refactor: prune unused llm registry leftovers * test: update codex hooks session import * test: fix model picker ci coverage * test: align model picker auth mock types
1382 lines
40 KiB
TypeScript
1382 lines
40 KiB
TypeScript
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
|
|
import { listRegisteredPluginAgentPromptGuidance } from "./command-registry-state.js";
|
|
import {
|
|
testing,
|
|
clearPluginCommands,
|
|
executePluginCommand,
|
|
getPluginCommandSpecs,
|
|
listProviderPluginCommandSpecs,
|
|
listPluginCommands,
|
|
matchPluginCommand,
|
|
registerPluginCommand,
|
|
} from "./commands.js";
|
|
import { createPluginRegistry, type PluginRecord } from "./registry.js";
|
|
import { setActivePluginRegistry } from "./runtime.js";
|
|
import type { PluginRuntime } from "./runtime/types.js";
|
|
|
|
const completionMocks = vi.hoisted(() => ({
|
|
prepareSimpleCompletionModelForAgent: vi.fn(),
|
|
completeWithPreparedSimpleCompletionModel: vi.fn(),
|
|
resolveSimpleCompletionSelectionForAgent: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../agents/simple-completion-runtime.js", () => ({
|
|
prepareSimpleCompletionModelForAgent: completionMocks.prepareSimpleCompletionModelForAgent,
|
|
completeWithPreparedSimpleCompletionModel:
|
|
completionMocks.completeWithPreparedSimpleCompletionModel,
|
|
resolveSimpleCompletionSelectionForAgent:
|
|
completionMocks.resolveSimpleCompletionSelectionForAgent,
|
|
}));
|
|
|
|
type CommandsModule = typeof import("./commands.js");
|
|
|
|
const commandsModuleUrl = new URL("./commands.ts", import.meta.url).href;
|
|
|
|
async function importCommandsModule(cacheBust: string): Promise<CommandsModule> {
|
|
return (await import(`${commandsModuleUrl}?t=${cacheBust}`)) as CommandsModule;
|
|
}
|
|
|
|
function createVoiceCommand(overrides: Partial<Parameters<typeof registerPluginCommand>[1]> = {}) {
|
|
return {
|
|
name: "voice",
|
|
description: "Voice command",
|
|
handler: async () => ({ text: "ok" }),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createBundledPluginRecord(id: string): PluginRecord {
|
|
return {
|
|
id,
|
|
name: id,
|
|
source: `bundled:${id}`,
|
|
rootDir: `/bundled/${id}`,
|
|
origin: "bundled",
|
|
enabled: true,
|
|
status: "loaded",
|
|
toolNames: [],
|
|
hookNames: [],
|
|
channelIds: [],
|
|
cliBackendIds: [],
|
|
providerIds: [],
|
|
embeddingProviderIds: [],
|
|
speechProviderIds: [],
|
|
realtimeTranscriptionProviderIds: [],
|
|
realtimeVoiceProviderIds: [],
|
|
mediaUnderstandingProviderIds: [],
|
|
transcriptSourceProviderIds: [],
|
|
imageGenerationProviderIds: [],
|
|
videoGenerationProviderIds: [],
|
|
musicGenerationProviderIds: [],
|
|
webFetchProviderIds: [],
|
|
webSearchProviderIds: [],
|
|
migrationProviderIds: [],
|
|
memoryEmbeddingProviderIds: [],
|
|
agentHarnessIds: [],
|
|
cliCommands: [],
|
|
services: [],
|
|
gatewayDiscoveryServiceIds: [],
|
|
commands: [],
|
|
httpRoutes: 0,
|
|
hookCount: 0,
|
|
configSchema: false,
|
|
} as PluginRecord;
|
|
}
|
|
|
|
function registerHostTrustedReservedCommandForTest(
|
|
command: Parameters<typeof registerPluginCommand>[1],
|
|
) {
|
|
const pluginRegistry = createPluginRegistry({
|
|
logger: {
|
|
info() {},
|
|
warn() {},
|
|
error() {},
|
|
debug() {},
|
|
},
|
|
runtime: {} as PluginRuntime,
|
|
activateGlobalSideEffects: true,
|
|
});
|
|
pluginRegistry.registerCommand(createBundledPluginRecord(command.name), command);
|
|
}
|
|
|
|
function registerVoiceCommandForTest(
|
|
overrides: Partial<Parameters<typeof registerPluginCommand>[1]> = {},
|
|
) {
|
|
return registerPluginCommand("demo-plugin", createVoiceCommand(overrides));
|
|
}
|
|
|
|
function resolveBindingConversationFromCommand(
|
|
params: Parameters<typeof testing.resolveBindingConversationFromCommand>[0],
|
|
) {
|
|
return testing.resolveBindingConversationFromCommand(params);
|
|
}
|
|
|
|
function expectCommandMatch(
|
|
commandBody: string,
|
|
params: { name: string; pluginId: string; args: string },
|
|
) {
|
|
const match = requirePluginCommandMatch(commandBody);
|
|
expect(match.command.name).toBe(params.name);
|
|
expect(match.command.pluginId).toBe(params.pluginId);
|
|
expect(match.args).toBe(params.args);
|
|
}
|
|
|
|
function requirePluginCommandMatch(commandBody: string) {
|
|
const match = matchPluginCommand(commandBody);
|
|
if (!match) {
|
|
throw new Error(`expected plugin command match for ${commandBody}`);
|
|
}
|
|
return match;
|
|
}
|
|
|
|
function expectProviderCommandSpecs(
|
|
provider: Parameters<typeof getPluginCommandSpecs>[0],
|
|
expectedNames: readonly string[],
|
|
) {
|
|
expect(getPluginCommandSpecs(provider)).toEqual(
|
|
expectedNames.map((name) => ({
|
|
name,
|
|
description: "Demo command",
|
|
acceptsArgs: false,
|
|
})),
|
|
);
|
|
}
|
|
|
|
function expectProviderCommandSpecCases(
|
|
cases: ReadonlyArray<{
|
|
provider: Parameters<typeof getPluginCommandSpecs>[0];
|
|
expectedNames: readonly string[];
|
|
}>,
|
|
) {
|
|
cases.forEach(({ provider, expectedNames }) => {
|
|
expectProviderCommandSpecs(provider, expectedNames);
|
|
});
|
|
}
|
|
|
|
function expectUnsupportedBindingApiResult(result: { text?: string }) {
|
|
expect(result.text).toBe(
|
|
JSON.stringify({
|
|
requested: {
|
|
status: "error",
|
|
message: "This command cannot bind the current conversation.",
|
|
},
|
|
current: null,
|
|
detached: { removed: false },
|
|
}),
|
|
);
|
|
}
|
|
|
|
function expectBindingConversationCase(
|
|
params: Parameters<typeof resolveBindingConversationFromCommand>[0],
|
|
expected: ReturnType<typeof resolveBindingConversationFromCommand>,
|
|
) {
|
|
expect(resolveBindingConversationFromCommand(params)).toEqual(expected);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
completionMocks.prepareSimpleCompletionModelForAgent.mockReset();
|
|
completionMocks.prepareSimpleCompletionModelForAgent.mockResolvedValue({
|
|
selection: {
|
|
provider: "openai",
|
|
modelId: "gpt-5.5",
|
|
agentDir: "/tmp/openclaw-agent",
|
|
},
|
|
model: {
|
|
provider: "openai",
|
|
id: "gpt-5.5",
|
|
name: "GPT-5.5",
|
|
api: "openai",
|
|
input: ["text"],
|
|
reasoning: false,
|
|
contextWindow: 128_000,
|
|
maxTokens: 4096,
|
|
cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0.2 },
|
|
},
|
|
auth: {
|
|
apiKey: "test-api-key",
|
|
source: "test",
|
|
mode: "api-key",
|
|
},
|
|
});
|
|
completionMocks.completeWithPreparedSimpleCompletionModel.mockReset();
|
|
completionMocks.completeWithPreparedSimpleCompletionModel.mockResolvedValue({
|
|
content: [{ type: "text", text: "done" }],
|
|
usage: {},
|
|
});
|
|
completionMocks.resolveSimpleCompletionSelectionForAgent.mockReset();
|
|
completionMocks.resolveSimpleCompletionSelectionForAgent.mockReturnValue({
|
|
provider: "openai",
|
|
modelId: "gpt-5.5",
|
|
agentDir: "/tmp/openclaw-agent",
|
|
});
|
|
setActivePluginRegistry(
|
|
createTestRegistry([
|
|
{
|
|
pluginId: "telegram",
|
|
source: "test",
|
|
plugin: {
|
|
...createChannelTestPluginBase({ id: "telegram", label: "Telegram" }),
|
|
commands: {
|
|
nativeCommandsAutoEnabled: true,
|
|
},
|
|
bindings: {
|
|
selfParentConversationByDefault: true,
|
|
resolveCommandConversation: ({
|
|
threadId,
|
|
originatingTo,
|
|
commandTo,
|
|
fallbackTo,
|
|
}: {
|
|
threadId?: string;
|
|
originatingTo?: string;
|
|
commandTo?: string;
|
|
fallbackTo?: string;
|
|
}) => {
|
|
const rawTarget = [commandTo, originatingTo, fallbackTo].find(Boolean)?.trim();
|
|
if (!rawTarget || rawTarget.startsWith("slash:")) {
|
|
return null;
|
|
}
|
|
const normalized = rawTarget.replace(/^telegram:/i, "");
|
|
const topicMatch = /^(.*?):topic:(\d+)$/i.exec(normalized);
|
|
if (topicMatch?.[1]) {
|
|
return {
|
|
conversationId: `${topicMatch[1]}:topic:${threadId ?? topicMatch[2]}`,
|
|
parentConversationId: topicMatch[1],
|
|
};
|
|
}
|
|
return { conversationId: normalized };
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
pluginId: "discord",
|
|
source: "test",
|
|
plugin: {
|
|
...createChannelTestPluginBase({ id: "discord", label: "Discord" }),
|
|
commands: {
|
|
nativeCommandsAutoEnabled: true,
|
|
},
|
|
bindings: {
|
|
resolveCommandConversation: ({
|
|
threadId,
|
|
threadParentId,
|
|
originatingTo,
|
|
commandTo,
|
|
fallbackTo,
|
|
}: {
|
|
threadId?: string;
|
|
threadParentId?: string;
|
|
originatingTo?: string;
|
|
commandTo?: string;
|
|
fallbackTo?: string;
|
|
}) => {
|
|
const rawTarget = [originatingTo, commandTo, fallbackTo].find(Boolean)?.trim();
|
|
if (!rawTarget || rawTarget.startsWith("slash:")) {
|
|
return null;
|
|
}
|
|
const normalized = rawTarget.replace(/^discord:/i, "");
|
|
if (/^\d+$/.test(normalized)) {
|
|
return { conversationId: `user:${normalized}` };
|
|
}
|
|
if (threadId) {
|
|
const baseConversationId =
|
|
originatingTo?.trim()?.replace(/^discord:/i, "") ||
|
|
commandTo?.trim()?.replace(/^discord:/i, "") ||
|
|
fallbackTo?.trim()?.replace(/^discord:/i, "");
|
|
return {
|
|
conversationId: baseConversationId || threadId,
|
|
...(threadParentId ? { parentConversationId: threadParentId } : {}),
|
|
};
|
|
}
|
|
if (normalized.startsWith("channel:") || normalized.startsWith("user:")) {
|
|
return { conversationId: normalized };
|
|
}
|
|
return null;
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
pluginId: "signal",
|
|
source: "test",
|
|
plugin: {
|
|
...createChannelTestPluginBase({ id: "signal", label: "Signal" }),
|
|
commands: {
|
|
nativeCommandsAutoEnabled: true,
|
|
},
|
|
bindings: {
|
|
resolveCommandConversation: ({ senderId }: { senderId?: string }) => {
|
|
const normalizedSenderId = senderId?.trim();
|
|
return normalizedSenderId ? { conversationId: `dm:${normalizedSenderId}` } : null;
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
pluginId: "slack",
|
|
source: "test",
|
|
plugin: {
|
|
...createChannelTestPluginBase({
|
|
id: "slack",
|
|
label: "Slack",
|
|
capabilities: { nativeCommands: true, chatTypes: ["direct", "group"] },
|
|
}),
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
clearPluginCommands();
|
|
});
|
|
|
|
describe("registerPluginCommand", () => {
|
|
it.each([
|
|
{
|
|
name: "rejects invalid command names",
|
|
command: {
|
|
// Runtime plugin payloads are untyped; guard at boundary.
|
|
name: undefined as unknown as string,
|
|
description: "Demo",
|
|
handler: async () => ({ text: "ok" }),
|
|
},
|
|
expected: {
|
|
ok: false,
|
|
error: "Command name must be a string",
|
|
},
|
|
},
|
|
{
|
|
name: "rejects invalid command descriptions",
|
|
command: {
|
|
name: "demo",
|
|
description: undefined as unknown as string,
|
|
handler: async () => ({ text: "ok" }),
|
|
},
|
|
expected: {
|
|
ok: false,
|
|
error: "Command description must be a string",
|
|
},
|
|
},
|
|
{
|
|
name: "rejects invalid agent prompt guidance",
|
|
command: {
|
|
name: "demo",
|
|
description: "Demo",
|
|
agentPromptGuidance: "use /demo" as unknown as string[],
|
|
handler: async () => ({ text: "ok" }),
|
|
},
|
|
expected: {
|
|
ok: false,
|
|
error: "Agent prompt guidance must be an array of strings or objects",
|
|
},
|
|
},
|
|
{
|
|
name: "rejects invalid structured agent prompt guidance",
|
|
command: {
|
|
name: "demo",
|
|
description: "Demo",
|
|
agentPromptGuidance: [{ text: "Use /demo.", surfaces: ["nope"] }] as never,
|
|
handler: async () => ({ text: "ok" }),
|
|
},
|
|
expected: {
|
|
ok: false,
|
|
error:
|
|
"Agent prompt guidance 1 surface 1 must be one of: openclaw_main, pi_main, codex_app_server, cli_backend, acp_backend, subagent",
|
|
},
|
|
},
|
|
{
|
|
name: "rejects empty structured agent prompt guidance surfaces",
|
|
command: {
|
|
name: "demo",
|
|
description: "Demo",
|
|
agentPromptGuidance: [{ text: "Use /demo.", surfaces: [] }] as never,
|
|
handler: async () => ({ text: "ok" }),
|
|
},
|
|
expected: {
|
|
ok: false,
|
|
error: "Agent prompt guidance 1 surfaces cannot be empty",
|
|
},
|
|
},
|
|
{
|
|
name: "rejects invalid channel scopes",
|
|
command: {
|
|
name: "demo",
|
|
description: "Demo",
|
|
channels: ["telegram", " "],
|
|
handler: async () => ({ text: "ok" }),
|
|
},
|
|
expected: {
|
|
ok: false,
|
|
error: "Command channel 2 cannot be empty",
|
|
},
|
|
},
|
|
{
|
|
name: "rejects primitive native command metadata",
|
|
command: {
|
|
name: "demo",
|
|
description: "Demo",
|
|
nativeNames: "demo-native",
|
|
handler: async () => ({ text: "ok" }),
|
|
},
|
|
expected: {
|
|
ok: false,
|
|
error: "Command nativeNames must be an object",
|
|
},
|
|
},
|
|
] as const)("$name", ({ command, expected }) => {
|
|
expect(registerPluginCommand("demo-plugin", command as never)).toEqual(expected);
|
|
});
|
|
|
|
it("normalizes command metadata for downstream consumers", () => {
|
|
const result = registerPluginCommand("demo-plugin", {
|
|
name: " demo_cmd ",
|
|
description: " Demo command ",
|
|
agentPromptGuidance: [" Use /demo_cmd for demo routing. "],
|
|
handler: async () => ({ text: "ok" }),
|
|
});
|
|
expect(result).toEqual({ ok: true });
|
|
expect(listPluginCommands()).toEqual([
|
|
{
|
|
name: "demo_cmd",
|
|
description: "Demo command",
|
|
pluginId: "demo-plugin",
|
|
acceptsArgs: false,
|
|
},
|
|
]);
|
|
expect(getPluginCommandSpecs()).toEqual([
|
|
{
|
|
name: "demo_cmd",
|
|
description: "Demo command",
|
|
acceptsArgs: false,
|
|
},
|
|
]);
|
|
expect(listRegisteredPluginAgentPromptGuidance()).toEqual(["Use /demo_cmd for demo routing."]);
|
|
});
|
|
|
|
it("normalizes and filters structured agent prompt guidance by surface", () => {
|
|
const result = registerPluginCommand("demo-plugin", {
|
|
name: "demo_cmd",
|
|
description: "Demo command",
|
|
agentPromptGuidance: [
|
|
" Use /demo_cmd everywhere. ",
|
|
{
|
|
text: " Use /demo_cmd for main agent routing. ",
|
|
surfaces: ["openclaw_main"],
|
|
},
|
|
{
|
|
text: "Use /demo_cmd for subagents.",
|
|
surfaces: ["subagent"],
|
|
},
|
|
],
|
|
handler: async () => ({ text: "ok" }),
|
|
});
|
|
expect(result).toEqual({ ok: true });
|
|
|
|
expect(listRegisteredPluginAgentPromptGuidance()).toEqual([
|
|
"Use /demo_cmd everywhere.",
|
|
"Use /demo_cmd for main agent routing.",
|
|
"Use /demo_cmd for subagents.",
|
|
]);
|
|
expect(listRegisteredPluginAgentPromptGuidance({ surface: "openclaw_main" })).toEqual([
|
|
"Use /demo_cmd everywhere.",
|
|
"Use /demo_cmd for main agent routing.",
|
|
]);
|
|
expect(listRegisteredPluginAgentPromptGuidance({ surface: "pi_main" })).toEqual([
|
|
"Use /demo_cmd everywhere.",
|
|
"Use /demo_cmd for main agent routing.",
|
|
]);
|
|
expect(listRegisteredPluginAgentPromptGuidance({ surface: "subagent" })).toEqual([
|
|
"Use /demo_cmd everywhere.",
|
|
"Use /demo_cmd for subagents.",
|
|
]);
|
|
expect(
|
|
listRegisteredPluginAgentPromptGuidance({
|
|
surface: "subagent",
|
|
includeLegacyGlobalGuidance: false,
|
|
}),
|
|
).toEqual(["Use /demo_cmd for subagents."]);
|
|
});
|
|
|
|
it("matches underscore aliases for hyphenated command names", () => {
|
|
registerPluginCommand("demo-plugin", {
|
|
name: "active-memory",
|
|
description: "Active Memory command",
|
|
acceptsArgs: true,
|
|
handler: async () => ({ text: "ok" }),
|
|
});
|
|
|
|
expectCommandMatch("/active_memory status", {
|
|
name: "active-memory",
|
|
pluginId: "demo-plugin",
|
|
args: "status",
|
|
});
|
|
});
|
|
|
|
it("supports provider-specific native command aliases", () => {
|
|
const result = registerVoiceCommandForTest({
|
|
nativeNames: {
|
|
default: "talkvoice",
|
|
discord: "discordvoice",
|
|
},
|
|
description: "Demo command",
|
|
});
|
|
|
|
expect(result).toEqual({ ok: true });
|
|
expectProviderCommandSpecCases([
|
|
{ provider: undefined, expectedNames: ["talkvoice"] },
|
|
{ provider: "discord", expectedNames: ["discordvoice"] },
|
|
{ provider: "telegram", expectedNames: ["talkvoice"] },
|
|
{ provider: "slack", expectedNames: [] },
|
|
]);
|
|
});
|
|
|
|
it("scopes plugin command matches and native specs to configured channels", () => {
|
|
const result = registerVoiceCommandForTest({
|
|
channels: [" Telegram "],
|
|
description: "Demo command",
|
|
});
|
|
|
|
expect(result).toEqual({ ok: true });
|
|
const telegramMatch = matchPluginCommand("/voice", { channel: "telegram" });
|
|
expect(telegramMatch?.command.name).toBe("voice");
|
|
expect(telegramMatch?.command.channels).toEqual(["telegram"]);
|
|
expect(matchPluginCommand("/voice", { channel: "discord" })).toBeNull();
|
|
expect(matchPluginCommand("/voice")?.command.name).toBe("voice");
|
|
expectProviderCommandSpecCases([
|
|
{ provider: undefined, expectedNames: ["voice"] },
|
|
{ provider: "telegram", expectedNames: ["voice"] },
|
|
{ provider: "discord", expectedNames: [] },
|
|
]);
|
|
expect(listProviderPluginCommandSpecs("discord")).toStrictEqual([]);
|
|
});
|
|
|
|
it("allows Slack to resolve provider-native plugin specs without changing shared native gating", () => {
|
|
const result = registerVoiceCommandForTest({
|
|
nativeNames: {
|
|
default: "talkvoice",
|
|
discord: "discordvoice",
|
|
},
|
|
description: "Demo command",
|
|
});
|
|
|
|
expect(result).toEqual({ ok: true });
|
|
expect(listProviderPluginCommandSpecs("slack")).toEqual([
|
|
{
|
|
name: "talkvoice",
|
|
description: "Demo command",
|
|
acceptsArgs: false,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("requires config before using read-only manifest command defaults", () => {
|
|
setActivePluginRegistry(createTestRegistry([]));
|
|
registerVoiceCommandForTest({
|
|
nativeNames: {
|
|
discord: "discordvoice",
|
|
},
|
|
description: "Demo command",
|
|
});
|
|
const env = {
|
|
...process.env,
|
|
OPENCLAW_BUNDLED_PLUGINS_DIR: path.resolve("extensions"),
|
|
OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY: "1",
|
|
};
|
|
|
|
expect(getPluginCommandSpecs("discord", { env })).toStrictEqual([]);
|
|
expect(
|
|
getPluginCommandSpecs("discord", {
|
|
env,
|
|
config: {
|
|
plugins: {
|
|
entries: {
|
|
discord: {
|
|
enabled: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
).toEqual([
|
|
{
|
|
name: "discordvoice",
|
|
description: "Demo command",
|
|
acceptsArgs: false,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("accepts native progress metadata on plugin commands", () => {
|
|
const result = registerVoiceCommandForTest({
|
|
nativeProgressMessages: { telegram: "Running voice command..." },
|
|
description: "Demo command",
|
|
});
|
|
|
|
expect(result).toEqual({ ok: true });
|
|
expect(matchPluginCommand("/voice")?.command.nativeProgressMessages).toEqual({
|
|
telegram: "Running voice command...",
|
|
});
|
|
});
|
|
|
|
it("exposes native description localizations on plugin command specs", () => {
|
|
const result = registerVoiceCommandForTest({
|
|
description: "Demo command",
|
|
descriptionLocalizations: { ko: "데모 명령" },
|
|
});
|
|
|
|
expect(result).toEqual({ ok: true });
|
|
expect(listProviderPluginCommandSpecs("discord")).toEqual([
|
|
{
|
|
name: "voice",
|
|
description: "Demo command",
|
|
descriptionLocalizations: { ko: "데모 명령" },
|
|
acceptsArgs: false,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("rejects empty native description localizations", () => {
|
|
const result = registerVoiceCommandForTest({
|
|
description: "Demo command",
|
|
descriptionLocalizations: { ko: " " },
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
ok: false,
|
|
error: 'Description localization "ko" cannot be empty',
|
|
});
|
|
});
|
|
|
|
it("rejects empty native progress metadata", () => {
|
|
const result = registerVoiceCommandForTest({
|
|
nativeProgressMessages: { telegram: " " },
|
|
description: "Demo command",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
ok: false,
|
|
error: 'Native progress message "telegram" cannot be empty',
|
|
});
|
|
});
|
|
|
|
it("keeps reserved command bypass scoped to the primary command name", () => {
|
|
const result = registerPluginCommand(
|
|
"status",
|
|
createVoiceCommand({
|
|
name: "status",
|
|
nativeNames: {
|
|
telegram: "help",
|
|
},
|
|
}),
|
|
{ allowReservedCommandNames: true },
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
ok: false,
|
|
error:
|
|
'Native command alias "telegram" invalid: Command name "help" is reserved by a built-in command',
|
|
});
|
|
});
|
|
|
|
it("reserves the bundled Codex command name", () => {
|
|
const result = registerPluginCommand("demo-plugin", {
|
|
name: "codex",
|
|
description: "Fake Codex command",
|
|
handler: async () => ({ text: "ok" }),
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
ok: false,
|
|
error: 'Command name "codex" is reserved by a built-in command',
|
|
});
|
|
});
|
|
|
|
it("rejects reserved ownership on non-reserved direct command registrations", () => {
|
|
const result = registerPluginCommand(
|
|
"demo-plugin",
|
|
{
|
|
name: "voice",
|
|
description: "Voice command",
|
|
ownership: "reserved",
|
|
handler: async () => ({ text: "ok" }),
|
|
},
|
|
{ allowReservedCommandNames: true },
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
ok: false,
|
|
error: "Reserved command ownership is only available to bundled reserved commands",
|
|
});
|
|
});
|
|
|
|
it("does not expose owner status to normal plugin commands", async () => {
|
|
let observedOwnerStatus: boolean | undefined;
|
|
registerPluginCommand("demo-plugin", {
|
|
name: "voice",
|
|
description: "Voice command",
|
|
handler: async (ctx) => {
|
|
observedOwnerStatus = ctx.senderIsOwner;
|
|
return { text: "ok" };
|
|
},
|
|
});
|
|
const match = requirePluginCommandMatch("/voice");
|
|
|
|
await executePluginCommand({
|
|
command: match.command,
|
|
channel: "telegram",
|
|
isAuthorizedSender: true,
|
|
senderIsOwner: true,
|
|
commandBody: "/voice",
|
|
config: {},
|
|
});
|
|
|
|
expect(observedOwnerStatus).toBeUndefined();
|
|
});
|
|
|
|
it("allows command owners to run scoped plugin commands without gateway scopes", async () => {
|
|
let observedOwnerStatus: boolean | undefined;
|
|
const handler = vi.fn(async (ctx: { senderIsOwner?: boolean }) => {
|
|
observedOwnerStatus = ctx.senderIsOwner;
|
|
return { text: "ok" };
|
|
});
|
|
registerPluginCommand("demo-plugin", {
|
|
name: "pairlike",
|
|
description: "Scoped command",
|
|
requiredScopes: ["operator.pairing"],
|
|
handler,
|
|
});
|
|
const match = requirePluginCommandMatch("/pairlike");
|
|
|
|
const result = await executePluginCommand({
|
|
command: match.command,
|
|
channel: "telegram",
|
|
isAuthorizedSender: true,
|
|
senderIsOwner: true,
|
|
commandBody: "/pairlike",
|
|
config: {},
|
|
});
|
|
|
|
expect(result).toEqual({ text: "ok" });
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
expect(observedOwnerStatus).toBe(true);
|
|
});
|
|
|
|
it("rejects command owners when explicit gateway scopes miss the required scope", async () => {
|
|
const handler = vi.fn(async () => ({ text: "ok" }));
|
|
registerPluginCommand("demo-plugin", {
|
|
name: "pairlike",
|
|
description: "Scoped command",
|
|
requiredScopes: ["operator.pairing"],
|
|
handler,
|
|
});
|
|
const match = requirePluginCommandMatch("/pairlike");
|
|
|
|
const result = await executePluginCommand({
|
|
command: match.command,
|
|
channel: "webchat",
|
|
isAuthorizedSender: true,
|
|
senderIsOwner: true,
|
|
commandBody: "/pairlike",
|
|
gatewayClientScopes: ["operator.write"],
|
|
config: {},
|
|
});
|
|
|
|
expect(result).toEqual({ text: "⚠️ This command requires gateway scope: operator.pairing." });
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects non-owner scoped plugin commands without gateway scopes", async () => {
|
|
const handler = vi.fn(async () => ({ text: "ok" }));
|
|
registerPluginCommand("demo-plugin", {
|
|
name: "pairlike",
|
|
description: "Scoped command",
|
|
requiredScopes: ["operator.pairing"],
|
|
handler,
|
|
});
|
|
const match = requirePluginCommandMatch("/pairlike");
|
|
|
|
const result = await executePluginCommand({
|
|
command: match.command,
|
|
channel: "telegram",
|
|
isAuthorizedSender: true,
|
|
senderIsOwner: false,
|
|
commandBody: "/pairlike",
|
|
config: {},
|
|
});
|
|
|
|
expect(result).toEqual({ text: "⚠️ This command requires gateway scope: operator.pairing." });
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("skips direct plugin command execution on unsupported channels", async () => {
|
|
let handlerCalled = false;
|
|
const handler = async () => {
|
|
handlerCalled = true;
|
|
return { text: "ok" };
|
|
};
|
|
|
|
const result = await executePluginCommand({
|
|
command: {
|
|
name: "voice",
|
|
description: "Voice command",
|
|
channels: ["qqbot"],
|
|
handler,
|
|
pluginId: "demo-plugin",
|
|
},
|
|
channel: "discord",
|
|
isAuthorizedSender: true,
|
|
commandBody: "/voice",
|
|
config: {},
|
|
});
|
|
|
|
expect(result).toEqual({ continueAgent: true });
|
|
expect(handlerCalled).toBe(false);
|
|
});
|
|
|
|
it("does not allow direct reserved command registrations to claim owner status", () => {
|
|
const result = registerPluginCommand(
|
|
"codex",
|
|
{
|
|
name: "codex",
|
|
description: "Codex command",
|
|
ownership: "reserved",
|
|
handler: async () => ({ text: "ok" }),
|
|
},
|
|
{ allowReservedCommandNames: true },
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
ok: false,
|
|
error: "Reserved command ownership is only available to bundled reserved commands",
|
|
});
|
|
expect(matchPluginCommand("/codex")).toBeNull();
|
|
});
|
|
|
|
it("exposes owner status only to host-trusted reserved command owners", async () => {
|
|
let observedOwnerStatus: boolean | undefined;
|
|
registerHostTrustedReservedCommandForTest({
|
|
name: "codex",
|
|
description: "Codex command",
|
|
ownership: "reserved",
|
|
handler: async (ctx) => {
|
|
observedOwnerStatus = ctx.senderIsOwner;
|
|
return { text: "ok" };
|
|
},
|
|
});
|
|
const match = requirePluginCommandMatch("/codex");
|
|
|
|
await executePluginCommand({
|
|
command: match.command,
|
|
channel: "telegram",
|
|
isAuthorizedSender: true,
|
|
senderIsOwner: true,
|
|
commandBody: "/codex",
|
|
config: {},
|
|
});
|
|
|
|
expect(observedOwnerStatus).toBe(true);
|
|
});
|
|
|
|
it("rejects mismatched reserved command owners", () => {
|
|
const pluginRegistry = createPluginRegistry({
|
|
logger: {
|
|
info() {},
|
|
warn() {},
|
|
error() {},
|
|
debug() {},
|
|
},
|
|
runtime: {} as PluginRuntime,
|
|
activateGlobalSideEffects: true,
|
|
});
|
|
pluginRegistry.registerCommand(createBundledPluginRecord("bundled-plugin"), {
|
|
name: "codex",
|
|
description: "Codex command",
|
|
ownership: "reserved",
|
|
handler: async () => ({ text: "ok" }),
|
|
});
|
|
|
|
const diagnostic = pluginRegistry.registry.diagnostics.find(
|
|
(entry) => entry.pluginId === "bundled-plugin",
|
|
);
|
|
expect(diagnostic?.level).toBe("error");
|
|
expect(diagnostic?.message).toBe(
|
|
'command registration failed: Reserved command ownership requires plugin id "bundled-plugin" to match reserved command name "codex"',
|
|
);
|
|
});
|
|
|
|
it("shares plugin commands across duplicate module instances", async () => {
|
|
const first = await importCommandsModule(`first-${Date.now()}`);
|
|
const second = await importCommandsModule(`second-${Date.now()}`);
|
|
|
|
first.clearPluginCommands();
|
|
|
|
expect(
|
|
first.registerPluginCommand(
|
|
"demo-plugin",
|
|
createVoiceCommand({
|
|
nativeNames: {
|
|
telegram: "voice",
|
|
},
|
|
}),
|
|
),
|
|
).toEqual({ ok: true });
|
|
|
|
expect(second.getPluginCommandSpecs("telegram")).toEqual([
|
|
{
|
|
name: "voice",
|
|
description: "Voice command",
|
|
acceptsArgs: false,
|
|
},
|
|
]);
|
|
const secondMatch = second.matchPluginCommand("/voice");
|
|
expect(secondMatch?.command.name).toBe("voice");
|
|
expect(secondMatch?.command.pluginId).toBe("demo-plugin");
|
|
|
|
second.clearPluginCommands();
|
|
});
|
|
|
|
it.each(["/talkvoice now", "/discordvoice now"] as const)(
|
|
"matches provider-specific native alias %s back to the canonical command",
|
|
(commandBody) => {
|
|
const result = registerVoiceCommandForTest({
|
|
nativeNames: {
|
|
default: "talkvoice",
|
|
discord: "discordvoice",
|
|
},
|
|
description: "Demo command",
|
|
acceptsArgs: true,
|
|
});
|
|
|
|
expect(result).toEqual({ ok: true });
|
|
expectCommandMatch(commandBody, {
|
|
name: "voice",
|
|
pluginId: "demo-plugin",
|
|
args: "now",
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{
|
|
name: "rejects provider aliases that collide with another registered command",
|
|
setup: () =>
|
|
registerPluginCommand(
|
|
"demo-plugin",
|
|
createVoiceCommand({
|
|
nativeNames: {
|
|
telegram: "pair_device",
|
|
},
|
|
}),
|
|
),
|
|
candidate: {
|
|
name: "pair",
|
|
nativeNames: {
|
|
telegram: "pair_device",
|
|
},
|
|
description: "Pair command",
|
|
handler: async () => ({ text: "ok" }),
|
|
},
|
|
expected: {
|
|
ok: false,
|
|
error: 'Command "pair_device" already registered by plugin "demo-plugin"',
|
|
},
|
|
},
|
|
{
|
|
name: "rejects reserved provider aliases",
|
|
candidate: createVoiceCommand({
|
|
nativeNames: {
|
|
telegram: "help",
|
|
},
|
|
}),
|
|
expected: {
|
|
ok: false,
|
|
error:
|
|
'Native command alias "telegram" invalid: Command name "help" is reserved by a built-in command',
|
|
},
|
|
},
|
|
] as const)("$name", ({ setup, candidate, expected }) => {
|
|
setup?.();
|
|
expect(registerPluginCommand("other-plugin", candidate)).toEqual(expected);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "resolves Discord DM command bindings with the user target prefix intact",
|
|
params: {
|
|
channel: "discord",
|
|
from: "discord:1177378744822943744",
|
|
to: "slash:1177378744822943744",
|
|
accountId: "default",
|
|
},
|
|
expected: {
|
|
channel: "discord",
|
|
accountId: "default",
|
|
conversationId: "user:1177378744822943744",
|
|
},
|
|
},
|
|
{
|
|
name: "resolves Discord guild command bindings with the channel target prefix intact",
|
|
params: {
|
|
channel: "discord",
|
|
from: "discord:channel:1480554272859881494",
|
|
accountId: "default",
|
|
},
|
|
expected: {
|
|
channel: "discord",
|
|
accountId: "default",
|
|
conversationId: "channel:1480554272859881494",
|
|
},
|
|
},
|
|
{
|
|
name: "resolves Discord thread command bindings with parent channel context intact",
|
|
params: {
|
|
channel: "discord",
|
|
from: "discord:channel:1480554272859881494",
|
|
accountId: "default",
|
|
messageThreadId: "thread-42",
|
|
threadParentId: "channel-parent-7",
|
|
},
|
|
expected: {
|
|
channel: "discord",
|
|
accountId: "default",
|
|
conversationId: "channel:1480554272859881494",
|
|
parentConversationId: "channel-parent-7",
|
|
threadId: "thread-42",
|
|
},
|
|
},
|
|
{
|
|
name: "does not resolve binding conversations for unsupported command channels",
|
|
params: {
|
|
channel: "slack",
|
|
from: "slack:U123",
|
|
to: "C456",
|
|
accountId: "default",
|
|
},
|
|
expected: null,
|
|
},
|
|
{
|
|
name: "resolves sender-keyed command bindings when only senderId is available",
|
|
params: {
|
|
channel: "signal",
|
|
senderId: "signal-user-42",
|
|
accountId: "default",
|
|
},
|
|
expected: {
|
|
channel: "signal",
|
|
accountId: "default",
|
|
conversationId: "dm:signal-user-42",
|
|
},
|
|
},
|
|
] as const)("$name", ({ params, expected }) => {
|
|
expectBindingConversationCase(params, expected);
|
|
});
|
|
|
|
it("does not expose binding APIs to plugin commands on unsupported channels", async () => {
|
|
const handler = async (ctx: {
|
|
requestConversationBinding: (params: { summary: string }) => Promise<unknown>;
|
|
getCurrentConversationBinding: () => Promise<unknown>;
|
|
detachConversationBinding: () => Promise<unknown>;
|
|
}) => {
|
|
const requested = await ctx.requestConversationBinding({
|
|
summary: "Bind this conversation.",
|
|
});
|
|
const current = await ctx.getCurrentConversationBinding();
|
|
const detached = await ctx.detachConversationBinding();
|
|
return {
|
|
text: JSON.stringify({
|
|
requested,
|
|
current,
|
|
detached,
|
|
}),
|
|
};
|
|
};
|
|
registerPluginCommand(
|
|
"demo-plugin",
|
|
{
|
|
name: "bindcheck",
|
|
description: "Demo command",
|
|
acceptsArgs: false,
|
|
handler,
|
|
},
|
|
{ pluginRoot: "/plugins/demo-plugin" },
|
|
);
|
|
|
|
const result = await executePluginCommand({
|
|
command: {
|
|
name: "bindcheck",
|
|
description: "Demo command",
|
|
acceptsArgs: false,
|
|
handler,
|
|
pluginId: "demo-plugin",
|
|
pluginRoot: "/plugins/demo-plugin",
|
|
},
|
|
channel: "slack",
|
|
senderId: "U123",
|
|
isAuthorizedSender: true,
|
|
commandBody: "/bindcheck",
|
|
config: {} as never,
|
|
from: "slack:U123",
|
|
to: "C456",
|
|
accountId: "default",
|
|
});
|
|
|
|
expectUnsupportedBindingApiResult(result);
|
|
});
|
|
|
|
it("passes host session identity through to the plugin command context", async () => {
|
|
let receivedCtx:
|
|
| {
|
|
sessionKey?: string;
|
|
sessionId?: string;
|
|
}
|
|
| undefined;
|
|
const handler = async (ctx: { sessionKey?: string; sessionId?: string }) => {
|
|
receivedCtx = ctx;
|
|
return { text: "ok" };
|
|
};
|
|
|
|
const result = await executePluginCommand({
|
|
command: {
|
|
name: "sessioncheck",
|
|
description: "Demo command",
|
|
acceptsArgs: false,
|
|
handler,
|
|
pluginId: "demo-plugin",
|
|
},
|
|
channel: "whatsapp",
|
|
senderId: "U123",
|
|
isAuthorizedSender: true,
|
|
sessionKey: "agent:main:whatsapp:direct:123",
|
|
sessionId: "session-123",
|
|
commandBody: "/sessioncheck",
|
|
config: {} as never,
|
|
});
|
|
|
|
expect(result).toEqual({ text: "ok" });
|
|
expect(receivedCtx?.sessionKey).toBe("agent:main:whatsapp:direct:123");
|
|
expect(receivedCtx?.sessionId).toBe("session-123");
|
|
});
|
|
|
|
it("passes a host-bound llm runtime through to plugin command handlers", async () => {
|
|
let receivedCtx:
|
|
| {
|
|
runtimeContext?: {
|
|
llm?: {
|
|
complete?: unknown;
|
|
};
|
|
};
|
|
}
|
|
| undefined;
|
|
const handler = async (ctx: typeof receivedCtx) => {
|
|
receivedCtx = ctx;
|
|
return { text: "ok" };
|
|
};
|
|
|
|
const result = await executePluginCommand({
|
|
command: {
|
|
name: "runtimecheck",
|
|
description: "Demo command",
|
|
acceptsArgs: false,
|
|
handler,
|
|
pluginId: "demo-plugin",
|
|
},
|
|
channel: "telegram",
|
|
senderId: "U123",
|
|
isAuthorizedSender: true,
|
|
sessionKey: "agent:main:telegram:direct:runtimecheck",
|
|
authProfileId: "openai-codex:claude@example.com",
|
|
commandBody: "/runtimecheck",
|
|
config: {} as never,
|
|
});
|
|
|
|
expect(result).toEqual({ text: "ok" });
|
|
expect(receivedCtx?.runtimeContext?.llm?.complete).toEqual(expect.any(Function));
|
|
});
|
|
|
|
it("binds legacy main session plugin llm runtime to the default agent", async () => {
|
|
const handler = async (ctx: {
|
|
runtimeContext?: {
|
|
llm?: {
|
|
complete: (params: {
|
|
messages: Array<{ role: "user"; content: string }>;
|
|
}) => Promise<unknown>;
|
|
};
|
|
};
|
|
}) => {
|
|
await ctx.runtimeContext?.llm?.complete({
|
|
messages: [{ role: "user", content: "draft" }],
|
|
});
|
|
return { text: "ok" };
|
|
};
|
|
|
|
await executePluginCommand({
|
|
command: {
|
|
name: "runtimecheck",
|
|
description: "Demo command",
|
|
acceptsArgs: false,
|
|
handler,
|
|
pluginId: "demo-plugin",
|
|
},
|
|
channel: "telegram",
|
|
senderId: "U123",
|
|
isAuthorizedSender: true,
|
|
sessionKey: "main",
|
|
commandBody: "/runtimecheck",
|
|
config: {
|
|
agents: {
|
|
list: [{ id: "ops", default: true }],
|
|
defaults: {
|
|
model: "openai/gpt-5.5",
|
|
},
|
|
},
|
|
session: {
|
|
mainKey: "main",
|
|
},
|
|
} as never,
|
|
});
|
|
|
|
expect(completionMocks.prepareSimpleCompletionModelForAgent).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
agentId: "ops",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("binds plugin-owned command sessions to the host-resolved agent", async () => {
|
|
const handler = async (ctx: {
|
|
runtimeContext?: {
|
|
llm?: {
|
|
complete: (params: {
|
|
messages: Array<{ role: "user"; content: string }>;
|
|
}) => Promise<unknown>;
|
|
};
|
|
};
|
|
}) => {
|
|
await ctx.runtimeContext?.llm?.complete({
|
|
messages: [{ role: "user", content: "summarize" }],
|
|
});
|
|
return { text: "ok" };
|
|
};
|
|
|
|
await executePluginCommand({
|
|
command: {
|
|
name: "runtimecheck",
|
|
description: "Demo command",
|
|
acceptsArgs: false,
|
|
handler,
|
|
pluginId: "demo-plugin",
|
|
},
|
|
channel: "discord",
|
|
senderId: "U123",
|
|
isAuthorizedSender: true,
|
|
agentId: "codex",
|
|
sessionKey: "plugin-binding:openclaw-codex-app-server:dm",
|
|
authProfileId: "openai-codex:owner@example.com",
|
|
commandBody: "/runtimecheck",
|
|
config: {} as never,
|
|
});
|
|
|
|
expect(completionMocks.prepareSimpleCompletionModelForAgent).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
agentId: "codex",
|
|
preferredProfile: "openai-codex:owner@example.com",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("normalizes undefined plugin command handler results to an empty reply payload", async () => {
|
|
const handler = async () => undefined as never;
|
|
|
|
const result = await executePluginCommand({
|
|
command: {
|
|
name: "silentcheck",
|
|
description: "Demo command",
|
|
acceptsArgs: false,
|
|
handler,
|
|
pluginId: "demo-plugin",
|
|
},
|
|
channel: "telegram",
|
|
senderId: "U123",
|
|
isAuthorizedSender: true,
|
|
commandBody: "/silentcheck",
|
|
config: {} as never,
|
|
});
|
|
|
|
expect(result).toStrictEqual({});
|
|
});
|
|
|
|
it("passes the effective default account to plugin command handlers when accountId is omitted", async () => {
|
|
setActivePluginRegistry(
|
|
createTestRegistry([
|
|
{
|
|
pluginId: "line",
|
|
source: "test",
|
|
plugin: {
|
|
...createChannelTestPluginBase({
|
|
id: "line",
|
|
label: "LINE",
|
|
config: {
|
|
listAccountIds: () => ["default", "work"],
|
|
defaultAccountId: () => "work",
|
|
resolveAccount: (_cfg, accountId) => ({ accountId: accountId ?? "work" }),
|
|
},
|
|
}),
|
|
bindings: {
|
|
resolveCommandConversation: ({
|
|
originatingTo,
|
|
commandTo,
|
|
fallbackTo,
|
|
}: {
|
|
originatingTo?: string;
|
|
commandTo?: string;
|
|
fallbackTo?: string;
|
|
}) => {
|
|
const rawTarget = [originatingTo, commandTo, fallbackTo].find(Boolean)?.trim();
|
|
if (!rawTarget) {
|
|
return null;
|
|
}
|
|
return {
|
|
conversationId: rawTarget.replace(/^line:/i, "").replace(/^user:/i, ""),
|
|
};
|
|
},
|
|
},
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
|
|
let receivedCtx:
|
|
| {
|
|
accountId?: string;
|
|
}
|
|
| undefined;
|
|
const handler = async (ctx: { accountId?: string }) => {
|
|
receivedCtx = ctx;
|
|
return { text: "ok" };
|
|
};
|
|
|
|
const result = await executePluginCommand({
|
|
command: {
|
|
name: "accountcheck",
|
|
description: "Demo command",
|
|
acceptsArgs: false,
|
|
handler,
|
|
pluginId: "demo-plugin",
|
|
},
|
|
channel: "line",
|
|
senderId: "U123",
|
|
isAuthorizedSender: true,
|
|
commandBody: "/accountcheck",
|
|
config: {} as never,
|
|
from: "line:user:U1234567890abcdef1234567890abcdef",
|
|
});
|
|
|
|
expect(result).toEqual({ text: "ok" });
|
|
expect(receivedCtx?.accountId).toBe("work");
|
|
});
|
|
});
|