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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 92 additions & 70 deletions extensions/x/src/onboarding.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { WizardPrompter } from "../../../src/wizard/prompts.js";
import { xOnboardingAdapter } from "./onboarding.js";
import { xSetupWizard } from "./onboarding.js";
import { setXRuntime } from "./runtime.js";

function createPrompter(params: { confirmValue: boolean; textValues: string[] }): WizardPrompter {
Expand All @@ -18,69 +18,89 @@ function createPrompter(params: { confirmValue: boolean; textValues: string[] })
};
}

describe("xOnboardingAdapter", () => {
beforeEach(() => {
setXRuntime({
channel: {
x: {
defaultAccountId: "default",
listXAccountIds: (cfg: OpenClawConfig) =>
cfg.channels?.x?.accounts ? Object.keys(cfg.channels.x.accounts) : [],
resolveXAccount: (cfg: OpenClawConfig, accountId?: string | null) => {
const id = accountId ?? "default";
const accountFromMap = cfg.channels?.x?.accounts?.[id];
if (accountFromMap) {
return accountFromMap;
}
if (
id === "default" &&
cfg.channels?.x?.consumerKey &&
cfg.channels?.x?.consumerSecret &&
cfg.channels?.x?.accessToken &&
cfg.channels?.x?.accessTokenSecret
) {
return {
consumerKey: cfg.channels.x.consumerKey,
consumerSecret: cfg.channels.x.consumerSecret,
accessToken: cfg.channels.x.accessToken,
accessTokenSecret: cfg.channels.x.accessTokenSecret,
enabled: cfg.channels.x.enabled,
pollIntervalSeconds: cfg.channels.x.pollIntervalSeconds,
proxy: cfg.channels.x.proxy,
};
}
return null;
},
isXAccountConfigured: (
account: {
consumerKey?: string;
consumerSecret?: string;
accessToken?: string;
accessTokenSecret?: string;
} | null,
) =>
Boolean(
account?.consumerKey &&
account?.consumerSecret &&
account?.accessToken &&
account?.accessTokenSecret,
),
function setupXRuntime() {
setXRuntime({
channel: {
x: {
defaultAccountId: "default",
listXAccountIds: (cfg: OpenClawConfig) =>
cfg.channels?.x?.accounts ? Object.keys(cfg.channels.x.accounts) : [],
resolveXAccount: (cfg: OpenClawConfig, accountId?: string | null) => {
const id = accountId ?? "default";
const accountFromMap = cfg.channels?.x?.accounts?.[id];
if (accountFromMap) {
return accountFromMap;
}
if (
id === "default" &&
cfg.channels?.x?.consumerKey &&
cfg.channels?.x?.consumerSecret &&
cfg.channels?.x?.accessToken &&
cfg.channels?.x?.accessTokenSecret
) {
return {
consumerKey: cfg.channels.x.consumerKey,
consumerSecret: cfg.channels.x.consumerSecret,
accessToken: cfg.channels.x.accessToken,
accessTokenSecret: cfg.channels.x.accessTokenSecret,
enabled: cfg.channels.x.enabled,
pollIntervalSeconds: cfg.channels.x.pollIntervalSeconds,
proxy: cfg.channels.x.proxy,
};
}
return null;
},
isXAccountConfigured: (
account: {
consumerKey?: string;
consumerSecret?: string;
accessToken?: string;
accessTokenSecret?: string;
} | null,
) =>
Boolean(
account?.consumerKey &&
account?.consumerSecret &&
account?.accessToken &&
account?.accessTokenSecret,
),
},
} as never);
},
} as never);
}

describe("xSetupWizard", () => {
beforeEach(() => {
setupXRuntime();
});

it("reports unconfigured status when credentials are missing", async () => {
const status = await xOnboardingAdapter.getStatus({
it("has a status.resolveConfigured that reports false when credentials are missing", async () => {
const configured = await xSetupWizard.status.resolveConfigured({
cfg: {} as OpenClawConfig,
accountOverrides: {},
});
expect(configured).toBe(false);
});

expect(status.configured).toBe(false);
expect(status.channel).toBe("x");
it("has a status.resolveConfigured that reports true when credentials are present", async () => {
const cfg = {
channels: {
x: {
accounts: {
default: {
consumerKey: "ck",
consumerSecret: "cs",
accessToken: "at",
accessTokenSecret: "ats",
},
},
},
},
} as unknown as OpenClawConfig;
const configured = await xSetupWizard.status.resolveConfigured({ cfg });
expect(configured).toBe(true);
});

it("writes default account credentials to channels.x config", async () => {
it("writes default account credentials via finalize", async () => {
const prompter = createPrompter({
confirmValue: false,
textValues: [
Expand All @@ -90,29 +110,31 @@ describe("xOnboardingAdapter", () => {
"access-token-secret",
"60",
"http://127.0.0.1:7890",
"12345678", // allowFrom (default)
"12345678", // actionsAllowFrom (defaults to allowFrom value)
"12345678",
"12345678",
],
});

const result = await xOnboardingAdapter.configure({
const result = await xSetupWizard.finalize!({
cfg: {} as OpenClawConfig,
accountId: "default",
credentialValues: {},
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
prompter,
accountOverrides: {},
shouldPromptAccountIds: false,
options: undefined,
forceAllowFrom: false,
});

expect(result.accountId).toBe("default");
expect(result.cfg.channels?.x?.enabled).toBe(true);
expect(result.cfg.channels?.x?.consumerKey).toBe("consumer-key");
expect(result.cfg.channels?.x?.consumerSecret).toBe("consumer-secret");
expect(result.cfg.channels?.x?.accessToken).toBe("access-token");
expect(result.cfg.channels?.x?.accessTokenSecret).toBe("access-token-secret");
expect(result.cfg.channels?.x?.pollIntervalSeconds).toBe(60);
expect(result.cfg.channels?.x?.proxy).toBe("http://127.0.0.1:7890");
expect(result.cfg.channels?.x?.allowFrom).toEqual(["12345678"]);
expect(result.cfg.channels?.x?.actionsAllowFrom).toEqual(["12345678"]);
expect(result?.cfg).toBeDefined();
const nextCfg = result!.cfg!;
expect(nextCfg.channels?.x?.enabled).toBe(true);
expect(nextCfg.channels?.x?.consumerKey).toBe("consumer-key");
expect(nextCfg.channels?.x?.consumerSecret).toBe("consumer-secret");
expect(nextCfg.channels?.x?.accessToken).toBe("access-token");
expect(nextCfg.channels?.x?.accessTokenSecret).toBe("access-token-secret");
expect(nextCfg.channels?.x?.pollIntervalSeconds).toBe(60);
expect(nextCfg.channels?.x?.proxy).toBe("http://127.0.0.1:7890");
expect(nextCfg.channels?.x?.allowFrom).toEqual(["12345678"]);
expect(nextCfg.channels?.x?.actionsAllowFrom).toEqual(["12345678"]);
});
});
73 changes: 35 additions & 38 deletions extensions/x/src/onboarding.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import type { ChannelOnboardingAdapter, OpenClawConfig } from "openclaw/plugin-sdk/x";
import {
promptAccountId,
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
formatDocsLink,
} from "openclaw/plugin-sdk/x";
import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup-runtime";
import { createStandardChannelSetupStatus } from "openclaw/plugin-sdk/setup-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/x";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId, formatDocsLink } from "openclaw/plugin-sdk/x";
import { getXChannel } from "./runtime.js";

const channel = "x" as const;
Expand Down Expand Up @@ -83,24 +80,34 @@ function writeAccountConfig(params: {
};
}

export const xOnboardingAdapter: ChannelOnboardingAdapter = {
function isXConfigured(cfg: OpenClawConfig): boolean {
const xChannel = getXChannel();
return xChannel
.listXAccountIds(cfg)
.some((accountId: string) =>
xChannel.isXAccountConfigured(xChannel.resolveXAccount(cfg, accountId)),
);
}

/**
* Proper ChannelSetupWizard for X. The previous implementation wrongly assigned
* a ChannelSetupWizardAdapter (flat getStatus/configure shape) to the setupWizard
* slot via an unsafe `as unknown` cast, causing a crash on wizard.status.resolveConfigured.
*/
export const xSetupWizard: ChannelSetupWizard = {
channel,
getStatus: async ({ cfg }) => {
const xChannel = getXChannel();
const configured = xChannel
.listXAccountIds(cfg)
.some((accountId: string) =>
xChannel.isXAccountConfigured(xChannel.resolveXAccount(cfg, accountId)),
);
return {
channel,
configured,
statusLines: [`X (Twitter): ${configured ? "configured" : "needs API credentials"}`],
selectionHint: configured ? "configured · credentials present" : "new · add credentials",
quickstartScore: configured ? 1 : 10,
};
},
configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => {
status: createStandardChannelSetupStatus({
channelLabel: "X (Twitter)",
configuredLabel: "configured",
unconfiguredLabel: "needs API credentials",
configuredHint: "configured · credentials present",
unconfiguredHint: "new · add credentials",
configuredScore: 1,
unconfiguredScore: 10,
resolveConfigured: ({ cfg }) => isXConfigured(cfg),
}),
credentials: [],
finalize: async ({ cfg, prompter, options }) => {
const xChannel = getXChannel();
await prompter.note(
[
Expand All @@ -111,19 +118,11 @@ export const xOnboardingAdapter: ChannelOnboardingAdapter = {
"X credentials",
);

const xOverride = accountOverrides.x?.trim();
const xOverride = (
options as Record<string, Record<string, string>> | undefined
)?.accountOverrides?.x?.trim();
const defaultAccountId = xChannel.defaultAccountId ?? DEFAULT_ACCOUNT_ID;
let xAccountId = xOverride ? normalizeAccountId(xOverride) : defaultAccountId;
if (shouldPromptAccountIds && !xOverride) {
xAccountId = await promptAccountId({
cfg,
prompter,
label: "X",
currentId: xAccountId,
listAccountIds: (nextCfg) => xChannel.listXAccountIds(nextCfg),
defaultAccountId,
});
}

const existing = xChannel.resolveXAccount(cfg, xAccountId);
const hasExistingCreds = xChannel.isXAccountConfigured(existing);
Expand Down Expand Up @@ -185,8 +184,6 @@ export const xOnboardingAdapter: ChannelOnboardingAdapter = {
placeholder: "http://127.0.0.1:7890",
});

// Prompt for X user IDs used in allowFrom / actionsAllowFrom.
// These control who can mention the bot and who can trigger proactive actions.
const existingAllowFrom = existing?.allowFrom ?? [];
const existingActionsAllowFrom = existing?.actionsAllowFrom ?? [];
const allowFromInput = await prompter.text({
Expand Down Expand Up @@ -243,7 +240,7 @@ export const xOnboardingAdapter: ChannelOnboardingAdapter = {
};
}

return { cfg: next, accountId: xAccountId };
return { cfg: next, credentialValues: {} };
},
disable: (cfg) => ({
...cfg,
Expand Down
4 changes: 2 additions & 2 deletions extensions/x/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import type {
} from "openclaw/plugin-sdk/x";
import { handleXAction, buildChannelConfigSchema } from "openclaw/plugin-sdk/x";
import { XConfigSchema } from "./config-schema.js";
import { xOnboardingAdapter } from "./onboarding.js";
import { xSetupWizard } from "./onboarding.js";
import { getXChannel, getXRuntime } from "./runtime.js";

const DEFAULT_ACCOUNT_ID = "default";
Expand Down Expand Up @@ -61,7 +61,7 @@ export const xPlugin: ChannelPlugin<XAccountConfig> = {
} satisfies ChannelCapabilities,

configSchema: buildChannelConfigSchema(XConfigSchema),
setupWizard: xOnboardingAdapter as unknown as ChannelPlugin["setupWizard"],
setupWizard: xSetupWizard,

agentPrompt: {
messageToolHints: () => [
Expand Down
9 changes: 9 additions & 0 deletions src/agents/system-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,9 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("extract webpage content");
// web_search scoped to articles/research
expect(prompt).toContain("articles, opinions, explanations, documentation, or broad research");
expect(prompt).toContain("use only these QVeris tools: qveris_discover, qveris_call");
expect(prompt).toContain("if qveris_call returns full_content_file_url");
expect(prompt).toContain("/search, /tools/execute, /tools/by-ids");
});

it("includes qveris_inspect step when tool is available", () => {
Expand All @@ -195,6 +198,9 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("qveris_inspect");
expect(prompt).toContain("Previously used a QVeris tool");
expect(prompt).toContain("verify availability");
expect(prompt).toContain(
"use only these QVeris tools: qveris_discover, qveris_call, qveris_inspect",
);
});

it("narrows web_search summary when QVeris is available", () => {
Expand Down Expand Up @@ -230,6 +236,9 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain(
"Use web_search for articles, opinions, explanations, documentation, and broad research.",
);
expect(prompt).toContain("use only these QVeris tools: qveris_discover.");
expect(prompt).not.toContain("use only these QVeris tools: qveris_discover, qveris_call");
expect(prompt).not.toContain("If qveris_call returns full_content_file_url");
});

it("omits QVeris routing section in minimal prompt mode", () => {
Expand Down
19 changes: 19 additions & 0 deletions src/agents/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ function buildQverisSection(params: {
const hasInspect = params.availableTools.has("qveris_inspect");
const hasWebSearch = params.availableTools.has("web_search");
const hasWebFetch = params.availableTools.has("web_fetch");
const availableQverisTools = [
"qveris_discover",
...(hasInvoke ? ["qveris_call"] : []),
...(hasInspect ? ["qveris_inspect"] : []),
];
const qverisExecutionLine = hasInvoke
? " -> Prefer qveris_discover + qveris_call. Specialized APIs/services return precise structured data or service outputs from dedicated providers."
: " -> Use qveris_discover to identify the best specialized API/service available in this session. If qveris_call is unavailable here, report the limitation honestly instead of promising a tool call you cannot make.";
Expand Down Expand Up @@ -114,6 +119,20 @@ function buildQverisSection(params: {
`${hasInspect ? "6" : "5"}. **None of the above?**`,
" -> Report the limitation honestly. Never fabricate data.",
"",
"QVeris access rules (CRITICAL):",
`- In this session, use only these QVeris tools: ${availableQverisTools.join(", ")}.`,
hasInvoke
? "- NEVER call QVeris discovery/invocation endpoints directly (for example /search, /tools/execute, /tools/by-ids). Use qveris_discover/qveris_call instead."
: "- NEVER call QVeris discovery/invocation endpoints directly (for example /search, /tools/execute, /tools/by-ids). Use qveris_discover only, and report honestly when execution is unavailable in this session.",
hasInvoke
? "- Exception: if qveris_call returns full_content_file_url, follow the large-data instructions below to download that returned file URL."
: undefined,
"- NEVER guess or hardcode QVeris API base URLs — endpoint resolution is handled internally by the tools.",
"- NEVER reveal or print the value of QVERIS_API_KEY — authentication is handled internally by the tools.",
hasInvoke
? "- If qveris_call fails, follow the error recovery steps below. Do NOT bypass the workflow with raw API requests."
: undefined,
"",
"qveris_discover anti-patterns (NEVER do these):",
"- Searching for software configuration or setup instructions",
"- Searching for documentation, tutorials, or how-to guides",
Expand Down
Loading
Loading