mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-26 09:12:13 +08:00
Compare commits
3 Commits
feat/plugi
...
codex/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92d0e7dbe6 | ||
|
|
706efe3628 | ||
|
|
4570c91651 |
@@ -85,7 +85,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Discord/voice: make READY auto-join fire-and-forget while keeping the shorter initial voice-connect timeout separate from the longer playback-start wait. (#60345) Thanks @geekhuashan.
|
||||
- Agents/skills: add inherited `agents.defaults.skills` allowlists, make per-agent `agents.list[].skills` replace defaults instead of merging, and scope embedded, session, sandbox, and cron skill snapshots through the effective runtime agent. (#59992) Thanks @gumadeiras.
|
||||
- Matrix/Telegram exec approvals: recover stored same-channel account bindings even when session reply state drifted to another channel, so foreign-channel approvals route to the bound account instead of fanning out or being rejected as ambiguous. (#60417) thanks @gumadeiras.
|
||||
- Plugins/runtime: honor explicit capability allowlists during fallback speech, media-understanding, and image-generation provider loading so bundled capability plugins do not bypass restrictive `plugins.allow` config. (#52262) Thanks @PerfectPan.
|
||||
- Sessions/forking: fall back to transcript-estimated parent token counts when cached totals are stale or missing, so oversized thread forks start fresh instead of cloning the full parent transcript. (#60463) Thanks @jalehman.
|
||||
|
||||
## 2026.4.2
|
||||
|
||||
|
||||
@@ -12279,7 +12279,7 @@
|
||||
"advanced"
|
||||
],
|
||||
"label": "Internal Hooks Enabled",
|
||||
"help": "Enables processing for internal hooks and configured entries in the internal hook runtime. Keep disabled unless internal hooks are intentionally configured.",
|
||||
"help": "Enables processing for internal hook handlers and configured entries in the internal hook runtime. Keep disabled unless internal hook handlers are intentionally configured.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
@@ -12345,6 +12345,72 @@
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "hooks.internal.handlers",
|
||||
"kind": "core",
|
||||
"type": "array",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Internal Hook Handlers",
|
||||
"help": "List of internal event handlers mapping event names to modules and optional exports. Keep handler definitions explicit so event-to-code routing is auditable.",
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "hooks.internal.handlers.*",
|
||||
"kind": "core",
|
||||
"type": "object",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "hooks.internal.handlers.*.event",
|
||||
"kind": "core",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Internal Hook Event",
|
||||
"help": "Internal event name that triggers this handler module when emitted by the runtime. Use stable event naming conventions to avoid accidental overlap across handlers.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "hooks.internal.handlers.*.export",
|
||||
"kind": "core",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Internal Hook Export",
|
||||
"help": "Optional named export for the internal hook handler function when module default export is not used. Set this when one module ships multiple handler entrypoints.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "hooks.internal.handlers.*.module",
|
||||
"kind": "core",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Internal Hook Module",
|
||||
"help": "Safe relative module path for the internal hook handler implementation loaded at runtime. Keep module files in reviewed directories and avoid dynamic path composition.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "hooks.internal.installs",
|
||||
"kind": "core",
|
||||
|
||||
@@ -12278,7 +12278,7 @@
|
||||
"advanced"
|
||||
],
|
||||
"label": "Internal Hooks Enabled",
|
||||
"help": "Enables processing for internal hooks and configured entries in the internal hook runtime. Keep disabled unless internal hooks are intentionally configured.",
|
||||
"help": "Enables processing for internal hook handlers and configured entries in the internal hook runtime. Keep disabled unless internal hook handlers are intentionally configured.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
@@ -12344,6 +12344,72 @@
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "hooks.internal.handlers",
|
||||
"kind": "core",
|
||||
"type": "array",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Internal Hook Handlers",
|
||||
"help": "List of internal event handlers mapping event names to modules and optional exports. Keep handler definitions explicit so event-to-code routing is auditable.",
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "hooks.internal.handlers.*",
|
||||
"kind": "core",
|
||||
"type": "object",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "hooks.internal.handlers.*.event",
|
||||
"kind": "core",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Internal Hook Event",
|
||||
"help": "Internal event name that triggers this handler module when emitted by the runtime. Use stable event naming conventions to avoid accidental overlap across handlers.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "hooks.internal.handlers.*.export",
|
||||
"kind": "core",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Internal Hook Export",
|
||||
"help": "Optional named export for the internal hook handler function when module default export is not used. Set this when one module ships multiple handler entrypoints.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "hooks.internal.handlers.*.module",
|
||||
"kind": "core",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Internal Hook Module",
|
||||
"help": "Safe relative module path for the internal hook handler implementation loaded at runtime. Keep module files in reviewed directories and avoid dynamic path composition.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "hooks.internal.installs",
|
||||
"kind": "core",
|
||||
|
||||
@@ -392,7 +392,7 @@ Notes:
|
||||
## Manifest and scope checklist
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Slack app manifest example" defaultOpen>
|
||||
<Accordion title="Slack app manifest example">
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import { streamSimple } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
applyAnthropicPayloadPolicyToParams,
|
||||
resolveAnthropicPayloadPolicy,
|
||||
streamWithPayloadPatch,
|
||||
} from "openclaw/plugin-sdk/provider-stream";
|
||||
import { resolveProviderRequestCapabilities } from "openclaw/plugin-sdk/provider-http";
|
||||
import { streamWithPayloadPatch } from "openclaw/plugin-sdk/provider-stream";
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||
|
||||
const log = createSubsystemLogger("anthropic-stream");
|
||||
@@ -55,6 +52,20 @@ function isAnthropicOAuthApiKey(apiKey: unknown): boolean {
|
||||
return typeof apiKey === "string" && apiKey.includes("sk-ant-oat");
|
||||
}
|
||||
|
||||
function allowsAnthropicServiceTier(model: {
|
||||
api?: unknown;
|
||||
provider?: unknown;
|
||||
baseUrl?: unknown;
|
||||
}): boolean {
|
||||
return resolveProviderRequestCapabilities({
|
||||
provider: typeof model.provider === "string" ? model.provider : undefined,
|
||||
api: typeof model.api === "string" ? model.api : undefined,
|
||||
baseUrl: typeof model.baseUrl === "string" ? model.baseUrl : undefined,
|
||||
capability: "llm",
|
||||
transport: "stream",
|
||||
}).allowsAnthropicServiceTier;
|
||||
}
|
||||
|
||||
function resolveAnthropicFastServiceTier(enabled: boolean): AnthropicServiceTier {
|
||||
return enabled ? "auto" : "standard_only";
|
||||
}
|
||||
@@ -150,19 +161,15 @@ export function createAnthropicFastModeWrapper(
|
||||
const underlying = baseStreamFn ?? streamSimple;
|
||||
const serviceTier = resolveAnthropicFastServiceTier(enabled);
|
||||
return (model, context, options) => {
|
||||
const payloadPolicy = resolveAnthropicPayloadPolicy({
|
||||
provider: typeof model.provider === "string" ? model.provider : undefined,
|
||||
api: typeof model.api === "string" ? model.api : undefined,
|
||||
baseUrl: typeof model.baseUrl === "string" ? model.baseUrl : undefined,
|
||||
serviceTier,
|
||||
});
|
||||
if (!payloadPolicy.allowsServiceTier) {
|
||||
if (!allowsAnthropicServiceTier(model)) {
|
||||
return underlying(model, context, options);
|
||||
}
|
||||
|
||||
return streamWithPayloadPatch(underlying, model, context, options, (payloadObj) =>
|
||||
applyAnthropicPayloadPolicyToParams(payloadObj, payloadPolicy),
|
||||
);
|
||||
return streamWithPayloadPatch(underlying, model, context, options, (payloadObj) => {
|
||||
if (payloadObj.service_tier === undefined) {
|
||||
payloadObj.service_tier = serviceTier;
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -172,19 +179,15 @@ export function createAnthropicServiceTierWrapper(
|
||||
): StreamFn {
|
||||
const underlying = baseStreamFn ?? streamSimple;
|
||||
return (model, context, options) => {
|
||||
const payloadPolicy = resolveAnthropicPayloadPolicy({
|
||||
provider: typeof model.provider === "string" ? model.provider : undefined,
|
||||
api: typeof model.api === "string" ? model.api : undefined,
|
||||
baseUrl: typeof model.baseUrl === "string" ? model.baseUrl : undefined,
|
||||
serviceTier,
|
||||
});
|
||||
if (!payloadPolicy.allowsServiceTier) {
|
||||
if (!allowsAnthropicServiceTier(model)) {
|
||||
return underlying(model, context, options);
|
||||
}
|
||||
|
||||
return streamWithPayloadPatch(underlying, model, context, options, (payloadObj) =>
|
||||
applyAnthropicPayloadPolicyToParams(payloadObj, payloadPolicy),
|
||||
);
|
||||
return streamWithPayloadPatch(underlying, model, context, options, (payloadObj) => {
|
||||
if (payloadObj.service_tier === undefined) {
|
||||
payloadObj.service_tier = serviceTier;
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ type DiscordGuild = { id: string; name: string };
|
||||
type DiscordUser = { id: string; username: string; global_name?: string; bot?: boolean };
|
||||
type DiscordMember = { user: DiscordUser; nick?: string | null };
|
||||
type DiscordChannel = { id: string; name?: string | null };
|
||||
type DiscordDirectoryAccess = { token: string; query: string; accountId: string };
|
||||
type DiscordDirectoryAccess = { token: string; query: string };
|
||||
|
||||
function normalizeQuery(value?: string | null): string {
|
||||
return value?.trim().toLowerCase() ?? "";
|
||||
@@ -30,7 +30,7 @@ function resolveDiscordDirectoryAccess(
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
return { token, query: normalizeQuery(params.query), accountId: account.accountId };
|
||||
return { token, query: normalizeQuery(params.query) };
|
||||
}
|
||||
|
||||
async function listDiscordGuilds(token: string): Promise<DiscordGuild[]> {
|
||||
@@ -45,7 +45,7 @@ export async function listDiscordDirectoryGroupsLive(
|
||||
if (!access) {
|
||||
return [];
|
||||
}
|
||||
const { token, query, accountId } = access;
|
||||
const { token, query } = access;
|
||||
const guilds = await listDiscordGuilds(token);
|
||||
const rows: ChannelDirectoryEntry[] = [];
|
||||
|
||||
@@ -82,7 +82,7 @@ export async function listDiscordDirectoryPeersLive(
|
||||
if (!access) {
|
||||
return [];
|
||||
}
|
||||
const { token, query, accountId } = access;
|
||||
const { token, query } = access;
|
||||
if (!query) {
|
||||
return [];
|
||||
}
|
||||
@@ -106,7 +106,7 @@ export async function listDiscordDirectoryPeersLive(
|
||||
continue;
|
||||
}
|
||||
rememberDiscordDirectoryUser({
|
||||
accountId,
|
||||
accountId: params.accountId,
|
||||
userId: user.id,
|
||||
handles: [
|
||||
user.username,
|
||||
|
||||
@@ -2,17 +2,9 @@ import { ChannelType } from "@buape/carbon";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const transcribeFirstAudioMock = vi.hoisted(() => vi.fn());
|
||||
const resolveDiscordDmCommandAccessMock = vi.hoisted(() => vi.fn());
|
||||
const handleDiscordDmCommandDecisionMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
|
||||
vi.mock("./preflight-audio.runtime.js", () => ({
|
||||
transcribeFirstAudio: transcribeFirstAudioMock,
|
||||
}));
|
||||
vi.mock("./dm-command-auth.js", () => ({
|
||||
resolveDiscordDmCommandAccess: resolveDiscordDmCommandAccessMock,
|
||||
}));
|
||||
vi.mock("./dm-command-decision.js", () => ({
|
||||
handleDiscordDmCommandDecision: handleDiscordDmCommandDecisionMock,
|
||||
transcribeFirstAudio: (...args: unknown[]) => transcribeFirstAudioMock(...args),
|
||||
}));
|
||||
import {
|
||||
__testing as sessionBindingTesting,
|
||||
@@ -269,14 +261,6 @@ describe("preflightDiscordMessage", () => {
|
||||
beforeEach(() => {
|
||||
sessionBindingTesting.resetSessionBindingAdaptersForTests();
|
||||
transcribeFirstAudioMock.mockReset();
|
||||
resolveDiscordDmCommandAccessMock.mockReset();
|
||||
resolveDiscordDmCommandAccessMock.mockResolvedValue({
|
||||
commandAuthorized: true,
|
||||
decision: "allow",
|
||||
allowMatch: { allowed: true, matchedBy: "allowFrom", value: "123" },
|
||||
});
|
||||
handleDiscordDmCommandDecisionMock.mockReset();
|
||||
handleDiscordDmCommandDecisionMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("drops bound-thread bot system messages to prevent ACP self-loop", async () => {
|
||||
@@ -365,56 +349,6 @@ describe("preflightDiscordMessage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the default discord account for omitted-account dm authorization", async () => {
|
||||
const message = createDiscordMessage({
|
||||
id: "m-dm-default-account",
|
||||
channelId: "dm-channel-default-account",
|
||||
content: "who are you",
|
||||
author: {
|
||||
id: "user-1",
|
||||
bot: false,
|
||||
username: "alice",
|
||||
},
|
||||
});
|
||||
|
||||
await preflightDiscordMessage({
|
||||
...createPreflightArgs({
|
||||
cfg: {
|
||||
...DEFAULT_PREFLIGHT_CFG,
|
||||
channels: {
|
||||
discord: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
default: {
|
||||
token: "token-default",
|
||||
},
|
||||
work: {
|
||||
token: "token-work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
discordConfig: {
|
||||
defaultAccount: "work",
|
||||
dmPolicy: "allowlist",
|
||||
} as DiscordConfig,
|
||||
data: {
|
||||
channel_id: "dm-channel-default-account",
|
||||
author: message.author,
|
||||
message,
|
||||
} as DiscordMessageEvent,
|
||||
client: createDmClient("dm-channel-default-account"),
|
||||
}),
|
||||
});
|
||||
|
||||
expect(resolveDiscordDmCommandAccessMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accountId: "default",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps bound-thread regular bot messages flowing when allowBots=true", async () => {
|
||||
const threadBinding = createThreadBinding({
|
||||
targetKind: "session",
|
||||
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
} from "./allow-list.js";
|
||||
import { resolveDiscordDmCommandAccess } from "./dm-command-auth.js";
|
||||
import { handleDiscordDmCommandDecision } from "./dm-command-decision.js";
|
||||
import { resolveDefaultDiscordAccountId } from "../accounts.js";
|
||||
import {
|
||||
formatDiscordUserTag,
|
||||
resolveDiscordSystemLocation,
|
||||
@@ -387,7 +386,7 @@ export async function preflightDiscordMessage(
|
||||
|
||||
const dmPolicy = params.discordConfig?.dmPolicy ?? params.discordConfig?.dm?.policy ?? "pairing";
|
||||
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
|
||||
const resolvedAccountId = params.accountId ?? resolveDefaultDiscordAccountId(params.cfg);
|
||||
const resolvedAccountId = params.accountId ?? DEFAULT_ACCOUNT_ID;
|
||||
const allowNameMatching = isDangerousNameMatchingEnabled(params.discordConfig);
|
||||
let commandAuthorized = true;
|
||||
if (isDirectMessage) {
|
||||
|
||||
@@ -365,15 +365,15 @@ export async function sendWebhookMessageDiscord(
|
||||
throw new Error("Discord webhook id/token are required");
|
||||
}
|
||||
|
||||
const rewrittenText = rewriteDiscordKnownMentions(text, {
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const replyTo = typeof opts.replyTo === "string" ? opts.replyTo.trim() : "";
|
||||
const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined;
|
||||
const { account, proxyFetch } = resolveDiscordClientAccountContext({
|
||||
cfg: opts.cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const rewrittenText = rewriteDiscordKnownMentions(text, {
|
||||
accountId: account.accountId,
|
||||
});
|
||||
|
||||
const response = await (proxyFetch ?? fetch)(
|
||||
resolveWebhookExecutionUrl({
|
||||
@@ -430,16 +430,11 @@ export async function sendStickerDiscord(
|
||||
stickerIds: string[],
|
||||
opts: DiscordSendOpts & { content?: string } = {},
|
||||
): Promise<DiscordSendResult> {
|
||||
const cfg = opts.cfg ?? loadConfig();
|
||||
const accountInfo = resolveDiscordAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const { rest, request, channelId } = await resolveDiscordSendTarget(to, opts);
|
||||
const content = opts.content?.trim();
|
||||
const rewrittenContent = content
|
||||
? rewriteDiscordKnownMentions(content, {
|
||||
accountId: accountInfo.accountId,
|
||||
accountId: opts.accountId,
|
||||
})
|
||||
: undefined;
|
||||
const stickers = normalizeStickerIds(stickerIds);
|
||||
@@ -461,16 +456,11 @@ export async function sendPollDiscord(
|
||||
poll: PollInput,
|
||||
opts: DiscordSendOpts & { content?: string } = {},
|
||||
): Promise<DiscordSendResult> {
|
||||
const cfg = opts.cfg ?? loadConfig();
|
||||
const accountInfo = resolveDiscordAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const { rest, request, channelId } = await resolveDiscordSendTarget(to, opts);
|
||||
const content = opts.content?.trim();
|
||||
const rewrittenContent = content
|
||||
? rewriteDiscordKnownMentions(content, {
|
||||
accountId: accountInfo.accountId,
|
||||
accountId: opts.accountId,
|
||||
})
|
||||
: undefined;
|
||||
if (poll.durationSeconds !== undefined) {
|
||||
|
||||
@@ -126,40 +126,6 @@ describe("sendMessageDiscord", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount for cached mention rewriting when accountId is omitted", async () => {
|
||||
rememberDiscordDirectoryUser({
|
||||
accountId: "work",
|
||||
userId: "222333444555666777",
|
||||
handles: ["Alice"],
|
||||
});
|
||||
const { rest, postMock, getMock } = makeDiscordRest();
|
||||
getMock.mockResolvedValueOnce({ type: ChannelType.GuildText });
|
||||
postMock.mockResolvedValue({
|
||||
id: "msg1",
|
||||
channel_id: "789",
|
||||
});
|
||||
await sendMessageDiscord("channel:789", "ping @Alice", {
|
||||
rest,
|
||||
token: "t",
|
||||
cfg: {
|
||||
channels: {
|
||||
discord: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
work: {
|
||||
token: "Bot work-token", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
Routes.channelMessages("789"),
|
||||
expect.objectContaining({ body: { content: "ping <@222333444555666777>" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("auto-creates a forum thread when target is a Forum channel", async () => {
|
||||
const { rest, postMock, getMock } = makeDiscordRest();
|
||||
// Channel type lookup returns a Forum channel.
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetDiscordDirectoryCacheForTest,
|
||||
resolveDiscordDirectoryUserId,
|
||||
} from "./directory-cache.js";
|
||||
import * as directoryLive from "./directory-live.js";
|
||||
import {
|
||||
resolveDiscordGroupRequireMention,
|
||||
@@ -80,7 +76,6 @@ describe("resolveDiscordTarget", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
__resetDiscordDirectoryCacheForTest();
|
||||
});
|
||||
|
||||
it("returns a resolved user for usernames", async () => {
|
||||
@@ -107,33 +102,6 @@ describe("resolveDiscordTarget", () => {
|
||||
).resolves.toMatchObject({ kind: "user", id: "123" });
|
||||
expect(listPeers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("caches username lookups under the configured default account when accountId is omitted", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
discord: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
work: {
|
||||
token: "discord-work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
vi.spyOn(directoryLive, "listDiscordDirectoryPeersLive").mockResolvedValueOnce([
|
||||
{ kind: "user", id: "user:999", name: "Jane" } as const,
|
||||
]);
|
||||
|
||||
await expect(resolveDiscordTarget("jane", { cfg })).resolves.toMatchObject({
|
||||
kind: "user",
|
||||
id: "999",
|
||||
normalized: "user:999",
|
||||
});
|
||||
expect(resolveDiscordDirectoryUserId({ accountId: "work", handle: "jane" })).toBe("999");
|
||||
expect(resolveDiscordDirectoryUserId({ accountId: "default", handle: "jane" })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeDiscordMessagingTarget", () => {
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
type MessagingTargetParseOptions,
|
||||
} from "openclaw/plugin-sdk/channel-targets";
|
||||
import type { DirectoryConfigParams } from "openclaw/plugin-sdk/directory-runtime";
|
||||
import { resolveDiscordAccount } from "./accounts.js";
|
||||
import { rememberDiscordDirectoryUser } from "./directory-cache.js";
|
||||
import { listDiscordDirectoryPeersLive } from "./directory-live.js";
|
||||
|
||||
@@ -101,12 +100,8 @@ export async function resolveDiscordTarget(
|
||||
if (match && match.kind === "user") {
|
||||
// Extract user ID from the directory entry (format: "user:<id>")
|
||||
const userId = match.id.replace(/^user:/, "");
|
||||
const resolvedAccountId = resolveDiscordAccount({
|
||||
cfg: options.cfg,
|
||||
accountId: options.accountId,
|
||||
}).accountId;
|
||||
rememberDiscordDirectoryUser({
|
||||
accountId: resolvedAccountId,
|
||||
accountId: options.accountId,
|
||||
userId,
|
||||
handles: [trimmed, match.name, match.handle],
|
||||
});
|
||||
|
||||
@@ -81,7 +81,7 @@ describe("feishu tool account routing", () => {
|
||||
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b");
|
||||
});
|
||||
|
||||
test("wiki tool prefers the active contextual account over configured defaultAccount", async () => {
|
||||
test("wiki tool prefers configured defaultAccount over inherited default account context", async () => {
|
||||
const { api, resolveTool } = createToolFactoryHarness(
|
||||
createConfig({
|
||||
defaultAccount: "b",
|
||||
@@ -94,7 +94,7 @@ describe("feishu tool account routing", () => {
|
||||
const tool = resolveTool("feishu_wiki", { agentAccountId: "a" });
|
||||
await tool.execute("call", { action: "search" });
|
||||
|
||||
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-a");
|
||||
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b");
|
||||
});
|
||||
|
||||
test("drive tool registers when first account disables it and routes to agentAccountId", async () => {
|
||||
|
||||
@@ -104,7 +104,13 @@ export const googlechatSetupWizard: ChannelSetupWizard = {
|
||||
unconfiguredHint: "needs auth",
|
||||
includeStatusLine: true,
|
||||
resolveConfigured: ({ cfg, accountId }) =>
|
||||
resolveGoogleChatAccount({ cfg, accountId }).credentialSource !== "none",
|
||||
accountId
|
||||
? resolveGoogleChatAccount({ cfg, accountId }).credentialSource !== "none"
|
||||
: listGoogleChatAccountIds(cfg).some(
|
||||
(resolvedAccountId) =>
|
||||
resolveGoogleChatAccount({ cfg, accountId: resolvedAccountId }).credentialSource !==
|
||||
"none",
|
||||
),
|
||||
}),
|
||||
introNote: {
|
||||
title: "Google Chat setup",
|
||||
|
||||
@@ -211,28 +211,6 @@ describe("googlechat setup", () => {
|
||||
expect(status.configured).toBe(false);
|
||||
});
|
||||
|
||||
it("reports configured state for the configured defaultAccount instead of any account", async () => {
|
||||
const status = await googlechatStatus({
|
||||
cfg: {
|
||||
channels: {
|
||||
googlechat: {
|
||||
defaultAccount: "alerts",
|
||||
accounts: {
|
||||
default: {
|
||||
serviceAccount: { client_email: "default@example.com" },
|
||||
},
|
||||
alerts: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
accountOverrides: {},
|
||||
options: {},
|
||||
});
|
||||
|
||||
expect(status.configured).toBe(false);
|
||||
});
|
||||
|
||||
it("reports account-scoped config keys for named accounts", () => {
|
||||
expect(googlechatPlugin.setupWizard?.dmPolicy?.resolveConfigKeys?.({}, "alerts")).toEqual({
|
||||
policyKey: "channels.googlechat.accounts.alerts.dm.policy",
|
||||
|
||||
@@ -107,10 +107,12 @@ describe("mattermost websocket monitor", () => {
|
||||
});
|
||||
|
||||
it("retries when first attempt errors before open and next attempt succeeds", async () => {
|
||||
const abort = new AbortController();
|
||||
const reconnectDelays: number[] = [];
|
||||
const onError = vi.fn();
|
||||
const patches: Array<Record<string, unknown>> = [];
|
||||
const sockets: FakeWebSocket[] = [];
|
||||
let disconnects = 0;
|
||||
|
||||
const connectOnce = createMattermostConnectOnce({
|
||||
wsUrl: "wss://example.invalid/api/v4/websocket",
|
||||
@@ -121,8 +123,15 @@ describe("mattermost websocket monitor", () => {
|
||||
return () => seq++;
|
||||
})(),
|
||||
onPosted: async () => {},
|
||||
abortSignal: abort.signal,
|
||||
statusSink: (patch) => {
|
||||
patches.push(patch as Record<string, unknown>);
|
||||
if (patch.lastDisconnect) {
|
||||
disconnects++;
|
||||
if (disconnects >= 2) {
|
||||
abort.abort();
|
||||
}
|
||||
}
|
||||
},
|
||||
webSocketFactory: () => {
|
||||
const socket = new FakeWebSocket();
|
||||
@@ -142,10 +151,10 @@ describe("mattermost websocket monitor", () => {
|
||||
});
|
||||
|
||||
await runWithReconnect(connectOnce, {
|
||||
abortSignal: abort.signal,
|
||||
initialDelayMs: 1,
|
||||
onError,
|
||||
onReconnect: (delay) => reconnectDelays.push(delay),
|
||||
shouldReconnect: ({ outcome }) => outcome === "rejected",
|
||||
});
|
||||
|
||||
expect(sockets).toHaveLength(2);
|
||||
|
||||
@@ -10,9 +10,6 @@ import { clearSlackRuntime, setSlackRuntime } from "./runtime.js";
|
||||
const { handleSlackActionMock } = vi.hoisted(() => ({
|
||||
handleSlackActionMock: vi.fn(),
|
||||
}));
|
||||
const { sendMessageSlackMock } = vi.hoisted(() => ({
|
||||
sendMessageSlackMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./action-runtime.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./action-runtime.js")>("./action-runtime.js");
|
||||
@@ -22,14 +19,8 @@ vi.mock("./action-runtime.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./send.runtime.js", () => ({
|
||||
sendMessageSlack: sendMessageSlackMock,
|
||||
}));
|
||||
|
||||
beforeEach(async () => {
|
||||
handleSlackActionMock.mockReset();
|
||||
sendMessageSlackMock.mockReset();
|
||||
sendMessageSlackMock.mockResolvedValue({ messageId: "msg-1", channelId: "D123" });
|
||||
setSlackRuntime({
|
||||
channel: {
|
||||
slack: {
|
||||
@@ -189,41 +180,6 @@ describe("slackPlugin actions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount for pairing approval notifications", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
work: {
|
||||
botToken: "xoxb-work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
setSlackRuntime({
|
||||
config: {
|
||||
loadConfig: () => cfg,
|
||||
},
|
||||
} as never);
|
||||
|
||||
const notify = slackPlugin.pairing?.notifyApproval;
|
||||
if (!notify) {
|
||||
throw new Error("slack pairing notify unavailable");
|
||||
}
|
||||
|
||||
await notify({
|
||||
cfg,
|
||||
id: "U12345678",
|
||||
});
|
||||
|
||||
expect(sendMessageSlackMock).toHaveBeenCalledWith(
|
||||
"user:U12345678",
|
||||
expect.stringContaining("approved"),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps blocks optional in the message tool schema", () => {
|
||||
const discovery = slackPlugin.actions?.describeMessageTool({
|
||||
cfg: {
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
} from "openclaw/plugin-sdk/status-helpers";
|
||||
import {
|
||||
listEnabledSlackAccounts,
|
||||
resolveDefaultSlackAccountId,
|
||||
resolveSlackAccount,
|
||||
resolveSlackReplyToMode,
|
||||
type ResolvedSlackAccount,
|
||||
@@ -490,7 +489,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
|
||||
const cfg = getSlackRuntime().config.loadConfig();
|
||||
const account = resolveSlackAccount({
|
||||
cfg,
|
||||
accountId: resolveDefaultSlackAccountId(cfg),
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
});
|
||||
const { sendMessageSlack } = await loadSlackSendRuntime();
|
||||
const token = getTokenForOperation(account, "write");
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
sendPayloadMediaSequenceAndFinalize,
|
||||
sendTextMediaPayload,
|
||||
} from "openclaw/plugin-sdk/reply-payload";
|
||||
import { resolveSlackAccount } from "./accounts.js";
|
||||
import { parseSlackBlocksInput } from "./blocks-input.js";
|
||||
import { buildSlackInteractiveBlocks, type SlackBlock } from "./blocks-render.js";
|
||||
import { compileSlackInteractiveReplies } from "./interactive-replies.js";
|
||||
@@ -51,7 +50,6 @@ function resolveSlackSendIdentity(identity?: OutboundIdentity): SlackSendIdentit
|
||||
}
|
||||
|
||||
async function applySlackMessageSendingHooks(params: {
|
||||
cfg: NonNullable<NonNullable<Parameters<typeof sendMessageSlack>[2]>["cfg"]>;
|
||||
to: string;
|
||||
text: string;
|
||||
threadTs?: string;
|
||||
@@ -62,10 +60,6 @@ async function applySlackMessageSendingHooks(params: {
|
||||
if (!hookRunner?.hasHooks("message_sending")) {
|
||||
return { cancelled: false, text: params.text };
|
||||
}
|
||||
const account = resolveSlackAccount({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const hookResult = await hookRunner.runMessageSending(
|
||||
{
|
||||
to: params.to,
|
||||
@@ -76,7 +70,7 @@ async function applySlackMessageSendingHooks(params: {
|
||||
...(params.mediaUrl ? { mediaUrl: params.mediaUrl } : {}),
|
||||
},
|
||||
},
|
||||
{ channelId: "slack", accountId: account.accountId },
|
||||
{ channelId: "slack", accountId: params.accountId ?? undefined },
|
||||
);
|
||||
if (hookResult?.cancel) {
|
||||
return { cancelled: true, text: params.text };
|
||||
@@ -85,7 +79,7 @@ async function applySlackMessageSendingHooks(params: {
|
||||
}
|
||||
|
||||
async function sendSlackOutboundMessage(params: {
|
||||
cfg: NonNullable<NonNullable<Parameters<typeof sendMessageSlack>[2]>["cfg"]>;
|
||||
cfg: NonNullable<Parameters<typeof sendMessageSlack>[2]>["cfg"];
|
||||
to: string;
|
||||
text: string;
|
||||
mediaUrl?: string;
|
||||
@@ -107,7 +101,6 @@ async function sendSlackOutboundMessage(params: {
|
||||
const threadTs =
|
||||
params.replyToId ?? (params.threadId != null ? String(params.threadId) : undefined);
|
||||
const hookResult = await applySlackMessageSendingHooks({
|
||||
cfg: params.cfg,
|
||||
to: params.to,
|
||||
text: params.text,
|
||||
threadTs,
|
||||
|
||||
@@ -140,38 +140,6 @@ describe("slack outbound hook wiring", () => {
|
||||
expectSlackSendCalledWith("hello");
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount for hook context when accountId is omitted", async () => {
|
||||
const mockRunner = {
|
||||
hasHooks: vi.fn().mockReturnValue(true),
|
||||
runMessageSending: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
getGlobalHookRunnerMock.mockReturnValue(mockRunner);
|
||||
|
||||
const sendText = slackOutbound.sendText as NonNullable<typeof slackOutbound.sendText>;
|
||||
await sendText({
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
work: {
|
||||
botToken: "xoxb-work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
to: "C123",
|
||||
text: "hello",
|
||||
replyToId: "1111.2222",
|
||||
});
|
||||
|
||||
expect(mockRunner.runMessageSending).toHaveBeenCalledWith(
|
||||
{ to: "C123", content: "hello", metadata: { threadTs: "1111.2222", channelId: "C123" } },
|
||||
{ channelId: "slack", accountId: "work" },
|
||||
);
|
||||
});
|
||||
|
||||
it("cancels send when hook returns cancel:true", async () => {
|
||||
const mockRunner = {
|
||||
hasHooks: vi.fn().mockReturnValue(true),
|
||||
|
||||
@@ -4,10 +4,7 @@ export * from "./src/action-threading.js";
|
||||
export * from "./src/allow-from.js";
|
||||
export * from "./src/api-fetch.js";
|
||||
export * from "./src/bot/helpers.js";
|
||||
export {
|
||||
buildCommandsPaginationKeyboard,
|
||||
buildTelegramModelsProviderChannelData,
|
||||
} from "./src/command-ui.js";
|
||||
export { buildTelegramModelsProviderChannelData } from "./src/command-ui.js";
|
||||
export * from "./src/directory-config.js";
|
||||
export * from "./src/exec-approval-forwarding.js";
|
||||
export * from "./src/exec-approvals.js";
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
export function buildTelegramInboundDebounceKey(params: {
|
||||
accountId?: string | null;
|
||||
conversationKey: string;
|
||||
senderId: string;
|
||||
debounceLane: "default" | "forward";
|
||||
}): string {
|
||||
const resolvedAccountId = params.accountId?.trim() || "default";
|
||||
return `telegram:${resolvedAccountId}:${params.conversationKey}:${params.senderId}:${params.debounceLane}`;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildTelegramInboundDebounceKey } from "./bot-handlers.debounce-key.js";
|
||||
|
||||
describe("buildTelegramInboundDebounceKey", () => {
|
||||
it("uses the resolved account id instead of literal default when provided", () => {
|
||||
expect(
|
||||
buildTelegramInboundDebounceKey({
|
||||
accountId: "work",
|
||||
conversationKey: "12345",
|
||||
senderId: "67890",
|
||||
debounceLane: "default",
|
||||
}),
|
||||
).toBe("telegram:work:12345:67890:default");
|
||||
});
|
||||
|
||||
it("falls back to literal default only when account id is actually absent", () => {
|
||||
expect(
|
||||
buildTelegramInboundDebounceKey({
|
||||
accountId: undefined,
|
||||
conversationKey: "12345",
|
||||
senderId: "67890",
|
||||
debounceLane: "forward",
|
||||
}),
|
||||
).toBe("telegram:default:12345:67890:forward");
|
||||
});
|
||||
});
|
||||
@@ -101,7 +101,6 @@ import {
|
||||
resolveModelSelection,
|
||||
type ProviderInfo,
|
||||
} from "./model-buttons.js";
|
||||
import { buildTelegramInboundDebounceKey } from "./bot-handlers.debounce-key.js";
|
||||
import { buildInlineKeyboard } from "./send.js";
|
||||
|
||||
export const registerTelegramHandlers = ({
|
||||
@@ -1086,12 +1085,7 @@ export const registerTelegramHandlers = ({
|
||||
conversationThreadId != null ? `${chatId}:topic:${conversationThreadId}` : String(chatId);
|
||||
const debounceLane = resolveTelegramDebounceLane(msg);
|
||||
const debounceKey = senderId
|
||||
? buildTelegramInboundDebounceKey({
|
||||
accountId,
|
||||
conversationKey,
|
||||
senderId,
|
||||
debounceLane,
|
||||
})
|
||||
? `telegram:${accountId ?? "default"}:${conversationKey}:${senderId}:${debounceLane}`
|
||||
: null;
|
||||
await inboundDebouncer.enqueue({
|
||||
ctx,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite
|
||||
let registerTelegramNativeCommands: typeof import("./bot-native-commands.js").registerTelegramNativeCommands;
|
||||
let clearPluginCommands: typeof import("../../../src/plugins/commands.js").clearPluginCommands;
|
||||
let registerPluginCommand: typeof import("../../../src/plugins/commands.js").registerPluginCommand;
|
||||
let setActivePluginRegistry: typeof import("../../../src/plugins/runtime.js").setActivePluginRegistry;
|
||||
let createCommandBot: typeof import("./bot-native-commands.menu-test-support.js").createCommandBot;
|
||||
let createNativeCommandTestParams: typeof import("./bot-native-commands.menu-test-support.js").createNativeCommandTestParams;
|
||||
let createPrivateCommandContext: typeof import("./bot-native-commands.menu-test-support.js").createPrivateCommandContext;
|
||||
@@ -13,62 +12,6 @@ let editMessageTelegram: typeof import("./bot-native-commands.menu-test-support.
|
||||
let resetNativeCommandMenuMocks: typeof import("./bot-native-commands.menu-test-support.js").resetNativeCommandMenuMocks;
|
||||
let waitForRegisteredCommands: typeof import("./bot-native-commands.menu-test-support.js").waitForRegisteredCommands;
|
||||
|
||||
function createTelegramPluginRegistry() {
|
||||
return {
|
||||
plugins: [],
|
||||
tools: [],
|
||||
hooks: [],
|
||||
typedHooks: [],
|
||||
channels: [
|
||||
{
|
||||
pluginId: "telegram",
|
||||
source: "test",
|
||||
plugin: {
|
||||
id: "telegram",
|
||||
meta: {
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
selectionLabel: "Telegram",
|
||||
docsPath: "/channels/telegram",
|
||||
blurb: "test stub.",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => ({}),
|
||||
},
|
||||
commands: {
|
||||
nativeCommandsAutoEnabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
channelSetups: [
|
||||
{
|
||||
pluginId: "telegram",
|
||||
source: "test",
|
||||
enabled: true,
|
||||
plugin: {
|
||||
id: "telegram",
|
||||
},
|
||||
},
|
||||
],
|
||||
providers: [],
|
||||
speechProviders: [],
|
||||
mediaUnderstandingProviders: [],
|
||||
imageGenerationProviders: [],
|
||||
webFetchProviders: [],
|
||||
webSearchProviders: [],
|
||||
gatewayHandlers: {},
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
conversationBindingResolvedHandlers: [],
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
function registerPairPluginCommand(params?: {
|
||||
nativeNames?: { telegram?: string; discord?: string };
|
||||
nativeProgressMessages?: { telegram?: string; default?: string };
|
||||
@@ -113,7 +56,6 @@ describe("registerTelegramNativeCommands real plugin registry", () => {
|
||||
beforeAll(async () => {
|
||||
({ clearPluginCommands, registerPluginCommand } =
|
||||
await import("../../../src/plugins/commands.js"));
|
||||
({ setActivePluginRegistry } = await import("../../../src/plugins/runtime.js"));
|
||||
({ registerTelegramNativeCommands } = await import("./bot-native-commands.js"));
|
||||
({
|
||||
createCommandBot,
|
||||
@@ -127,7 +69,6 @@ describe("registerTelegramNativeCommands real plugin registry", () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePluginRegistry(createTelegramPluginRegistry() as never);
|
||||
clearPluginCommands();
|
||||
resetNativeCommandMenuMocks();
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
|
||||
const {
|
||||
getLoadConfigMock,
|
||||
@@ -76,34 +75,34 @@ describe("createTelegramBot command menu", () => {
|
||||
|
||||
it("merges custom commands with native commands", async () => {
|
||||
const config = {
|
||||
commands: {
|
||||
native: true,
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
envelopeTimezone: "utc",
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
telegram: {
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
execApprovals: {
|
||||
enabled: true,
|
||||
approvers: ["9"],
|
||||
target: "dm",
|
||||
},
|
||||
customCommands: [
|
||||
{ command: "custom_backup", description: "Git backup" },
|
||||
{ command: "/Custom_Generate", description: "Create an image" },
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
};
|
||||
loadConfig.mockReturnValue(config);
|
||||
const commandsSynced = waitForNextSetMyCommands();
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
createTelegramBot({
|
||||
token: "tok",
|
||||
config: {
|
||||
channels: {
|
||||
telegram: {
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
execApprovals: {
|
||||
enabled: true,
|
||||
approvers: ["9"],
|
||||
target: "dm",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await commandsSynced;
|
||||
|
||||
@@ -122,25 +121,15 @@ describe("createTelegramBot command menu", () => {
|
||||
it("ignores custom commands that collide with native commands", async () => {
|
||||
const errorSpy = vi.fn();
|
||||
const config = {
|
||||
commands: {
|
||||
native: true,
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
envelopeTimezone: "utc",
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
telegram: {
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
customCommands: [
|
||||
{ command: "status", description: "Custom status" },
|
||||
{ command: "custom_backup", description: "Git backup" },
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
};
|
||||
loadConfig.mockReturnValue(config);
|
||||
const commandsSynced = waitForNextSetMyCommands();
|
||||
|
||||
@@ -177,22 +166,15 @@ describe("createTelegramBot command menu", () => {
|
||||
it("registers custom commands when native commands are disabled", async () => {
|
||||
const config = {
|
||||
commands: { native: false },
|
||||
agents: {
|
||||
defaults: {
|
||||
envelopeTimezone: "utc",
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
telegram: {
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
customCommands: [
|
||||
{ command: "custom_backup", description: "Git backup" },
|
||||
{ command: "custom_generate", description: "Create an image" },
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
};
|
||||
loadConfig.mockReturnValue(config);
|
||||
const commandsSynced = waitForNextSetMyCommands();
|
||||
|
||||
|
||||
@@ -352,7 +352,6 @@ describe("createTelegramBot", () => {
|
||||
loadConfig.mockReturnValue({
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "tok",
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
capabilities: ["vision"],
|
||||
@@ -756,8 +755,12 @@ describe("createTelegramBot", () => {
|
||||
const [chatId, messageId, text, params] = editMessageTextSpy.mock.calls[0] ?? [];
|
||||
expect(chatId).toBe(1234);
|
||||
expect(messageId).toBe(12);
|
||||
expect(String(text)).toContain(`${INFO_EMOJI} Slash commands`);
|
||||
expect(params).toBeUndefined();
|
||||
expect(String(text)).toContain(`${INFO_EMOJI} Commands`);
|
||||
expect(params).toEqual(
|
||||
expect.objectContaining({
|
||||
reply_markup: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to default agent for pagination callbacks without agent suffix", async () => {
|
||||
|
||||
@@ -39,16 +39,6 @@ const pluginRequest = {
|
||||
};
|
||||
|
||||
function createHandler(cfg: OpenClawConfig, accountId = "default") {
|
||||
const normalizedCfg = {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
telegram: {
|
||||
...cfg.channels?.telegram,
|
||||
botToken: cfg.channels?.telegram?.botToken ?? "tg-token",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const sendTyping = vi.fn().mockResolvedValue({ ok: true });
|
||||
const sendMessage = vi
|
||||
.fn()
|
||||
@@ -59,7 +49,7 @@ function createHandler(cfg: OpenClawConfig, accountId = "default") {
|
||||
{
|
||||
token: "tg-token",
|
||||
accountId,
|
||||
cfg: normalizedCfg,
|
||||
cfg,
|
||||
},
|
||||
{
|
||||
nowMs: () => 1000,
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { twitchMessageActions } from "./actions.js";
|
||||
import { twitchOutbound } from "./outbound.js";
|
||||
import { resolveTwitchAccountContext } from "./config.js";
|
||||
|
||||
vi.mock("./config.js", () => ({
|
||||
DEFAULT_ACCOUNT_ID: "default",
|
||||
resolveTwitchAccountContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./outbound.js", () => ({
|
||||
twitchOutbound: {
|
||||
sendText: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("twitchMessageActions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount when action accountId is omitted", async () => {
|
||||
vi.mocked(resolveTwitchAccountContext)
|
||||
.mockImplementationOnce(() => ({
|
||||
accountId: "secondary",
|
||||
account: {
|
||||
channel: "secondary-channel",
|
||||
username: "secondary",
|
||||
accessToken: "oauth:secondary-token",
|
||||
clientId: "secondary-client",
|
||||
enabled: true,
|
||||
},
|
||||
tokenResolution: { source: "config", token: "oauth:secondary-token" },
|
||||
configured: true,
|
||||
availableAccountIds: ["default", "secondary"],
|
||||
}))
|
||||
.mockImplementation((_cfg, accountId) => ({
|
||||
accountId: accountId?.trim() || "secondary",
|
||||
account: {
|
||||
channel: "secondary-channel",
|
||||
username: "secondary",
|
||||
accessToken: "oauth:secondary-token",
|
||||
clientId: "secondary-client",
|
||||
enabled: true,
|
||||
},
|
||||
tokenResolution: { source: "config", token: "oauth:secondary-token" },
|
||||
configured: true,
|
||||
availableAccountIds: ["default", "secondary"],
|
||||
}));
|
||||
vi.mocked(twitchOutbound.sendText!).mockResolvedValue({
|
||||
channel: "twitch",
|
||||
messageId: "msg-1",
|
||||
timestamp: 1,
|
||||
});
|
||||
|
||||
await twitchMessageActions.handleAction!({
|
||||
action: "send",
|
||||
params: { message: "Hello!" },
|
||||
cfg: {
|
||||
channels: {
|
||||
twitch: {
|
||||
defaultAccount: "secondary",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(twitchOutbound.sendText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accountId: "secondary",
|
||||
to: "secondary-channel",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
* Handles tool-based actions for Twitch, such as sending messages.
|
||||
*/
|
||||
|
||||
import { resolveTwitchAccountContext } from "./config.js";
|
||||
import { DEFAULT_ACCOUNT_ID, resolveTwitchAccountContext } from "./config.js";
|
||||
import { twitchOutbound } from "./outbound.js";
|
||||
import type { ChannelMessageActionAdapter, ChannelMessageActionContext } from "./types.js";
|
||||
|
||||
@@ -130,7 +130,7 @@ export const twitchMessageActions: ChannelMessageActionAdapter = {
|
||||
|
||||
const message = readStringParam(ctx.params, "message", { required: true });
|
||||
const to = readStringParam(ctx.params, "to", { required: false });
|
||||
const accountId = ctx.accountId ?? resolveTwitchAccountContext(ctx.cfg).accountId;
|
||||
const accountId = ctx.accountId ?? DEFAULT_ACCOUNT_ID;
|
||||
|
||||
const { account, availableAccountIds } = resolveTwitchAccountContext(ctx.cfg, accountId);
|
||||
if (!account) {
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getAccountConfig,
|
||||
listAccountIds,
|
||||
resolveDefaultTwitchAccountId,
|
||||
resolveTwitchAccountContext,
|
||||
} from "./config.js";
|
||||
import { getAccountConfig, listAccountIds } from "./config.js";
|
||||
|
||||
describe("getAccountConfig", () => {
|
||||
const mockMultiAccountConfig = {
|
||||
@@ -121,46 +116,3 @@ describe("listAccountIds", () => {
|
||||
).toEqual(["default", "secondary"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDefaultTwitchAccountId", () => {
|
||||
it("prefers channels.twitch.defaultAccount when configured", () => {
|
||||
expect(
|
||||
resolveDefaultTwitchAccountId({
|
||||
channels: {
|
||||
twitch: {
|
||||
defaultAccount: "secondary",
|
||||
accounts: {
|
||||
default: { username: "default" },
|
||||
secondary: { username: "secondary" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof resolveDefaultTwitchAccountId>[0]),
|
||||
).toBe("secondary");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTwitchAccountContext", () => {
|
||||
it("uses configured defaultAccount when accountId is omitted", () => {
|
||||
const context = resolveTwitchAccountContext({
|
||||
channels: {
|
||||
twitch: {
|
||||
defaultAccount: "secondary",
|
||||
accounts: {
|
||||
default: {
|
||||
username: "default-bot",
|
||||
accessToken: "oauth:default-token",
|
||||
},
|
||||
secondary: {
|
||||
username: "second-bot",
|
||||
accessToken: "oauth:second-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof resolveTwitchAccountContext>[0]);
|
||||
|
||||
expect(context.accountId).toBe("secondary");
|
||||
expect(context.account?.username).toBe("second-bot");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -118,26 +118,11 @@ export function listAccountIds(cfg: OpenClawConfig): string[] {
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveDefaultTwitchAccountId(cfg: OpenClawConfig): string {
|
||||
const preferred =
|
||||
typeof cfg.channels?.twitch?.defaultAccount === "string"
|
||||
? cfg.channels.twitch.defaultAccount.trim()
|
||||
: "";
|
||||
const ids = listAccountIds(cfg);
|
||||
if (preferred && ids.includes(preferred)) {
|
||||
return preferred;
|
||||
}
|
||||
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
export function resolveTwitchAccountContext(
|
||||
cfg: OpenClawConfig,
|
||||
accountId?: string | null,
|
||||
): ResolvedTwitchAccountContext {
|
||||
const resolvedAccountId = accountId?.trim() || resolveDefaultTwitchAccountId(cfg);
|
||||
const resolvedAccountId = accountId?.trim() || DEFAULT_ACCOUNT_ID;
|
||||
const account = getAccountConfig(cfg, resolvedAccountId);
|
||||
const tokenResolution = resolveTwitchToken(cfg, { accountId: resolvedAccountId });
|
||||
return {
|
||||
|
||||
@@ -305,57 +305,6 @@ describe("outbound", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount when accountId is omitted", async () => {
|
||||
const { sendMessageTwitchInternal } = await import("./send.js");
|
||||
|
||||
vi.mocked(resolveTwitchAccountContext)
|
||||
.mockImplementationOnce(() => ({
|
||||
accountId: "secondary",
|
||||
account: {
|
||||
...mockAccount,
|
||||
channel: "secondary-channel",
|
||||
},
|
||||
tokenResolution: { source: "config", token: mockAccount.accessToken },
|
||||
configured: true,
|
||||
availableAccountIds: ["default", "secondary"],
|
||||
}))
|
||||
.mockImplementation((_cfg, accountId) => ({
|
||||
accountId: accountId?.trim() || "secondary",
|
||||
account: {
|
||||
...mockAccount,
|
||||
channel: "secondary-channel",
|
||||
},
|
||||
tokenResolution: { source: "config", token: mockAccount.accessToken },
|
||||
configured: true,
|
||||
availableAccountIds: ["default", "secondary"],
|
||||
}));
|
||||
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
||||
ok: true,
|
||||
messageId: "msg-secondary",
|
||||
});
|
||||
|
||||
await twitchOutbound.sendText!({
|
||||
cfg: {
|
||||
channels: {
|
||||
twitch: {
|
||||
defaultAccount: "secondary",
|
||||
},
|
||||
},
|
||||
} as typeof mockConfig,
|
||||
to: "#secondary-channel",
|
||||
text: "Hello!",
|
||||
});
|
||||
|
||||
expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
|
||||
"secondary-channel",
|
||||
"Hello!",
|
||||
expect.any(Object),
|
||||
"secondary",
|
||||
true,
|
||||
console,
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle abort signal", async () => {
|
||||
const abortController = new AbortController();
|
||||
abortController.abort();
|
||||
|
||||
@@ -113,7 +113,7 @@ export const twitchOutbound: ChannelOutboundAdapter = {
|
||||
throw new Error("Outbound delivery aborted");
|
||||
}
|
||||
|
||||
const resolvedAccountId = accountId ?? resolveTwitchAccountContext(cfg).accountId;
|
||||
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
|
||||
const { account, availableAccountIds } = resolveTwitchAccountContext(cfg, resolvedAccountId);
|
||||
if (!account) {
|
||||
throw new Error(
|
||||
|
||||
@@ -44,34 +44,3 @@ describe("twitchPlugin.status.buildAccountSnapshot", () => {
|
||||
expect(snapshot?.accountId).toBe("secondary");
|
||||
});
|
||||
});
|
||||
|
||||
describe("twitchPlugin.config", () => {
|
||||
it("uses configured defaultAccount for omitted-account plugin resolution", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
twitch: {
|
||||
defaultAccount: "secondary",
|
||||
accounts: {
|
||||
default: {
|
||||
channel: "default-channel",
|
||||
username: "default",
|
||||
accessToken: "oauth:default-token",
|
||||
clientId: "default-client",
|
||||
enabled: true,
|
||||
},
|
||||
secondary: {
|
||||
channel: "secondary-channel",
|
||||
username: "secondary",
|
||||
accessToken: "oauth:secondary-token",
|
||||
clientId: "secondary-client",
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(twitchPlugin.config.defaultAccountId?.(cfg)).toBe("secondary");
|
||||
expect(twitchPlugin.config.resolveAccount(cfg).accountId).toBe("secondary");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
getAccountConfig,
|
||||
listAccountIds,
|
||||
resolveDefaultTwitchAccountId,
|
||||
resolveTwitchAccountContext,
|
||||
resolveTwitchSnapshotAccountId,
|
||||
} from "./config.js";
|
||||
@@ -82,7 +81,7 @@ export const twitchPlugin: ChannelPlugin<ResolvedTwitchAccount> =
|
||||
config: {
|
||||
listAccountIds: (cfg: OpenClawConfig): string[] => listAccountIds(cfg),
|
||||
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null): ResolvedTwitchAccount => {
|
||||
const resolvedAccountId = accountId ?? resolveDefaultTwitchAccountId(cfg);
|
||||
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
|
||||
const account = getAccountConfig(cfg, resolvedAccountId);
|
||||
if (!account) {
|
||||
return {
|
||||
@@ -99,9 +98,9 @@ export const twitchPlugin: ChannelPlugin<ResolvedTwitchAccount> =
|
||||
...account,
|
||||
};
|
||||
},
|
||||
defaultAccountId: (cfg: OpenClawConfig): string => resolveDefaultTwitchAccountId(cfg),
|
||||
defaultAccountId: (): string => DEFAULT_ACCOUNT_ID,
|
||||
isConfigured: (_account: unknown, cfg: OpenClawConfig): boolean =>
|
||||
resolveTwitchAccountContext(cfg).configured,
|
||||
resolveTwitchAccountContext(cfg, DEFAULT_ACCOUNT_ID).configured,
|
||||
isEnabled: (account: ResolvedTwitchAccount | undefined): boolean =>
|
||||
account?.enabled !== false,
|
||||
describeAccount: (account: TwitchAccountConfig | undefined) =>
|
||||
@@ -131,7 +130,7 @@ export const twitchPlugin: ChannelPlugin<ResolvedTwitchAccount> =
|
||||
kind: ChannelResolveKind;
|
||||
runtime: import("openclaw/plugin-sdk/runtime-env").RuntimeEnv;
|
||||
}): Promise<ChannelResolveResult[]> => {
|
||||
const account = getAccountConfig(cfg, accountId ?? resolveDefaultTwitchAccountId(cfg));
|
||||
const account = getAccountConfig(cfg, accountId ?? DEFAULT_ACCOUNT_ID);
|
||||
if (!account) {
|
||||
return inputs.map((input) => ({
|
||||
input,
|
||||
|
||||
@@ -258,52 +258,5 @@ describe("send", () => {
|
||||
"default",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the configured default account when accountId is omitted", async () => {
|
||||
const secondaryAccount = {
|
||||
...mockAccount,
|
||||
username: "secondary-user",
|
||||
channel: "secondary-channel",
|
||||
};
|
||||
vi.mocked(resolveTwitchAccountContext).mockImplementation((_cfg, accountId) => ({
|
||||
accountId: accountId?.trim() || "secondary",
|
||||
account: secondaryAccount,
|
||||
tokenResolution: { source: "config", token: secondaryAccount.accessToken ?? "" },
|
||||
configured: true,
|
||||
availableAccountIds: ["default", "secondary"],
|
||||
}));
|
||||
const mockSend = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
messageId: "twitch-msg-secondary",
|
||||
});
|
||||
vi.mocked(getClientManager).mockReturnValue({
|
||||
sendMessage: mockSend,
|
||||
} as unknown as ReturnType<typeof getClientManager>);
|
||||
|
||||
const result = await sendMessageTwitchInternal(
|
||||
"",
|
||||
"Hello!",
|
||||
{
|
||||
channels: {
|
||||
twitch: {
|
||||
defaultAccount: "secondary",
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
undefined,
|
||||
false,
|
||||
mockLogger as unknown as Console,
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(getClientManager).toHaveBeenCalledWith("secondary");
|
||||
expect(mockSend).toHaveBeenCalledWith(
|
||||
secondaryAccount,
|
||||
"secondary-channel",
|
||||
"Hello!",
|
||||
expect.any(Object),
|
||||
"secondary",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import { getClientManager as getRegistryClientManager } from "./client-manager-registry.js";
|
||||
import { resolveTwitchAccountContext } from "./config.js";
|
||||
import { DEFAULT_ACCOUNT_ID, resolveTwitchAccountContext } from "./config.js";
|
||||
import { stripMarkdownForTwitch } from "./utils/markdown.js";
|
||||
import { generateMessageId, normalizeTwitchChannel } from "./utils/twitch.js";
|
||||
|
||||
@@ -51,21 +51,16 @@ export async function sendMessageTwitchInternal(
|
||||
channel: string,
|
||||
text: string,
|
||||
cfg: OpenClawConfig,
|
||||
accountId?: string,
|
||||
accountId: string = DEFAULT_ACCOUNT_ID,
|
||||
stripMarkdown: boolean = true,
|
||||
logger: Console = console,
|
||||
): Promise<SendMessageResult> {
|
||||
const {
|
||||
account,
|
||||
configured,
|
||||
availableAccountIds,
|
||||
accountId: resolvedAccountId,
|
||||
} = resolveTwitchAccountContext(cfg, accountId);
|
||||
const { account, configured, availableAccountIds } = resolveTwitchAccountContext(cfg, accountId);
|
||||
if (!account) {
|
||||
return {
|
||||
ok: false,
|
||||
messageId: generateMessageId(),
|
||||
error: `Account not found: ${accountId ?? "(default)"}. Available accounts: ${availableAccountIds.join(", ") || "none"}`,
|
||||
error: `Account not found: ${accountId}. Available accounts: ${availableAccountIds.join(", ") || "none"}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -74,7 +69,7 @@ export async function sendMessageTwitchInternal(
|
||||
ok: false,
|
||||
messageId: generateMessageId(),
|
||||
error:
|
||||
`Account ${resolvedAccountId} is not properly configured. ` +
|
||||
`Account ${accountId} is not properly configured. ` +
|
||||
"Required: username, clientId, and token (config or env for default account).",
|
||||
};
|
||||
}
|
||||
@@ -96,12 +91,12 @@ export async function sendMessageTwitchInternal(
|
||||
};
|
||||
}
|
||||
|
||||
const clientManager = getRegistryClientManager(resolvedAccountId);
|
||||
const clientManager = getRegistryClientManager(accountId);
|
||||
if (!clientManager) {
|
||||
return {
|
||||
ok: false,
|
||||
messageId: generateMessageId(),
|
||||
error: `Client manager not found for account: ${resolvedAccountId}. Please start the Twitch gateway first.`,
|
||||
error: `Client manager not found for account: ${accountId}. Please start the Twitch gateway first.`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -111,7 +106,7 @@ export async function sendMessageTwitchInternal(
|
||||
normalizeTwitchChannel(normalizedChannel),
|
||||
cleanedText,
|
||||
cfg,
|
||||
resolvedAccountId,
|
||||
accountId,
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
|
||||
@@ -201,60 +201,5 @@ describe("setup surface helpers", () => {
|
||||
expect(result?.cfg.channels?.twitch?.accounts?.default?.username).toBe("testbot");
|
||||
expect(result?.cfg.channels?.twitch?.accounts?.default?.clientId).toBe("test-client-id");
|
||||
});
|
||||
|
||||
it("writes env-token setup to the configured default account", async () => {
|
||||
const { configureWithEnvToken } = await import("./setup-surface.js");
|
||||
|
||||
mockPromptConfirm.mockReset().mockResolvedValue(true as never);
|
||||
mockPromptText
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce("secondary-bot" as never)
|
||||
.mockResolvedValueOnce("secondary-client" as never);
|
||||
|
||||
const result = await configureWithEnvToken(
|
||||
{
|
||||
channels: {
|
||||
twitch: {
|
||||
defaultAccount: "secondary",
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof configureWithEnvToken>[0],
|
||||
mockPrompter,
|
||||
null,
|
||||
"oauth:fromenv",
|
||||
false,
|
||||
{} as Parameters<typeof configureWithEnvToken>[5],
|
||||
);
|
||||
|
||||
expect(result?.cfg.channels?.twitch?.accounts?.secondary?.username).toBe("secondary-bot");
|
||||
expect(result?.cfg.channels?.twitch?.accounts?.secondary?.clientId).toBe("secondary-client");
|
||||
expect(result?.cfg.channels?.twitch?.accounts?.default).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultAccount setup resolution", () => {
|
||||
it("reports status for the configured default account", async () => {
|
||||
const { twitchSetupWizard } = await import("./setup-surface.js");
|
||||
|
||||
const lines = twitchSetupWizard.status?.resolveStatusLines?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
twitch: {
|
||||
defaultAccount: "secondary",
|
||||
accounts: {
|
||||
secondary: {
|
||||
username: "secondary-bot",
|
||||
accessToken: "oauth:secondary",
|
||||
clientId: "secondary-client",
|
||||
channel: "#secondary",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(lines).toEqual(["Twitch (secondary): configured"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,27 +10,17 @@ import {
|
||||
type OpenClawConfig,
|
||||
type WizardPrompter,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
getAccountConfig,
|
||||
resolveDefaultTwitchAccountId,
|
||||
} from "./config.js";
|
||||
import { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js";
|
||||
import type { TwitchAccountConfig, TwitchRole } from "./types.js";
|
||||
import { isAccountConfigured } from "./utils/twitch.js";
|
||||
|
||||
const channel = "twitch" as const;
|
||||
|
||||
function resolveSetupAccountId(cfg: OpenClawConfig): string {
|
||||
const preferred = cfg.channels?.twitch?.defaultAccount?.trim();
|
||||
return preferred || resolveDefaultTwitchAccountId(cfg);
|
||||
}
|
||||
|
||||
export function setTwitchAccount(
|
||||
cfg: OpenClawConfig,
|
||||
account: Partial<TwitchAccountConfig>,
|
||||
accountId: string = resolveSetupAccountId(cfg),
|
||||
): OpenClawConfig {
|
||||
const existing = getAccountConfig(cfg, accountId);
|
||||
const existing = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID);
|
||||
const merged: TwitchAccountConfig = {
|
||||
username: account.username ?? existing?.username ?? "",
|
||||
accessToken: account.accessToken ?? existing?.accessToken ?? "",
|
||||
@@ -59,7 +49,7 @@ export function setTwitchAccount(
|
||||
...((
|
||||
(cfg.channels as Record<string, unknown>)?.twitch as Record<string, unknown> | undefined
|
||||
)?.accounts as Record<string, unknown> | undefined),
|
||||
[accountId]: merged,
|
||||
[DEFAULT_ACCOUNT_ID]: merged,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -227,8 +217,7 @@ function setTwitchAccessControl(
|
||||
allowedRoles: TwitchRole[],
|
||||
requireMention: boolean,
|
||||
): OpenClawConfig {
|
||||
const accountId = resolveSetupAccountId(cfg);
|
||||
const account = getAccountConfig(cfg, accountId);
|
||||
const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID);
|
||||
if (!account) {
|
||||
return cfg;
|
||||
}
|
||||
@@ -237,11 +226,11 @@ function setTwitchAccessControl(
|
||||
...account,
|
||||
allowedRoles,
|
||||
requireMention,
|
||||
}, accountId);
|
||||
});
|
||||
}
|
||||
|
||||
function resolveTwitchGroupPolicy(cfg: OpenClawConfig): "open" | "allowlist" | "disabled" {
|
||||
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg));
|
||||
const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID);
|
||||
if (account?.allowedRoles?.includes("all")) {
|
||||
return "open";
|
||||
}
|
||||
@@ -264,9 +253,9 @@ const twitchDmPolicy: ChannelSetupDmPolicy = {
|
||||
label: "Twitch",
|
||||
channel,
|
||||
policyKey: "channels.twitch.allowedRoles",
|
||||
allowFromKey: "channels.twitch.accounts.<default>.allowFrom",
|
||||
allowFromKey: "channels.twitch.accounts.default.allowFrom",
|
||||
getCurrent: (cfg) => {
|
||||
const account = getAccountConfig(cfg as OpenClawConfig, resolveSetupAccountId(cfg as OpenClawConfig));
|
||||
const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID);
|
||||
if (account?.allowedRoles?.includes("all")) {
|
||||
return "open";
|
||||
}
|
||||
@@ -281,8 +270,7 @@ const twitchDmPolicy: ChannelSetupDmPolicy = {
|
||||
return setTwitchAccessControl(cfg as OpenClawConfig, allowedRoles, true);
|
||||
},
|
||||
promptAllowFrom: async ({ cfg, prompter }) => {
|
||||
const accountId = resolveSetupAccountId(cfg as OpenClawConfig);
|
||||
const account = getAccountConfig(cfg as OpenClawConfig, accountId);
|
||||
const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID);
|
||||
const existingAllowFrom = account?.allowFrom ?? [];
|
||||
|
||||
const entry = await prompter.text({
|
||||
@@ -299,7 +287,7 @@ const twitchDmPolicy: ChannelSetupDmPolicy = {
|
||||
return setTwitchAccount(cfg as OpenClawConfig, {
|
||||
...(account ?? undefined),
|
||||
allowFrom,
|
||||
}, accountId);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -309,11 +297,11 @@ const twitchGroupAccess: NonNullable<ChannelSetupWizard["groupAccess"]> = {
|
||||
skipAllowlistEntries: true,
|
||||
currentPolicy: ({ cfg }) => resolveTwitchGroupPolicy(cfg as OpenClawConfig),
|
||||
currentEntries: ({ cfg }) => {
|
||||
const account = getAccountConfig(cfg as OpenClawConfig, resolveSetupAccountId(cfg as OpenClawConfig));
|
||||
const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID);
|
||||
return account?.allowFrom ?? [];
|
||||
},
|
||||
updatePrompt: ({ cfg }) => {
|
||||
const account = getAccountConfig(cfg as OpenClawConfig, resolveSetupAccountId(cfg as OpenClawConfig));
|
||||
const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID);
|
||||
return Boolean(account?.allowedRoles?.length || account?.allowFrom?.length);
|
||||
},
|
||||
setPolicy: ({ cfg, policy }) => setTwitchGroupPolicy(cfg as OpenClawConfig, policy),
|
||||
@@ -322,16 +310,16 @@ const twitchGroupAccess: NonNullable<ChannelSetupWizard["groupAccess"]> = {
|
||||
};
|
||||
|
||||
export const twitchSetupAdapter: ChannelSetupAdapter = {
|
||||
resolveAccountId: ({ cfg }) => resolveSetupAccountId(cfg as OpenClawConfig),
|
||||
applyAccountConfig: ({ cfg, accountId }) =>
|
||||
resolveAccountId: () => DEFAULT_ACCOUNT_ID,
|
||||
applyAccountConfig: ({ cfg }) =>
|
||||
setTwitchAccount(cfg, {
|
||||
enabled: true,
|
||||
}, accountId),
|
||||
}),
|
||||
};
|
||||
|
||||
export const twitchSetupWizard: ChannelSetupWizard = {
|
||||
channel,
|
||||
resolveAccountIdForConfigure: ({ defaultAccountId }) => defaultAccountId,
|
||||
resolveAccountIdForConfigure: () => DEFAULT_ACCOUNT_ID,
|
||||
resolveShouldPromptAccountIds: () => false,
|
||||
status: {
|
||||
configuredLabel: "configured",
|
||||
@@ -339,22 +327,18 @@ export const twitchSetupWizard: ChannelSetupWizard = {
|
||||
configuredHint: "configured",
|
||||
unconfiguredHint: "needs setup",
|
||||
resolveConfigured: ({ cfg }) => {
|
||||
const account = getAccountConfig(cfg as OpenClawConfig, resolveSetupAccountId(cfg as OpenClawConfig));
|
||||
const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID);
|
||||
return account ? isAccountConfigured(account) : false;
|
||||
},
|
||||
resolveStatusLines: ({ cfg }) => {
|
||||
const accountId = resolveSetupAccountId(cfg as OpenClawConfig);
|
||||
const account = getAccountConfig(cfg as OpenClawConfig, accountId);
|
||||
const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID);
|
||||
const configured = account ? isAccountConfigured(account) : false;
|
||||
return [
|
||||
`Twitch${accountId !== DEFAULT_ACCOUNT_ID ? ` (${accountId})` : ""}: ${configured ? "configured" : "needs username, token, and clientId"}`,
|
||||
];
|
||||
return [`Twitch: ${configured ? "configured" : "needs username, token, and clientId"}`];
|
||||
},
|
||||
},
|
||||
credentials: [],
|
||||
finalize: async ({ cfg, prompter, forceAllowFrom }) => {
|
||||
const accountId = resolveSetupAccountId(cfg as OpenClawConfig);
|
||||
const account = getAccountConfig(cfg as OpenClawConfig, accountId);
|
||||
const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID);
|
||||
|
||||
if (!account || !isAccountConfigured(account)) {
|
||||
await noteTwitchSetupHelp(prompter);
|
||||
@@ -390,7 +374,7 @@ export const twitchSetupWizard: ChannelSetupWizard = {
|
||||
clientSecret,
|
||||
refreshToken,
|
||||
enabled: true,
|
||||
}, accountId);
|
||||
});
|
||||
|
||||
const cfgWithAllowFrom =
|
||||
forceAllowFrom && twitchDmPolicy.promptAllowFrom
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createQueuedWizardPrompter } from "../../../test/helpers/plugins/setup-wizard.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
import { whatsappPlugin } from "./channel.js";
|
||||
import { finalizeWhatsAppSetup } from "./setup-finalize.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
@@ -18,10 +17,7 @@ vi.mock("./login.js", () => ({
|
||||
loginWeb: hoisted.loginWeb,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/setup", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/setup")>(
|
||||
"openclaw/plugin-sdk/setup",
|
||||
);
|
||||
vi.mock("openclaw/plugin-sdk/setup", () => {
|
||||
const normalizeE164 = (value?: string | null) => {
|
||||
const raw = `${value ?? ""}`.trim();
|
||||
if (!raw) {
|
||||
@@ -31,7 +27,6 @@ vi.mock("openclaw/plugin-sdk/setup", async () => {
|
||||
return digits.startsWith("+") ? digits : `+${digits}`;
|
||||
};
|
||||
return {
|
||||
...actual,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId: (value?: string | null) => value?.trim() || DEFAULT_ACCOUNT_ID,
|
||||
normalizeAllowFromEntries: (entries: string[], normalize: (value: string) => string) => [
|
||||
@@ -253,27 +248,4 @@ describe("whatsapp setup wizard", () => {
|
||||
"WhatsApp",
|
||||
);
|
||||
});
|
||||
|
||||
it("heartbeat readiness uses configured defaultAccount for active listener checks", async () => {
|
||||
const result = await whatsappPlugin.heartbeat?.checkReady?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
work: {
|
||||
authDir: "/tmp/work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
deps: {
|
||||
webAuthExists: async () => true,
|
||||
hasActiveWebListener: (accountId?: string) => accountId === "work",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true, reason: "ok" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,8 +158,8 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> =
|
||||
return { ok: false, reason: "whatsapp-not-linked" };
|
||||
}
|
||||
const listenerActive = deps?.hasActiveWebListener
|
||||
? deps.hasActiveWebListener(account.accountId)
|
||||
: Boolean((await loadWhatsAppChannelRuntime()).getActiveWebListener(account.accountId));
|
||||
? deps.hasActiveWebListener()
|
||||
: Boolean((await loadWhatsAppChannelRuntime()).getActiveWebListener());
|
||||
if (!listenerActive) {
|
||||
return { ok: false, reason: "whatsapp-not-running" };
|
||||
}
|
||||
|
||||
@@ -84,36 +84,6 @@ describe("web outbound", () => {
|
||||
expect(sendMessage).toHaveBeenCalledWith("+1555", "hi", undefined, undefined);
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount when outbound accountId is omitted", async () => {
|
||||
setActiveWebListener(null);
|
||||
setActiveWebListener("work", {
|
||||
sendComposingTo,
|
||||
sendMessage,
|
||||
sendPoll,
|
||||
sendReaction,
|
||||
});
|
||||
|
||||
const result = await sendMessageWhatsApp("+1555", "hi", {
|
||||
verbose: false,
|
||||
cfg: {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
work: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
messageId: "msg123",
|
||||
toJid: "1555@s.whatsapp.net",
|
||||
});
|
||||
expect(sendMessage).toHaveBeenCalledWith("+1555", "hi", undefined, undefined);
|
||||
});
|
||||
|
||||
it("trims leading whitespace before sending text and captions", async () => {
|
||||
await sendMessageWhatsApp("+1555", "\n \thello", { verbose: false });
|
||||
expect(sendMessage).toHaveBeenLastCalledWith("+1555", "hello", undefined, undefined);
|
||||
|
||||
@@ -8,27 +8,12 @@ import { redactIdentifier } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { markdownToWhatsApp } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { toWhatsappJid } from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
resolveDefaultWhatsAppAccountId,
|
||||
resolveWhatsAppAccount,
|
||||
resolveWhatsAppMediaMaxBytes,
|
||||
} from "./accounts.js";
|
||||
import { resolveWhatsAppAccount, resolveWhatsAppMediaMaxBytes } from "./accounts.js";
|
||||
import { type ActiveWebSendOptions, requireActiveWebListener } from "./active-listener.js";
|
||||
import { loadOutboundMediaFromUrl } from "./runtime-api.js";
|
||||
|
||||
const outboundLog = createSubsystemLogger("gateway/channels/whatsapp").child("outbound");
|
||||
|
||||
function resolveOutboundWhatsAppAccountId(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string;
|
||||
}): string | undefined {
|
||||
const explicitAccountId = params.accountId?.trim();
|
||||
if (explicitAccountId) {
|
||||
return explicitAccountId;
|
||||
}
|
||||
return resolveDefaultWhatsAppAccountId(params.cfg);
|
||||
}
|
||||
|
||||
export async function sendMessageWhatsApp(
|
||||
to: string,
|
||||
body: string,
|
||||
@@ -53,14 +38,10 @@ export async function sendMessageWhatsApp(
|
||||
}
|
||||
const correlationId = generateSecureUuid();
|
||||
const startedAt = Date.now();
|
||||
const cfg = options.cfg ?? loadConfig();
|
||||
const effectiveAccountId = resolveOutboundWhatsAppAccountId({
|
||||
cfg,
|
||||
accountId: options.accountId,
|
||||
});
|
||||
const { listener: active, accountId: resolvedAccountId } = requireActiveWebListener(
|
||||
effectiveAccountId,
|
||||
options.accountId,
|
||||
);
|
||||
const cfg = options.cfg ?? loadConfig();
|
||||
const account = resolveWhatsAppAccount({
|
||||
cfg,
|
||||
accountId: resolvedAccountId ?? options.accountId,
|
||||
@@ -152,12 +133,7 @@ export async function sendReactionWhatsApp(
|
||||
},
|
||||
): Promise<void> {
|
||||
const correlationId = generateSecureUuid();
|
||||
const cfg = loadConfig();
|
||||
const effectiveAccountId = resolveOutboundWhatsAppAccountId({
|
||||
cfg,
|
||||
accountId: options.accountId,
|
||||
});
|
||||
const { listener: active } = requireActiveWebListener(effectiveAccountId);
|
||||
const { listener: active } = requireActiveWebListener(options.accountId);
|
||||
const redactedChatJid = redactIdentifier(chatJid);
|
||||
const logger = getChildLogger({
|
||||
module: "web-outbound",
|
||||
@@ -195,12 +171,7 @@ export async function sendPollWhatsApp(
|
||||
): Promise<{ messageId: string; toJid: string }> {
|
||||
const correlationId = generateSecureUuid();
|
||||
const startedAt = Date.now();
|
||||
const cfg = options.cfg ?? loadConfig();
|
||||
const effectiveAccountId = resolveOutboundWhatsAppAccountId({
|
||||
cfg,
|
||||
accountId: options.accountId,
|
||||
});
|
||||
const { listener: active } = requireActiveWebListener(effectiveAccountId);
|
||||
const { listener: active } = requireActiveWebListener(options.accountId);
|
||||
const redactedTo = redactIdentifier(to);
|
||||
const logger = getChildLogger({
|
||||
module: "web-outbound",
|
||||
|
||||
@@ -10,11 +10,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
|
||||
import { formatCliCommand, formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
|
||||
import {
|
||||
resolveDefaultWhatsAppAccountId,
|
||||
resolveWhatsAppAccount,
|
||||
resolveWhatsAppAuthDir,
|
||||
} from "./accounts.js";
|
||||
import { resolveWhatsAppAccount, resolveWhatsAppAuthDir } from "./accounts.js";
|
||||
import { loginWeb } from "./login.js";
|
||||
import { whatsappSetupAdapter } from "./setup-core.js";
|
||||
|
||||
@@ -337,20 +333,19 @@ export async function finalizeWhatsAppSetup(params: {
|
||||
prompter: SetupPrompter;
|
||||
runtime: SetupRuntime;
|
||||
}) {
|
||||
const accountId = params.accountId.trim() || resolveDefaultWhatsAppAccountId(params.cfg);
|
||||
let next =
|
||||
accountId === DEFAULT_ACCOUNT_ID
|
||||
params.accountId === DEFAULT_ACCOUNT_ID
|
||||
? params.cfg
|
||||
: whatsappSetupAdapter.applyAccountConfig({
|
||||
cfg: params.cfg,
|
||||
accountId,
|
||||
accountId: params.accountId,
|
||||
input: {},
|
||||
});
|
||||
|
||||
const linked = await detectWhatsAppLinked(next, accountId);
|
||||
const linked = await detectWhatsAppLinked(next, params.accountId);
|
||||
const { authDir } = resolveWhatsAppAuthDir({
|
||||
cfg: next,
|
||||
accountId,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
|
||||
if (!linked) {
|
||||
@@ -370,7 +365,7 @@ export async function finalizeWhatsAppSetup(params: {
|
||||
});
|
||||
if (wantsLink) {
|
||||
try {
|
||||
await loginWeb(false, undefined, params.runtime, accountId);
|
||||
await loginWeb(false, undefined, params.runtime, params.accountId);
|
||||
} catch (error) {
|
||||
params.runtime.error(`WhatsApp login failed: ${String(error)}`);
|
||||
await params.prompter.note(
|
||||
@@ -387,7 +382,7 @@ export async function finalizeWhatsAppSetup(params: {
|
||||
|
||||
next = await promptWhatsAppDmAccess({
|
||||
cfg: next,
|
||||
accountId,
|
||||
accountId: params.accountId,
|
||||
forceAllowFrom: params.forceAllowFrom,
|
||||
prompter: params.prompter,
|
||||
});
|
||||
|
||||
@@ -10,9 +10,7 @@ import { whatsappSetupPlugin } from "./channel.setup.js";
|
||||
import { whatsappSetupWizard } from "./setup-surface.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
detectWhatsAppLinked: vi.fn<(cfg: OpenClawConfig, accountId: string) => Promise<boolean>>(
|
||||
async () => false,
|
||||
),
|
||||
detectWhatsAppLinked: vi.fn(async () => false),
|
||||
loginWeb: vi.fn(async () => {}),
|
||||
pathExists: vi.fn(async () => false),
|
||||
resolveWhatsAppAuthDir: vi.fn(() => ({
|
||||
@@ -184,7 +182,10 @@ describe("whatsapp setup wizard", () => {
|
||||
expect(named.cfg.channels?.whatsapp?.dmPolicy).toBe("disabled");
|
||||
expect(named.cfg.channels?.whatsapp?.allowFrom).toEqual(["+15555550123"]);
|
||||
expect(named.cfg.channels?.whatsapp?.accounts?.work?.dmPolicy).toBe("open");
|
||||
expect(named.cfg.channels?.whatsapp?.accounts?.work?.allowFrom).toEqual(["*", "+15555550123"]);
|
||||
expect(named.cfg.channels?.whatsapp?.accounts?.work?.allowFrom).toEqual([
|
||||
"*",
|
||||
"+15555550123",
|
||||
]);
|
||||
expect(harness.note).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"`channels.whatsapp.accounts.work.dmPolicy` + `channels.whatsapp.accounts.work.allowFrom`",
|
||||
@@ -215,78 +216,6 @@ describe("whatsapp setup wizard", () => {
|
||||
expect(status.statusLines).toEqual(["WhatsApp (work): not linked"]);
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount for omitted-account setup status", async () => {
|
||||
hoisted.detectWhatsAppLinked.mockImplementation(
|
||||
async (_cfg: OpenClawConfig, accountId: string) => accountId === "work",
|
||||
);
|
||||
|
||||
const status = await whatsappGetStatus({
|
||||
cfg: {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
default: {
|
||||
authDir: "/tmp/default",
|
||||
},
|
||||
work: {
|
||||
authDir: "/tmp/work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
accountOverrides: {},
|
||||
});
|
||||
|
||||
expect(status.configured).toBe(true);
|
||||
expect(status.statusLines).toEqual(["WhatsApp (work): linked"]);
|
||||
expect(hoisted.detectWhatsAppLinked).toHaveBeenCalledWith(expect.any(Object), "work");
|
||||
expect(hoisted.detectWhatsAppLinked).not.toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount for omitted-account finalize writes", async () => {
|
||||
hoisted.pathExists.mockResolvedValue(true);
|
||||
const harness = createSeparatePhoneHarness({
|
||||
selectValues: ["separate", "open"],
|
||||
});
|
||||
|
||||
const result = expectFinalizeResult(
|
||||
await runFinalizeWithHarness({
|
||||
harness,
|
||||
accountId: "",
|
||||
cfg: {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
defaultAccount: "work",
|
||||
dmPolicy: "disabled",
|
||||
allowFrom: ["+15555550123"],
|
||||
accounts: {
|
||||
work: {
|
||||
authDir: "/tmp/work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.cfg.channels?.whatsapp?.dmPolicy).toBe("disabled");
|
||||
expect(result.cfg.channels?.whatsapp?.allowFrom).toEqual(["+15555550123"]);
|
||||
expect(result.cfg.channels?.whatsapp?.accounts?.work?.dmPolicy).toBe("open");
|
||||
expect(result.cfg.channels?.whatsapp?.accounts?.work?.allowFrom).toEqual(["*", "+15555550123"]);
|
||||
expect(harness.note).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"`channels.whatsapp.accounts.work.dmPolicy` + `channels.whatsapp.accounts.work.allowFrom`",
|
||||
),
|
||||
"WhatsApp DM access",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes allowFrom entries when list mode is selected", async () => {
|
||||
const { result } = await runSeparatePhoneFlow({
|
||||
selectValues: ["separate", "allowlist", "list"],
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
|
||||
import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
|
||||
import { listWhatsAppAccountIds, resolveDefaultWhatsAppAccountId } from "./accounts.js";
|
||||
import { listWhatsAppAccountIds } from "./accounts.js";
|
||||
import { detectWhatsAppLinked, finalizeWhatsAppSetup } from "./setup-finalize.js";
|
||||
|
||||
const channel = "whatsapp" as const;
|
||||
@@ -20,13 +20,23 @@ export const whatsappSetupWizard: ChannelSetupWizard = {
|
||||
configuredScore: 5,
|
||||
unconfiguredScore: 4,
|
||||
resolveConfigured: async ({ cfg, accountId }) => {
|
||||
return await detectWhatsAppLinked(
|
||||
cfg,
|
||||
accountId || resolveDefaultWhatsAppAccountId(cfg),
|
||||
);
|
||||
for (const resolvedAccountId of accountId ? [accountId] : listWhatsAppAccountIds(cfg)) {
|
||||
if (await detectWhatsAppLinked(cfg, resolvedAccountId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
resolveStatusLines: async ({ cfg, accountId, configured }) => {
|
||||
const labelAccountId = accountId || resolveDefaultWhatsAppAccountId(cfg);
|
||||
const linkedAccountId = (
|
||||
await Promise.all(
|
||||
(accountId ? [accountId] : listWhatsAppAccountIds(cfg)).map(async (resolvedAccountId) => ({
|
||||
accountId: resolvedAccountId,
|
||||
linked: await detectWhatsAppLinked(cfg, resolvedAccountId),
|
||||
})),
|
||||
)
|
||||
).find((entry) => entry.linked)?.accountId;
|
||||
const labelAccountId = accountId ?? linkedAccountId;
|
||||
const label = labelAccountId
|
||||
? `WhatsApp (${labelAccountId === DEFAULT_ACCOUNT_ID ? "default" : labelAccountId})`
|
||||
: "WhatsApp";
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyAnthropicPayloadPolicyToParams,
|
||||
resolveAnthropicPayloadPolicy,
|
||||
} from "./anthropic-payload-policy.js";
|
||||
|
||||
type TestPayload = {
|
||||
messages: Array<{ role: string; content: unknown }>;
|
||||
service_tier?: string;
|
||||
system?: unknown;
|
||||
};
|
||||
|
||||
describe("anthropic payload policy", () => {
|
||||
it("applies native Anthropic service tier and cache markers without widening cache scope", () => {
|
||||
const policy = resolveAnthropicPayloadPolicy({
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://api.anthropic.com/v1",
|
||||
cacheRetention: "long",
|
||||
enableCacheControl: true,
|
||||
serviceTier: "standard_only",
|
||||
});
|
||||
const payload: TestPayload = {
|
||||
system: [
|
||||
{ type: "text", text: "Follow policy." },
|
||||
{ type: "text", text: "Use tools carefully." },
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Working." }],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Hello" },
|
||||
{ type: "tool_result", tool_use_id: "tool_1", content: "done" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
applyAnthropicPayloadPolicyToParams(payload, policy);
|
||||
|
||||
expect(payload.service_tier).toBe("standard_only");
|
||||
expect(payload.system).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: "Follow policy.",
|
||||
cache_control: { type: "ephemeral", ttl: "1h" },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Use tools carefully.",
|
||||
cache_control: { type: "ephemeral", ttl: "1h" },
|
||||
},
|
||||
]);
|
||||
expect(payload.messages[0]).toEqual({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Working." }],
|
||||
});
|
||||
expect(payload.messages[1]).toEqual({
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Hello" },
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool_1",
|
||||
content: "done",
|
||||
cache_control: { type: "ephemeral", ttl: "1h" },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("denies proxied Anthropic service tier and omits long-TTL upgrades for custom hosts", () => {
|
||||
const policy = resolveAnthropicPayloadPolicy({
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://proxy.example.com/anthropic",
|
||||
cacheRetention: "long",
|
||||
enableCacheControl: true,
|
||||
serviceTier: "auto",
|
||||
});
|
||||
const payload: TestPayload = {
|
||||
system: [{ type: "text", text: "Follow policy." }],
|
||||
messages: [{ role: "user", content: "Hello" }],
|
||||
};
|
||||
|
||||
applyAnthropicPayloadPolicyToParams(payload, policy);
|
||||
|
||||
expect(payload).not.toHaveProperty("service_tier");
|
||||
expect(payload.system).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: "Follow policy.",
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
]);
|
||||
expect(payload.messages[0]).toEqual({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hello", cache_control: { type: "ephemeral" } }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,188 +0,0 @@
|
||||
import { resolveProviderRequestCapabilities } from "./provider-attribution.js";
|
||||
|
||||
export type AnthropicServiceTier = "auto" | "standard_only";
|
||||
|
||||
export type AnthropicEphemeralCacheControl = {
|
||||
type: "ephemeral";
|
||||
ttl?: "1h";
|
||||
};
|
||||
|
||||
type AnthropicPayloadPolicyInput = {
|
||||
api?: string;
|
||||
baseUrl?: string;
|
||||
cacheRetention?: "short" | "long" | "none";
|
||||
enableCacheControl?: boolean;
|
||||
provider?: string;
|
||||
serviceTier?: AnthropicServiceTier;
|
||||
};
|
||||
|
||||
export type AnthropicPayloadPolicy = {
|
||||
allowsServiceTier: boolean;
|
||||
cacheControl: AnthropicEphemeralCacheControl | undefined;
|
||||
serviceTier: AnthropicServiceTier | undefined;
|
||||
};
|
||||
|
||||
function resolveAnthropicEphemeralCacheControl(
|
||||
baseUrl: string | undefined,
|
||||
cacheRetention: AnthropicPayloadPolicyInput["cacheRetention"],
|
||||
): AnthropicEphemeralCacheControl | undefined {
|
||||
const retention =
|
||||
cacheRetention ?? (process.env.PI_CACHE_RETENTION === "long" ? "long" : "short");
|
||||
if (retention === "none") {
|
||||
return undefined;
|
||||
}
|
||||
const ttl =
|
||||
retention === "long" && typeof baseUrl === "string" && baseUrl.includes("api.anthropic.com")
|
||||
? "1h"
|
||||
: undefined;
|
||||
return { type: "ephemeral", ...(ttl ? { ttl } : {}) };
|
||||
}
|
||||
|
||||
function applyAnthropicCacheControlToSystem(
|
||||
system: unknown,
|
||||
cacheControl: AnthropicEphemeralCacheControl,
|
||||
): void {
|
||||
if (!Array.isArray(system)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const block of system) {
|
||||
if (!block || typeof block !== "object") {
|
||||
continue;
|
||||
}
|
||||
const record = block as Record<string, unknown>;
|
||||
if (record.type === "text" && record.cache_control === undefined) {
|
||||
record.cache_control = cacheControl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyAnthropicCacheControlToMessages(
|
||||
messages: unknown,
|
||||
cacheControl: AnthropicEphemeralCacheControl,
|
||||
): void {
|
||||
if (!Array.isArray(messages) || messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
if (!lastMessage || typeof lastMessage !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
const record = lastMessage as Record<string, unknown>;
|
||||
if (record.role !== "user") {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = record.content;
|
||||
if (Array.isArray(content)) {
|
||||
const lastBlock = content[content.length - 1];
|
||||
if (!lastBlock || typeof lastBlock !== "object") {
|
||||
return;
|
||||
}
|
||||
const lastBlockRecord = lastBlock as Record<string, unknown>;
|
||||
if (
|
||||
lastBlockRecord.type === "text" ||
|
||||
lastBlockRecord.type === "image" ||
|
||||
lastBlockRecord.type === "tool_result"
|
||||
) {
|
||||
lastBlockRecord.cache_control = cacheControl;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof content === "string") {
|
||||
record.content = [
|
||||
{
|
||||
type: "text",
|
||||
text: content,
|
||||
cache_control: cacheControl,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAnthropicPayloadPolicy(
|
||||
input: AnthropicPayloadPolicyInput,
|
||||
): AnthropicPayloadPolicy {
|
||||
const capabilities = resolveProviderRequestCapabilities({
|
||||
provider: input.provider,
|
||||
api: input.api,
|
||||
baseUrl: input.baseUrl,
|
||||
capability: "llm",
|
||||
transport: "stream",
|
||||
});
|
||||
|
||||
return {
|
||||
allowsServiceTier: capabilities.allowsAnthropicServiceTier,
|
||||
cacheControl:
|
||||
input.enableCacheControl === true
|
||||
? resolveAnthropicEphemeralCacheControl(input.baseUrl, input.cacheRetention)
|
||||
: undefined,
|
||||
serviceTier: input.serviceTier,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyAnthropicPayloadPolicyToParams(
|
||||
payloadObj: Record<string, unknown>,
|
||||
policy: AnthropicPayloadPolicy,
|
||||
): void {
|
||||
if (
|
||||
policy.allowsServiceTier &&
|
||||
policy.serviceTier !== undefined &&
|
||||
payloadObj.service_tier === undefined
|
||||
) {
|
||||
payloadObj.service_tier = policy.serviceTier;
|
||||
}
|
||||
|
||||
if (!policy.cacheControl) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyAnthropicCacheControlToSystem(payloadObj.system, policy.cacheControl);
|
||||
// Preserve Anthropic cache-write scope by only tagging the trailing user turn.
|
||||
applyAnthropicCacheControlToMessages(payloadObj.messages, policy.cacheControl);
|
||||
}
|
||||
|
||||
export function applyAnthropicEphemeralCacheControlMarkers(
|
||||
payloadObj: Record<string, unknown>,
|
||||
): void {
|
||||
const messages = payloadObj.messages;
|
||||
if (!Array.isArray(messages)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const message of messages as Array<{ role?: string; content?: unknown }>) {
|
||||
if (message.role === "system" || message.role === "developer") {
|
||||
if (typeof message.content === "string") {
|
||||
message.content = [
|
||||
{ type: "text", text: message.content, cache_control: { type: "ephemeral" } },
|
||||
];
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(message.content) && message.content.length > 0) {
|
||||
const last = message.content[message.content.length - 1];
|
||||
if (last && typeof last === "object") {
|
||||
const record = last as Record<string, unknown>;
|
||||
if (record.type !== "thinking" && record.type !== "redacted_thinking") {
|
||||
record.cache_control = { type: "ephemeral" };
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role === "assistant" && Array.isArray(message.content)) {
|
||||
for (const block of message.content) {
|
||||
if (!block || typeof block !== "object") {
|
||||
continue;
|
||||
}
|
||||
const record = block as Record<string, unknown>;
|
||||
if (record.type === "thinking" || record.type === "redacted_thinking") {
|
||||
delete record.cache_control;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,6 @@ import {
|
||||
type SimpleStreamOptions,
|
||||
type ThinkingLevel,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import {
|
||||
applyAnthropicPayloadPolicyToParams,
|
||||
resolveAnthropicPayloadPolicy,
|
||||
} from "./anthropic-payload-policy.js";
|
||||
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./copilot-dynamic-headers.js";
|
||||
import { buildGuardedModelFetch } from "./provider-transport-fetch.js";
|
||||
import { transformTransportMessages } from "./transport-message-transform.js";
|
||||
@@ -168,6 +164,22 @@ function fromClaudeCodeName(name: string, tools: Context["tools"] | undefined):
|
||||
return name;
|
||||
}
|
||||
|
||||
function resolveCacheControl(
|
||||
baseUrl: string | undefined,
|
||||
cacheRetention: AnthropicOptions["cacheRetention"],
|
||||
): { type: "ephemeral"; ttl?: "1h" } | undefined {
|
||||
const retention =
|
||||
cacheRetention ?? (process.env.PI_CACHE_RETENTION === "long" ? "long" : "short");
|
||||
if (retention === "none") {
|
||||
return undefined;
|
||||
}
|
||||
const ttl =
|
||||
retention === "long" && typeof baseUrl === "string" && baseUrl.includes("api.anthropic.com")
|
||||
? "1h"
|
||||
: undefined;
|
||||
return { type: "ephemeral", ...(ttl ? { ttl } : {}) };
|
||||
}
|
||||
|
||||
function convertContentBlocks(
|
||||
content: Array<
|
||||
{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }
|
||||
@@ -212,6 +224,7 @@ function convertAnthropicMessages(
|
||||
messages: Context["messages"],
|
||||
model: AnthropicTransportModel,
|
||||
isOAuthToken: boolean,
|
||||
cacheControl: { type: "ephemeral"; ttl?: "1h" } | undefined,
|
||||
) {
|
||||
const params: Array<Record<string, unknown>> = [];
|
||||
const transformedMessages = transformTransportMessages(messages, model, normalizeToolCallId);
|
||||
@@ -348,6 +361,33 @@ function convertAnthropicMessages(
|
||||
});
|
||||
}
|
||||
}
|
||||
if (cacheControl && params.length > 0) {
|
||||
const lastMessage = params[params.length - 1];
|
||||
if (lastMessage.role === "user") {
|
||||
const content = lastMessage.content;
|
||||
if (Array.isArray(content)) {
|
||||
const lastBlock = content[content.length - 1];
|
||||
if (
|
||||
lastBlock &&
|
||||
typeof lastBlock === "object" &&
|
||||
"type" in lastBlock &&
|
||||
(lastBlock.type === "text" ||
|
||||
lastBlock.type === "image" ||
|
||||
lastBlock.type === "tool_result")
|
||||
) {
|
||||
(lastBlock as Record<string, unknown>).cache_control = cacheControl;
|
||||
}
|
||||
} else if (typeof content === "string") {
|
||||
lastMessage.content = [
|
||||
{
|
||||
type: "text",
|
||||
text: content,
|
||||
cache_control: cacheControl,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
@@ -475,17 +515,11 @@ function buildAnthropicParams(
|
||||
isOAuthToken: boolean,
|
||||
options: AnthropicTransportOptions | undefined,
|
||||
) {
|
||||
const payloadPolicy = resolveAnthropicPayloadPolicy({
|
||||
provider: model.provider,
|
||||
api: model.api,
|
||||
baseUrl: model.baseUrl,
|
||||
cacheRetention: options?.cacheRetention,
|
||||
enableCacheControl: true,
|
||||
});
|
||||
const cacheControl = resolveCacheControl(model.baseUrl, options?.cacheRetention);
|
||||
const defaultMaxTokens = Math.min(model.maxTokens, 32_000);
|
||||
const params: Record<string, unknown> = {
|
||||
model: model.id,
|
||||
messages: convertAnthropicMessages(context.messages, model, isOAuthToken),
|
||||
messages: convertAnthropicMessages(context.messages, model, isOAuthToken, cacheControl),
|
||||
max_tokens: options?.maxTokens || defaultMaxTokens,
|
||||
stream: true,
|
||||
};
|
||||
@@ -494,12 +528,14 @@ function buildAnthropicParams(
|
||||
{
|
||||
type: "text",
|
||||
text: "You are Claude Code, Anthropic's official CLI for Claude.",
|
||||
...(cacheControl ? { cache_control: cacheControl } : {}),
|
||||
},
|
||||
...(context.systemPrompt
|
||||
? [
|
||||
{
|
||||
type: "text",
|
||||
text: sanitizeTransportPayloadText(context.systemPrompt),
|
||||
...(cacheControl ? { cache_control: cacheControl } : {}),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -509,6 +545,7 @@ function buildAnthropicParams(
|
||||
{
|
||||
type: "text",
|
||||
text: sanitizeTransportPayloadText(context.systemPrompt),
|
||||
...(cacheControl ? { cache_control: cacheControl } : {}),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -542,7 +579,6 @@ function buildAnthropicParams(
|
||||
params.tool_choice =
|
||||
typeof options.toolChoice === "string" ? { type: options.toolChoice } : options.toolChoice;
|
||||
}
|
||||
applyAnthropicPayloadPolicyToParams(params, payloadPolicy);
|
||||
return params;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,18 +23,16 @@ const { computeBackoffMock, sleepWithAbortMock } = vi.hoisted(() => ({
|
||||
sleepWithAbortMock: vi.fn(async (_ms: number, _abortSignal?: AbortSignal) => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("./pi-embedded-runner/run/attempt.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./pi-embedded-runner/run/attempt.js")>(
|
||||
"./pi-embedded-runner/run/attempt.js",
|
||||
);
|
||||
vi.mock("./pi-embedded-runner/run/attempt.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./pi-embedded-runner/run/attempt.js")>();
|
||||
return {
|
||||
...actual,
|
||||
runEmbeddedAttempt: (params: unknown) => runEmbeddedAttemptMock(params),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../infra/backoff.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../infra/backoff.js")>("../infra/backoff.js");
|
||||
vi.mock("../infra/backoff.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../infra/backoff.js")>();
|
||||
return {
|
||||
...actual,
|
||||
computeBackoff: (
|
||||
@@ -45,8 +43,8 @@ vi.mock("../infra/backoff.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./models-config.js", async () => {
|
||||
const mod = await vi.importActual<typeof import("./models-config.js")>("./models-config.js");
|
||||
vi.mock("./models-config.js", async (importOriginal) => {
|
||||
const mod = await importOriginal<typeof import("./models-config.js")>();
|
||||
return {
|
||||
...mod,
|
||||
ensureOpenClawModelsJson: vi.fn(async () => ({ wrote: false })),
|
||||
@@ -70,10 +68,8 @@ const installRunEmbeddedMocks = () => {
|
||||
resolveModelAsync: async (provider: string, modelId: string) =>
|
||||
createResolvedEmbeddedRunnerModel(provider, modelId),
|
||||
}));
|
||||
vi.doMock("../plugins/provider-runtime.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../plugins/provider-runtime.js")>(
|
||||
"../plugins/provider-runtime.js",
|
||||
);
|
||||
vi.doMock("../plugins/provider-runtime.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../plugins/provider-runtime.js")>();
|
||||
return {
|
||||
...actual,
|
||||
prepareProviderRuntimeAuth: vi.fn(async () => undefined),
|
||||
|
||||
@@ -181,9 +181,8 @@ function freshSession(name: string): string {
|
||||
describe("OpenAI WebSocket e2e", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("@mariozechner/pi-ai", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@mariozechner/pi-ai")>("@mariozechner/pi-ai");
|
||||
vi.doMock("@mariozechner/pi-ai", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@mariozechner/pi-ai")>();
|
||||
return {
|
||||
...actual,
|
||||
createAssistantMessageEventStream: actual.createAssistantMessageEventStream,
|
||||
|
||||
@@ -72,8 +72,8 @@ vi.mock("./pi-bundle-mcp-tools.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@mariozechner/pi-ai", async () => {
|
||||
const actual = await vi.importActual<typeof import("@mariozechner/pi-ai")>("@mariozechner/pi-ai");
|
||||
vi.mock("@mariozechner/pi-ai", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@mariozechner/pi-ai")>();
|
||||
|
||||
const buildToolUseMessage = (model: { api: string; provider: string; id: string }) => ({
|
||||
role: "assistant" as const,
|
||||
|
||||
@@ -20,8 +20,8 @@ const disposeSessionMcpRuntimeMock = vi.fn<(sessionId: string) => Promise<void>>
|
||||
});
|
||||
let refreshRuntimeAuthOnFirstPromptError = false;
|
||||
|
||||
vi.mock("@mariozechner/pi-ai", async () => {
|
||||
const actual = await vi.importActual<typeof import("@mariozechner/pi-ai")>("@mariozechner/pi-ai");
|
||||
vi.mock("@mariozechner/pi-ai", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@mariozechner/pi-ai")>();
|
||||
|
||||
const buildAssistantMessage = (model: { api: string; provider: string; id: string }) => ({
|
||||
role: "assistant" as const,
|
||||
@@ -101,10 +101,8 @@ const installRunEmbeddedMocks = () => {
|
||||
vi.doMock("./pi-bundle-mcp-tools.js", () => ({
|
||||
disposeSessionMcpRuntime: (sessionId: string) => disposeSessionMcpRuntimeMock(sessionId),
|
||||
}));
|
||||
vi.doMock("./pi-embedded-runner/model.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./pi-embedded-runner/model.js")>(
|
||||
"./pi-embedded-runner/model.js",
|
||||
);
|
||||
vi.doMock("./pi-embedded-runner/model.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./pi-embedded-runner/model.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveModelAsync: async (provider: string, modelId: string) =>
|
||||
@@ -121,17 +119,15 @@ const installRunEmbeddedMocks = () => {
|
||||
stopRuntimeAuthRefreshTimer: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
vi.doMock("../plugins/provider-runtime.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../plugins/provider-runtime.js")>(
|
||||
"../plugins/provider-runtime.js",
|
||||
);
|
||||
vi.doMock("../plugins/provider-runtime.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../plugins/provider-runtime.js")>();
|
||||
return {
|
||||
...actual,
|
||||
prepareProviderRuntimeAuth: vi.fn(async () => undefined),
|
||||
};
|
||||
});
|
||||
vi.doMock("./models-config.js", async () => {
|
||||
const mod = await vi.importActual<typeof import("./models-config.js")>("./models-config.js");
|
||||
vi.doMock("./models-config.js", async (importOriginal) => {
|
||||
const mod = await importOriginal<typeof import("./models-config.js")>();
|
||||
return {
|
||||
...mod,
|
||||
ensureOpenClawModelsJson: vi.fn(async () => ({ wrote: false })),
|
||||
|
||||
@@ -59,10 +59,8 @@ const installRunEmbeddedMocks = () => {
|
||||
vi.doMock("./pi-embedded-runner/run/attempt.js", () => ({
|
||||
runEmbeddedAttempt: (params: unknown) => runEmbeddedAttemptMock(params),
|
||||
}));
|
||||
vi.doMock("../plugins/provider-runtime.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../plugins/provider-runtime.js")>(
|
||||
"../plugins/provider-runtime.js",
|
||||
);
|
||||
vi.doMock("../plugins/provider-runtime.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../plugins/provider-runtime.js")>();
|
||||
return {
|
||||
...actual,
|
||||
prepareProviderRuntimeAuth: async (params: {
|
||||
@@ -94,8 +92,8 @@ const installRunEmbeddedMocks = () => {
|
||||
throw new Error("compact should not run in auth profile rotation tests");
|
||||
}),
|
||||
}));
|
||||
vi.doMock("./models-config.js", async () => {
|
||||
const mod = await vi.importActual<typeof import("./models-config.js")>("./models-config.js");
|
||||
vi.doMock("./models-config.js", async (importOriginal) => {
|
||||
const mod = await importOriginal<typeof import("./models-config.js")>();
|
||||
return {
|
||||
...mod,
|
||||
ensureOpenClawModelsJson: vi.fn(async () => ({ wrote: false })),
|
||||
|
||||
@@ -1 +1,41 @@
|
||||
export { applyAnthropicEphemeralCacheControlMarkers } from "../anthropic-payload-policy.js";
|
||||
export function applyAnthropicEphemeralCacheControlMarkers(
|
||||
payloadObj: Record<string, unknown>,
|
||||
): void {
|
||||
const messages = payloadObj.messages;
|
||||
if (!Array.isArray(messages)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const message of messages as Array<{ role?: string; content?: unknown }>) {
|
||||
if (message.role === "system" || message.role === "developer") {
|
||||
if (typeof message.content === "string") {
|
||||
message.content = [
|
||||
{ type: "text", text: message.content, cache_control: { type: "ephemeral" } },
|
||||
];
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(message.content) && message.content.length > 0) {
|
||||
const last = message.content[message.content.length - 1];
|
||||
if (last && typeof last === "object") {
|
||||
const record = last as Record<string, unknown>;
|
||||
if (record.type !== "thinking" && record.type !== "redacted_thinking") {
|
||||
record.cache_control = { type: "ephemeral" };
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role === "assistant" && Array.isArray(message.content)) {
|
||||
for (const block of message.content) {
|
||||
if (!block || typeof block !== "object") {
|
||||
continue;
|
||||
}
|
||||
const record = block as Record<string, unknown>;
|
||||
if (record.type === "thinking" || record.type === "redacted_thinking") {
|
||||
delete record.cache_control;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ let hookCacheCleared = false;
|
||||
const clearProviderRuntimeHookCacheMock = vi.fn<() => void>(() => {
|
||||
hookCacheCleared = true;
|
||||
});
|
||||
const resolveProviderRuntimePluginMock = vi.fn<(params: unknown) => unknown>(() =>
|
||||
hookCacheCleared ? { id: "openai", label: "OpenAI", auth: [] } : undefined,
|
||||
);
|
||||
const prepareProviderDynamicModelMock = vi.fn<(params: unknown) => Promise<void>>(async () => {});
|
||||
const runProviderDynamicModelMock = vi.fn<(params: unknown) => unknown>(() =>
|
||||
hookCacheCleared
|
||||
@@ -34,21 +37,24 @@ vi.mock("../pi-model-discovery.js", () => ({
|
||||
discoverModels: discoverModelsMock,
|
||||
}));
|
||||
|
||||
describe("resolveModelAsync startup retry", () => {
|
||||
const runtimeHooks = {
|
||||
vi.mock("../../plugins/provider-runtime.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../plugins/provider-runtime.js")>();
|
||||
return {
|
||||
...actual,
|
||||
applyProviderResolvedModelCompatWithPlugins: () => undefined,
|
||||
buildProviderUnknownModelHintWithPlugin: () => undefined,
|
||||
clearProviderRuntimeHookCache: clearProviderRuntimeHookCacheMock,
|
||||
normalizeProviderResolvedModelWithPlugin: () => undefined,
|
||||
normalizeProviderTransportWithPlugin: () => undefined,
|
||||
prepareProviderDynamicModel: (params: unknown) => prepareProviderDynamicModelMock(params),
|
||||
resolveProviderRuntimePlugin: (params: unknown) => resolveProviderRuntimePluginMock(params),
|
||||
runProviderDynamicModel: (params: unknown) => runProviderDynamicModelMock(params),
|
||||
applyProviderResolvedTransportWithPlugin: () => undefined,
|
||||
};
|
||||
});
|
||||
|
||||
describe("resolveModelAsync startup retry", () => {
|
||||
beforeEach(() => {
|
||||
hookCacheCleared = false;
|
||||
clearProviderRuntimeHookCacheMock.mockClear();
|
||||
resolveProviderRuntimePluginMock.mockClear();
|
||||
prepareProviderDynamicModelMock.mockClear();
|
||||
runProviderDynamicModelMock.mockClear();
|
||||
discoverAuthStorageMock.mockClear();
|
||||
@@ -65,7 +71,6 @@ describe("resolveModelAsync startup retry", () => {
|
||||
{},
|
||||
{
|
||||
retryTransientProviderRuntimeMiss: true,
|
||||
runtimeHooks,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -76,25 +81,19 @@ describe("resolveModelAsync startup retry", () => {
|
||||
api: "openai-codex-responses",
|
||||
});
|
||||
expect(clearProviderRuntimeHookCacheMock).toHaveBeenCalledTimes(1);
|
||||
expect(prepareProviderDynamicModelMock).toHaveBeenCalledTimes(2);
|
||||
expect(resolveProviderRuntimePluginMock).toHaveBeenCalledTimes(2);
|
||||
expect(runProviderDynamicModelMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not clear the hook cache during steady-state misses", async () => {
|
||||
const { resolveModelAsync } = await import("./model.js");
|
||||
|
||||
const result = await resolveModelAsync(
|
||||
"openai-codex",
|
||||
"gpt-5.4",
|
||||
"/tmp/agent",
|
||||
{},
|
||||
{ runtimeHooks },
|
||||
);
|
||||
const result = await resolveModelAsync("openai-codex", "gpt-5.4", "/tmp/agent", {});
|
||||
|
||||
expect(result.model).toBeUndefined();
|
||||
expect(result.error).toBe("Unknown model: openai-codex/gpt-5.4");
|
||||
expect(clearProviderRuntimeHookCacheMock).not.toHaveBeenCalled();
|
||||
expect(prepareProviderDynamicModelMock).toHaveBeenCalledTimes(1);
|
||||
expect(resolveProviderRuntimePluginMock).toHaveBeenCalledTimes(1);
|
||||
expect(runProviderDynamicModelMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1158,7 +1158,6 @@ describe("resolveModel", () => {
|
||||
runtimeHooks: {
|
||||
applyProviderResolvedModelCompatWithPlugins: () => undefined,
|
||||
buildProviderUnknownModelHintWithPlugin: () => undefined,
|
||||
clearProviderRuntimeHookCache: () => {},
|
||||
prepareProviderDynamicModel: async () => {},
|
||||
runProviderDynamicModel: () => undefined,
|
||||
applyProviderResolvedTransportWithPlugin: ({ provider, context }) =>
|
||||
|
||||
@@ -54,7 +54,6 @@ type ProviderRuntimeHooks = {
|
||||
buildProviderUnknownModelHintWithPlugin: (
|
||||
params: Parameters<typeof buildProviderUnknownModelHintWithPlugin>[0],
|
||||
) => string | undefined;
|
||||
clearProviderRuntimeHookCache: () => void;
|
||||
prepareProviderDynamicModel: (
|
||||
params: Parameters<typeof prepareProviderDynamicModel>[0],
|
||||
) => Promise<void>;
|
||||
@@ -71,7 +70,6 @@ const DEFAULT_PROVIDER_RUNTIME_HOOKS: ProviderRuntimeHooks = {
|
||||
applyProviderResolvedModelCompatWithPlugins,
|
||||
applyProviderResolvedTransportWithPlugin,
|
||||
buildProviderUnknownModelHintWithPlugin,
|
||||
clearProviderRuntimeHookCache,
|
||||
prepareProviderDynamicModel,
|
||||
runProviderDynamicModel,
|
||||
normalizeProviderResolvedModelWithPlugin,
|
||||
@@ -82,7 +80,6 @@ const STATIC_PROVIDER_RUNTIME_HOOKS: ProviderRuntimeHooks = {
|
||||
applyProviderResolvedModelCompatWithPlugins: () => undefined,
|
||||
applyProviderResolvedTransportWithPlugin: () => undefined,
|
||||
buildProviderUnknownModelHintWithPlugin: () => undefined,
|
||||
clearProviderRuntimeHookCache: () => {},
|
||||
prepareProviderDynamicModel: async () => {},
|
||||
runProviderDynamicModel: () => undefined,
|
||||
normalizeProviderResolvedModelWithPlugin: () => undefined,
|
||||
@@ -723,7 +720,7 @@ export async function resolveModelAsync(
|
||||
const providerConfig = resolveConfiguredProviderConfig(cfg, provider);
|
||||
const resolveDynamicAttempt = async (attemptOptions?: { clearHookCache?: boolean }) => {
|
||||
if (attemptOptions?.clearHookCache) {
|
||||
runtimeHooks.clearProviderRuntimeHookCache();
|
||||
clearProviderRuntimeHookCache();
|
||||
}
|
||||
await runtimeHooks.prepareProviderDynamicModel({
|
||||
provider,
|
||||
|
||||
@@ -14,10 +14,8 @@ import { CRITICAL_THRESHOLD, GLOBAL_CIRCUIT_BREAKER_THRESHOLD } from "./tool-loo
|
||||
import type { AnyAgentTool } from "./tools/common.js";
|
||||
import { callGatewayTool } from "./tools/gateway.js";
|
||||
|
||||
vi.mock("../plugins/hook-runner-global.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../plugins/hook-runner-global.js")>(
|
||||
"../plugins/hook-runner-global.js",
|
||||
);
|
||||
vi.mock("../plugins/hook-runner-global.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../plugins/hook-runner-global.js")>();
|
||||
return {
|
||||
...actual,
|
||||
getGlobalHookRunner: vi.fn(),
|
||||
|
||||
@@ -599,210 +599,4 @@ describe("provider attribution", () => {
|
||||
isKnownNativeEndpoint: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves a provider capability matrix for representative native and proxied routes", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "native OpenAI responses",
|
||||
input: {
|
||||
provider: "openai",
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
capability: "llm" as const,
|
||||
transport: "stream" as const,
|
||||
},
|
||||
expected: {
|
||||
knownProviderFamily: "openai-family",
|
||||
endpointClass: "openai-public",
|
||||
isKnownNativeEndpoint: true,
|
||||
allowsOpenAIServiceTier: true,
|
||||
supportsOpenAIReasoningCompatPayload: true,
|
||||
allowsResponsesStore: true,
|
||||
supportsResponsesStoreField: true,
|
||||
shouldStripResponsesPromptCache: false,
|
||||
allowsAnthropicServiceTier: false,
|
||||
supportsNativeStreamingUsageCompat: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "proxied OpenAI responses",
|
||||
input: {
|
||||
provider: "openai",
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
capability: "llm" as const,
|
||||
transport: "stream" as const,
|
||||
},
|
||||
expected: {
|
||||
knownProviderFamily: "openai-family",
|
||||
endpointClass: "custom",
|
||||
isKnownNativeEndpoint: false,
|
||||
allowsOpenAIServiceTier: false,
|
||||
supportsOpenAIReasoningCompatPayload: false,
|
||||
allowsResponsesStore: false,
|
||||
supportsResponsesStoreField: true,
|
||||
shouldStripResponsesPromptCache: true,
|
||||
allowsAnthropicServiceTier: false,
|
||||
supportsNativeStreamingUsageCompat: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "direct Anthropic messages",
|
||||
input: {
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://api.anthropic.com/v1",
|
||||
capability: "llm" as const,
|
||||
transport: "stream" as const,
|
||||
},
|
||||
expected: {
|
||||
knownProviderFamily: "anthropic",
|
||||
endpointClass: "anthropic-public",
|
||||
isKnownNativeEndpoint: true,
|
||||
allowsOpenAIServiceTier: false,
|
||||
supportsOpenAIReasoningCompatPayload: false,
|
||||
allowsResponsesStore: false,
|
||||
supportsResponsesStoreField: false,
|
||||
shouldStripResponsesPromptCache: false,
|
||||
allowsAnthropicServiceTier: true,
|
||||
supportsNativeStreamingUsageCompat: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "proxied custom anthropic api",
|
||||
input: {
|
||||
provider: "custom-anthropic",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://proxy.example.com/anthropic",
|
||||
capability: "llm" as const,
|
||||
transport: "stream" as const,
|
||||
},
|
||||
expected: {
|
||||
endpointClass: "custom",
|
||||
isKnownNativeEndpoint: false,
|
||||
allowsAnthropicServiceTier: false,
|
||||
supportsOpenAIReasoningCompatPayload: false,
|
||||
supportsResponsesStoreField: false,
|
||||
supportsNativeStreamingUsageCompat: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "native OpenRouter responses",
|
||||
input: {
|
||||
provider: "openrouter",
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
capability: "llm" as const,
|
||||
transport: "stream" as const,
|
||||
},
|
||||
expected: {
|
||||
knownProviderFamily: "openrouter",
|
||||
endpointClass: "openrouter",
|
||||
isKnownNativeEndpoint: true,
|
||||
allowsOpenAIServiceTier: false,
|
||||
supportsOpenAIReasoningCompatPayload: false,
|
||||
allowsResponsesStore: false,
|
||||
supportsResponsesStoreField: true,
|
||||
shouldStripResponsesPromptCache: true,
|
||||
allowsAnthropicServiceTier: false,
|
||||
supportsNativeStreamingUsageCompat: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "native Moonshot completions",
|
||||
input: {
|
||||
provider: "moonshot",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.moonshot.ai/v1",
|
||||
capability: "llm" as const,
|
||||
transport: "stream" as const,
|
||||
},
|
||||
expected: {
|
||||
knownProviderFamily: "moonshot",
|
||||
endpointClass: "moonshot-native",
|
||||
isKnownNativeEndpoint: true,
|
||||
allowsOpenAIServiceTier: false,
|
||||
supportsOpenAIReasoningCompatPayload: false,
|
||||
allowsResponsesStore: false,
|
||||
supportsResponsesStoreField: false,
|
||||
shouldStripResponsesPromptCache: false,
|
||||
allowsAnthropicServiceTier: false,
|
||||
supportsNativeStreamingUsageCompat: true,
|
||||
compatibilityFamily: "moonshot",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "native ModelStudio completions",
|
||||
input: {
|
||||
provider: "modelstudio",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
|
||||
capability: "llm" as const,
|
||||
transport: "stream" as const,
|
||||
},
|
||||
expected: {
|
||||
knownProviderFamily: "modelstudio",
|
||||
endpointClass: "modelstudio-native",
|
||||
isKnownNativeEndpoint: true,
|
||||
allowsOpenAIServiceTier: false,
|
||||
supportsOpenAIReasoningCompatPayload: false,
|
||||
allowsResponsesStore: false,
|
||||
supportsResponsesStoreField: false,
|
||||
shouldStripResponsesPromptCache: false,
|
||||
allowsAnthropicServiceTier: false,
|
||||
supportsNativeStreamingUsageCompat: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "native Google Gemini api",
|
||||
input: {
|
||||
provider: "google",
|
||||
api: "google-generative-ai",
|
||||
baseUrl: "https://generativelanguage.googleapis.com",
|
||||
capability: "llm" as const,
|
||||
transport: "stream" as const,
|
||||
},
|
||||
expected: {
|
||||
knownProviderFamily: "google",
|
||||
endpointClass: "google-generative-ai",
|
||||
isKnownNativeEndpoint: true,
|
||||
allowsOpenAIServiceTier: false,
|
||||
supportsOpenAIReasoningCompatPayload: false,
|
||||
allowsResponsesStore: false,
|
||||
supportsResponsesStoreField: false,
|
||||
shouldStripResponsesPromptCache: false,
|
||||
allowsAnthropicServiceTier: false,
|
||||
supportsNativeStreamingUsageCompat: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "native GitHub Copilot responses",
|
||||
input: {
|
||||
provider: "github-copilot",
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://api.individual.githubcopilot.com",
|
||||
capability: "llm" as const,
|
||||
transport: "stream" as const,
|
||||
},
|
||||
expected: {
|
||||
knownProviderFamily: "github-copilot",
|
||||
endpointClass: "github-copilot-native",
|
||||
isKnownNativeEndpoint: true,
|
||||
allowsOpenAIServiceTier: false,
|
||||
supportsOpenAIReasoningCompatPayload: false,
|
||||
allowsResponsesStore: false,
|
||||
supportsResponsesStoreField: true,
|
||||
shouldStripResponsesPromptCache: true,
|
||||
allowsAnthropicServiceTier: false,
|
||||
supportsNativeStreamingUsageCompat: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
expect(resolveProviderRequestCapabilities(testCase.input), testCase.name).toMatchObject(
|
||||
testCase.expected,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,8 +12,8 @@ type SpawnCall = {
|
||||
|
||||
const spawnCalls: SpawnCall[] = [];
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
vi.mock("node:child_process", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:child_process")>();
|
||||
return {
|
||||
...actual,
|
||||
spawn: (command: string, args: string[]) => {
|
||||
@@ -42,8 +42,8 @@ vi.mock("node:child_process", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./skills.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./skills.js")>("./skills.js");
|
||||
vi.mock("./skills.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./skills.js")>();
|
||||
return {
|
||||
...actual,
|
||||
syncSkillsToWorkspace: vi.fn(async () => undefined),
|
||||
|
||||
@@ -24,8 +24,8 @@ vi.mock("../infra/agent-events.js", () => ({
|
||||
onAgentEvent: vi.fn((_handler: unknown) => noop),
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: loadConfigMock,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import "./subagent-registry.mocks.shared.js";
|
||||
|
||||
vi.mock("../config/config.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: vi.fn(() => ({
|
||||
|
||||
@@ -15,8 +15,8 @@ const childProcessMocks = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("../agents/sandbox.js", () => sandboxMocks);
|
||||
vi.mock("node:child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
vi.mock("node:child_process", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:child_process")>();
|
||||
return {
|
||||
...actual,
|
||||
spawn: childProcessMocks.spawn,
|
||||
|
||||
@@ -22,15 +22,15 @@ let stageSandboxMedia: typeof import("./reply/stage-sandbox-media.js").stageSand
|
||||
async function loadFreshStageSandboxMediaModuleForTest() {
|
||||
vi.resetModules();
|
||||
vi.doMock(sandboxModuleId, () => sandboxMocks);
|
||||
vi.doMock("node:child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
vi.doMock("node:child_process", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:child_process")>();
|
||||
return {
|
||||
...actual,
|
||||
spawn: childProcessMocks.spawn,
|
||||
};
|
||||
});
|
||||
vi.doMock(fsSafeModuleId, async () => {
|
||||
const actual = await vi.importActual<typeof import("../infra/fs-safe.js")>(fsSafeModuleId);
|
||||
vi.doMock(fsSafeModuleId, async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../infra/fs-safe.js")>();
|
||||
return {
|
||||
...actual,
|
||||
copyFileWithinRoot: vi.fn(async ({ sourcePath, rootDir, relativePath, maxBytes }) => {
|
||||
|
||||
@@ -100,10 +100,9 @@ vi.mock("../../config/sessions.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../infra/outbound/session-binding-service.js", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("../../infra/outbound/session-binding-service.js")
|
||||
>("../../infra/outbound/session-binding-service.js");
|
||||
vi.mock("../../infra/outbound/session-binding-service.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../../infra/outbound/session-binding-service.js")>();
|
||||
const patched = { ...actual } as typeof actual & {
|
||||
getSessionBindingService: () => ReturnType<typeof createAcpCommandSessionBindingService>;
|
||||
};
|
||||
|
||||
@@ -12,8 +12,8 @@ const hookRunnerMocks = vi.hoisted(() => ({
|
||||
runBeforeReset: vi.fn<HookRunner["runBeforeReset"]>(),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
|
||||
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs/promises")>();
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
|
||||
@@ -24,10 +24,8 @@ async function loadToolsHarness(options?: {
|
||||
};
|
||||
}) {
|
||||
vi.resetModules();
|
||||
vi.doMock("../../agents/agent-scope.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../agents/agent-scope.js")>(
|
||||
"../../agents/agent-scope.js",
|
||||
);
|
||||
vi.doMock("../../agents/agent-scope.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../agents/agent-scope.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveSessionAgentId: () => "main",
|
||||
|
||||
@@ -7,9 +7,8 @@ const resolveDefaultModelForAgent = vi.hoisted(() => vi.fn());
|
||||
const resolveModelAsync = vi.hoisted(() => vi.fn());
|
||||
const prepareModelForSimpleCompletion = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@mariozechner/pi-ai", async () => {
|
||||
const original =
|
||||
await vi.importActual<typeof import("@mariozechner/pi-ai")>("@mariozechner/pi-ai");
|
||||
vi.mock("@mariozechner/pi-ai", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("@mariozechner/pi-ai")>();
|
||||
return {
|
||||
...original,
|
||||
completeSimple,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import { estimateMessagesTokens } from "../../agents/compaction.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { SessionEntry } from "../../config/sessions/types.js";
|
||||
import { resolveFreshSessionTotalTokens, type SessionEntry } from "../../config/sessions/types.js";
|
||||
import { readSessionMessages } from "../../gateway/session-utils.fs.js";
|
||||
|
||||
/**
|
||||
* Default max parent token count beyond which thread/session parent forking is skipped.
|
||||
@@ -16,6 +19,48 @@ export function resolveParentForkMaxTokens(cfg: OpenClawConfig): number {
|
||||
return DEFAULT_PARENT_FORK_MAX_TOKENS;
|
||||
}
|
||||
|
||||
function resolvePositiveTokenCount(value: number | undefined): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0
|
||||
? Math.floor(value)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the best available token estimate for deciding whether parent-session
|
||||
* forking is safe. Prefer fresh persisted totals, then estimate from the
|
||||
* transcript when cached totals are stale or missing.
|
||||
*/
|
||||
export function resolveParentForkTokenCount(params: {
|
||||
parentEntry: SessionEntry;
|
||||
storePath: string;
|
||||
}): number | undefined {
|
||||
const freshPersistedTokens = resolveFreshSessionTotalTokens(params.parentEntry);
|
||||
if (typeof freshPersistedTokens === "number") {
|
||||
return freshPersistedTokens;
|
||||
}
|
||||
|
||||
try {
|
||||
const transcriptMessages = readSessionMessages(
|
||||
params.parentEntry.sessionId,
|
||||
params.storePath,
|
||||
params.parentEntry.sessionFile,
|
||||
) as AgentMessage[];
|
||||
if (transcriptMessages.length > 0) {
|
||||
const estimatedTokens = estimateMessagesTokens(transcriptMessages);
|
||||
const transcriptTokens = resolvePositiveTokenCount(
|
||||
Number.isFinite(estimatedTokens) ? Math.ceil(estimatedTokens) : undefined,
|
||||
);
|
||||
if (typeof transcriptTokens === "number") {
|
||||
return transcriptTokens;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall back to cached totals/unknown tokens when the transcript cannot be read.
|
||||
}
|
||||
|
||||
return resolvePositiveTokenCount(params.parentEntry.totalTokens);
|
||||
}
|
||||
|
||||
export async function forkSessionFromParent(params: {
|
||||
parentEntry: SessionEntry;
|
||||
agentId: string;
|
||||
|
||||
@@ -4,11 +4,9 @@ import { importFreshModule } from "../../../test/helpers/import-fresh.ts";
|
||||
describe("reply session module imports", () => {
|
||||
it("does not load archive runtime on module import", async () => {
|
||||
const archiveRuntimeLoads = vi.fn();
|
||||
vi.doMock("../../gateway/session-archive.runtime.js", async () => {
|
||||
vi.doMock("../../gateway/session-archive.runtime.js", async (importOriginal) => {
|
||||
archiveRuntimeLoads();
|
||||
return await vi.importActual<typeof import("../../gateway/session-archive.runtime.js")>(
|
||||
"../../gateway/session-archive.runtime.js",
|
||||
);
|
||||
return await importOriginal<typeof import("../../gateway/session-archive.runtime.js")>();
|
||||
});
|
||||
|
||||
await importFreshModule<typeof import("./session.js")>(
|
||||
|
||||
@@ -442,6 +442,79 @@ describe("initSessionState thread forking", () => {
|
||||
expect(result.sessionEntry.sessionFile).not.toBe(parentSessionFile);
|
||||
});
|
||||
|
||||
it("skips fork when parent transcript estimate exceeds threshold and cached total is stale", async () => {
|
||||
const root = await makeCaseDir("openclaw-thread-session-overflow-transcript-fallback-");
|
||||
const sessionsDir = path.join(root, "sessions");
|
||||
await fs.mkdir(sessionsDir);
|
||||
|
||||
const parentSessionId = "parent-overflow-transcript";
|
||||
const parentSessionFile = path.join(sessionsDir, "parent.jsonl");
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: parentSessionId,
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
];
|
||||
for (let index = 0; index < 40; index += 1) {
|
||||
const userId = `u${index}`;
|
||||
const assistantId = `a${index}`;
|
||||
const body = `turn-${index} ${"x".repeat(12_000)}`;
|
||||
lines.push(
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
id: userId,
|
||||
parentId: index === 0 ? null : `a${index - 1}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
message: { role: "user", content: body },
|
||||
}),
|
||||
);
|
||||
lines.push(
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
id: assistantId,
|
||||
parentId: userId,
|
||||
timestamp: new Date().toISOString(),
|
||||
message: { role: "assistant", content: body },
|
||||
}),
|
||||
);
|
||||
}
|
||||
await fs.writeFile(parentSessionFile, `${lines.join("\n")}\n`, "utf-8");
|
||||
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const parentSessionKey = "agent:main:slack:channel:c1";
|
||||
await writeSessionStoreFast(storePath, {
|
||||
[parentSessionKey]: {
|
||||
sessionId: parentSessionId,
|
||||
sessionFile: parentSessionFile,
|
||||
updatedAt: Date.now(),
|
||||
totalTokens: 1,
|
||||
totalTokensFresh: false,
|
||||
},
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
session: { store: storePath },
|
||||
} as OpenClawConfig;
|
||||
|
||||
const threadSessionKey = "agent:main:slack:channel:c1:thread:457";
|
||||
const result = await initSessionState({
|
||||
ctx: {
|
||||
Body: "Thread reply",
|
||||
SessionKey: threadSessionKey,
|
||||
ParentSessionKey: parentSessionKey,
|
||||
},
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(result.sessionEntry.forkedFromParent).toBe(true);
|
||||
expect(result.sessionEntry.sessionId).not.toBe(parentSessionId);
|
||||
expect(result.sessionEntry.sessionFile).not.toBe(parentSessionFile);
|
||||
});
|
||||
|
||||
it("respects session.parentForkMaxTokens override", async () => {
|
||||
const root = await makeCaseDir("openclaw-thread-session-overflow-override-");
|
||||
const sessionsDir = path.join(root, "sessions");
|
||||
|
||||
@@ -47,7 +47,11 @@ import {
|
||||
resolveLastChannelRaw,
|
||||
resolveLastToRaw,
|
||||
} from "./session-delivery.js";
|
||||
import { forkSessionFromParent, resolveParentForkMaxTokens } from "./session-fork.js";
|
||||
import {
|
||||
forkSessionFromParent,
|
||||
resolveParentForkMaxTokens,
|
||||
resolveParentForkTokenCount,
|
||||
} from "./session-fork.js";
|
||||
import { buildSessionEndHookPayload, buildSessionStartHookPayload } from "./session-hooks.js";
|
||||
|
||||
const log = createSubsystemLogger("session-init");
|
||||
@@ -597,8 +601,19 @@ export async function initSessionState(params: {
|
||||
sessionStore[parentSessionKey] &&
|
||||
!alreadyForked
|
||||
) {
|
||||
const parentTokens = sessionStore[parentSessionKey].totalTokens ?? 0;
|
||||
if (parentForkMaxTokens > 0 && parentTokens > parentForkMaxTokens) {
|
||||
const parentEntry = sessionStore[parentSessionKey];
|
||||
const parentTokens =
|
||||
parentForkMaxTokens > 0
|
||||
? resolveParentForkTokenCount({
|
||||
parentEntry,
|
||||
storePath,
|
||||
})
|
||||
: undefined;
|
||||
if (
|
||||
parentForkMaxTokens > 0 &&
|
||||
typeof parentTokens === "number" &&
|
||||
parentTokens > parentForkMaxTokens
|
||||
) {
|
||||
// Parent context is too large — forking would create a thread session
|
||||
// that immediately overflows the model's context window. Start fresh
|
||||
// instead and mark as forked to prevent re-attempts. See #26905.
|
||||
@@ -610,10 +625,10 @@ export async function initSessionState(params: {
|
||||
} else {
|
||||
log.warn(
|
||||
`forking from parent session: parentKey=${parentSessionKey} → sessionKey=${sessionKey} ` +
|
||||
`parentTokens=${parentTokens}`,
|
||||
`parentTokens=${parentTokens ?? "unknown"}`,
|
||||
);
|
||||
const forked = await forkSessionFromParent({
|
||||
parentEntry: sessionStore[parentSessionKey],
|
||||
parentEntry,
|
||||
agentId,
|
||||
sessionsDir: path.dirname(storePath),
|
||||
});
|
||||
|
||||
@@ -117,7 +117,7 @@ export type ChannelAccountState =
|
||||
|
||||
export type ChannelHeartbeatDeps = {
|
||||
webAuthExists?: () => Promise<boolean>;
|
||||
hasActiveWebListener?: (accountId?: string) => boolean;
|
||||
hasActiveWebListener?: () => boolean;
|
||||
};
|
||||
|
||||
/** User-facing metadata used in docs, pickers, and setup surfaces. */
|
||||
|
||||
@@ -11,9 +11,8 @@ vi.mock("../../runtime.js", () => ({
|
||||
defaultRuntime: runtime,
|
||||
}));
|
||||
|
||||
vi.mock("../../terminal/theme.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../../terminal/theme.js")>("../../terminal/theme.js");
|
||||
vi.mock("../../terminal/theme.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../terminal/theme.js")>();
|
||||
return {
|
||||
...actual,
|
||||
colorize: (_rich: boolean, _theme: unknown, text: string) => text,
|
||||
|
||||
@@ -32,8 +32,8 @@ vi.mock("../cli-utils.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../runtime.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../../runtime.js")>("../../runtime.js")),
|
||||
vi.mock("../../runtime.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../runtime.js")>()),
|
||||
defaultRuntime: mocks.defaultRuntime,
|
||||
}));
|
||||
|
||||
|
||||
@@ -77,8 +77,8 @@ vi.mock("../gateway/call.js", () => ({
|
||||
randomIdempotencyKey: () => randomIdempotencyKey(),
|
||||
}));
|
||||
|
||||
vi.mock("../runtime.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../runtime.js")>("../runtime.js")),
|
||||
vi.mock("../runtime.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../runtime.js")>()),
|
||||
defaultRuntime: mocks.defaultRuntime,
|
||||
}));
|
||||
|
||||
|
||||
@@ -2,10 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { bundledPluginRootAt, repoInstallSpec } from "../../test/helpers/bundled-plugin-paths.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { ConfigFileSnapshot } from "../config/types.openclaw.js";
|
||||
import {
|
||||
resolvePluginInstallRequestContext,
|
||||
type PluginInstallRequestContext,
|
||||
} from "./plugin-install-config-policy.js";
|
||||
import { resolvePluginInstallRequestContext } from "./plugin-install-config-policy.js";
|
||||
import { loadConfigForInstall } from "./plugins-install-command.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
@@ -51,12 +48,13 @@ function makeSnapshot(overrides: Partial<ConfigFileSnapshot> = {}): ConfigFileSn
|
||||
}
|
||||
|
||||
describe("loadConfigForInstall", () => {
|
||||
const matrixNpmRequest = {
|
||||
rawSpec: "@openclaw/matrix",
|
||||
normalizedSpec: "@openclaw/matrix",
|
||||
bundledPluginId: "matrix",
|
||||
allowInvalidConfigRecovery: true,
|
||||
} satisfies PluginInstallRequestContext;
|
||||
const matrixNpmRequest = (() => {
|
||||
const resolved = resolvePluginInstallRequestContext({ rawSpec: "@openclaw/matrix" });
|
||||
if (!resolved.ok) {
|
||||
throw new Error(resolved.error);
|
||||
}
|
||||
return resolved.request;
|
||||
})();
|
||||
|
||||
beforeEach(() => {
|
||||
loadConfigMock.mockReset();
|
||||
|
||||
@@ -20,8 +20,8 @@ const runtime = vi.hoisted<RuntimeEnv>(() => ({
|
||||
exit: vi.fn<(code: number) => void>(),
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: loadConfigMock,
|
||||
|
||||
@@ -13,8 +13,8 @@ vi.mock("./gateway-rpc.js", () => ({
|
||||
callGatewayFromCli,
|
||||
}));
|
||||
|
||||
vi.mock("../runtime.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../runtime.js")>("../runtime.js")),
|
||||
vi.mock("../runtime.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../runtime.js")>()),
|
||||
defaultRuntime,
|
||||
writeRuntimeJson: (runtime: { log: (...args: unknown[]) => void }, value: unknown, space = 2) =>
|
||||
runtime.log(JSON.stringify(value, null, space > 0 ? space : undefined)),
|
||||
|
||||
@@ -11,8 +11,8 @@ const wizardMocks = vi.hoisted(() => ({
|
||||
createClackPrompter: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../config/config.js")>("../config/config.js")),
|
||||
vi.mock("../config/config.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../config/config.js")>()),
|
||||
readConfigFileSnapshot: readConfigFileSnapshotMock,
|
||||
writeConfigFile: writeConfigFileMock,
|
||||
replaceConfigFile: replaceConfigFileMock,
|
||||
|
||||
@@ -9,10 +9,8 @@ import {
|
||||
} from "./agents.bind.test-support.js";
|
||||
import { baseConfigSnapshot } from "./test-runtime-config-helpers.js";
|
||||
|
||||
vi.mock("../channels/plugins/index.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../channels/plugins/index.js")>(
|
||||
"../channels/plugins/index.js",
|
||||
);
|
||||
vi.mock("../channels/plugins/index.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../channels/plugins/index.js")>();
|
||||
const knownChannels = new Map([
|
||||
[
|
||||
"discord",
|
||||
|
||||
@@ -15,8 +15,8 @@ const configMocks = vi.hoisted(() => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../config/config.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../config/config.js")>("../config/config.js")),
|
||||
vi.mock("../config/config.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../config/config.js")>()),
|
||||
readConfigFileSnapshot: configMocks.readConfigFileSnapshot,
|
||||
writeConfigFile: configMocks.writeConfigFile,
|
||||
replaceConfigFile: configMocks.replaceConfigFile,
|
||||
|
||||
@@ -34,10 +34,9 @@ vi.mock("./openai-codex-oauth.js", () => ({
|
||||
}));
|
||||
|
||||
const resolvePluginProviders = vi.hoisted(() => vi.fn<() => ProviderPlugin[]>(() => []));
|
||||
vi.mock("../plugins/provider-auth-choice.runtime.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../plugins/provider-auth-choice.runtime.js")>(
|
||||
"../plugins/provider-auth-choice.runtime.js",
|
||||
);
|
||||
vi.mock("../plugins/provider-auth-choice.runtime.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../plugins/provider-auth-choice.runtime.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolvePluginProviders,
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
bundledPluginRootAt,
|
||||
} from "../../../test/helpers/bundled-plugin-paths.js";
|
||||
|
||||
vi.mock("node:fs", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs")>();
|
||||
const existsSync = vi.fn();
|
||||
return {
|
||||
...actual,
|
||||
@@ -30,10 +30,8 @@ vi.mock("../../config/plugin-auto-enable.js", () => ({
|
||||
|
||||
const resolveBundledPluginSources = vi.fn();
|
||||
const getChannelPluginCatalogEntry = vi.fn();
|
||||
vi.mock("../../channels/plugins/catalog.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../channels/plugins/catalog.js")>(
|
||||
"../../channels/plugins/catalog.js",
|
||||
);
|
||||
vi.mock("../../channels/plugins/catalog.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../channels/plugins/catalog.js")>();
|
||||
return {
|
||||
...actual,
|
||||
getChannelPluginCatalogEntry: (...args: unknown[]) => getChannelPluginCatalogEntry(...args),
|
||||
|
||||
@@ -13,10 +13,8 @@ const authMocks = vi.hoisted(() => ({
|
||||
loadAuthProfileStore: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../agents/auth-profiles.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../agents/auth-profiles.js")>(
|
||||
"../agents/auth-profiles.js",
|
||||
);
|
||||
vi.mock("../agents/auth-profiles.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../agents/auth-profiles.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadAuthProfileStore: authMocks.loadAuthProfileStore,
|
||||
|
||||
@@ -27,9 +27,8 @@ vi.mock("../../channels/plugins/index.js", () => ({
|
||||
getChannelPlugin: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../config/config.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../../config/config.js")>("../../config/config.js");
|
||||
vi.mock("../../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
readConfigFileSnapshot: mocks.readConfigFileSnapshot,
|
||||
|
||||
@@ -50,9 +50,8 @@ vi.mock("./daemon-runtime.js", () => ({
|
||||
GATEWAY_DAEMON_RUNTIME_OPTIONS: [{ value: "node", label: "Node" }],
|
||||
}));
|
||||
|
||||
vi.mock("../daemon/service.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../daemon/service.js")>("../daemon/service.js");
|
||||
vi.mock("../daemon/service.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../daemon/service.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveGatewayService: vi.fn(() => ({
|
||||
|
||||
@@ -1430,40 +1430,6 @@ describe("doctor config flow", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("warns clearly about legacy hooks.internal.handlers and requires manual migration", async () => {
|
||||
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
|
||||
try {
|
||||
await runDoctorConfigWithInput({
|
||||
config: {
|
||||
hooks: {
|
||||
internal: {
|
||||
handlers: [{ event: "command:new", module: "hooks/legacy-handler.js" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
run: loadAndMaybeMigrateDoctorConfig,
|
||||
});
|
||||
|
||||
expect(
|
||||
noteSpy.mock.calls.some(
|
||||
([message, title]) =>
|
||||
title === "Legacy config keys detected" &&
|
||||
String(message).includes("hooks.internal.handlers:") &&
|
||||
String(message).includes("HOOK.md + handler.js"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
noteSpy.mock.calls.some(
|
||||
([message, title]) =>
|
||||
title === "Legacy config keys detected" &&
|
||||
String(message).includes("does not rewrite this shape automatically"),
|
||||
),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
noteSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("warns clearly about legacy thread binding ttlHours config and points to doctor --fix", async () => {
|
||||
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
|
||||
try {
|
||||
|
||||
@@ -31,12 +31,6 @@ import {
|
||||
} from "./doctor/shared/mutable-allowlist.js";
|
||||
import { collectDoctorPreviewWarnings } from "./doctor/shared/preview-warnings.js";
|
||||
|
||||
function hasLegacyInternalHookHandlers(raw: unknown): boolean {
|
||||
const handlers = (raw as { hooks?: { internal?: { handlers?: unknown } } })?.hooks?.internal
|
||||
?.handlers;
|
||||
return Array.isArray(handlers) && handlers.length > 0;
|
||||
}
|
||||
|
||||
export async function loadAndMaybeMigrateDoctorConfig(params: {
|
||||
options: DoctorOptions;
|
||||
confirm: (p: { message: string; initialValue: boolean }) => Promise<boolean>;
|
||||
@@ -64,16 +58,6 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
|
||||
if (legacyStep.changeLines.length > 0) {
|
||||
note(legacyStep.changeLines.join("\n"), "Doctor changes");
|
||||
}
|
||||
if (hasLegacyInternalHookHandlers(snapshot.parsed)) {
|
||||
note(
|
||||
[
|
||||
"- hooks.internal.handlers: legacy inline hook modules are no longer part of the public config surface.",
|
||||
"- Migrate each entry to a managed or workspace hook directory with HOOK.md + handler.js, then enable it through hooks.internal.entries.<hookKey> as needed.",
|
||||
"- openclaw doctor --fix does not rewrite this shape automatically.",
|
||||
].join("\n"),
|
||||
"Legacy config keys detected",
|
||||
);
|
||||
}
|
||||
|
||||
const normalized = normalizeCompatibilityConfigValues(candidate);
|
||||
if (normalized.changes.length > 0) {
|
||||
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
import { withTempDir } from "../test-helpers/temp-dir.js";
|
||||
import { flowsCancelCommand, flowsListCommand, flowsShowCommand } from "./flows.js";
|
||||
|
||||
vi.mock("../config/config.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: vi.fn(() => ({})),
|
||||
|
||||
@@ -146,9 +146,8 @@ vi.mock("../infra/tailnet.js", () => ({
|
||||
pickPrimaryTailnetIPv4: mocks.pickPrimaryTailnetIPv4,
|
||||
}));
|
||||
|
||||
vi.mock("../infra/ssh-tunnel.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../infra/ssh-tunnel.js")>("../infra/ssh-tunnel.js");
|
||||
vi.mock("../infra/ssh-tunnel.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../infra/ssh-tunnel.js")>();
|
||||
return {
|
||||
...actual,
|
||||
startSshPortForward: mocks.startSshPortForward,
|
||||
|
||||
@@ -25,9 +25,8 @@ type TelegramHealthAccount = {
|
||||
};
|
||||
|
||||
async function loadFreshHealthModulesForTest() {
|
||||
vi.doMock("../config/config.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
|
||||
vi.doMock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => testConfig,
|
||||
|
||||
@@ -23,8 +23,8 @@ const runMessageAction = vi.hoisted(() =>
|
||||
})),
|
||||
);
|
||||
|
||||
vi.mock("../config/config.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => testConfig,
|
||||
|
||||
@@ -12,8 +12,8 @@ import { captureEnv } from "../test-utils/env.js";
|
||||
|
||||
let testConfig: Record<string, unknown> = {};
|
||||
const applyPluginAutoEnable = vi.hoisted(() => vi.fn(({ config }) => ({ config, changes: [] })));
|
||||
vi.mock("../config/config.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => testConfig,
|
||||
|
||||
@@ -5,8 +5,8 @@ const mocks = vi.hoisted(() => ({
|
||||
writtenConfig: undefined as Record<string, unknown> | undefined,
|
||||
}));
|
||||
|
||||
vi.mock("./models/shared.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./models/shared.js")>("./models/shared.js");
|
||||
vi.mock("./models/shared.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./models/shared.js")>();
|
||||
return {
|
||||
...actual,
|
||||
updateConfig: async (mutator: (cfg: Record<string, unknown>) => Record<string, unknown>) => {
|
||||
|
||||
@@ -22,10 +22,8 @@ vi.mock("../../secrets/resolve.js", () => ({
|
||||
resolveSecretRefString: resolveSecretRefStringMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/auth-profiles.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../agents/auth-profiles.js")>(
|
||||
"../../agents/auth-profiles.js",
|
||||
);
|
||||
vi.mock("../../agents/auth-profiles.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../agents/auth-profiles.js")>();
|
||||
return {
|
||||
...actual,
|
||||
ensureAuthProfileStore: () => mockStore,
|
||||
|
||||
@@ -575,10 +575,8 @@ vi.mock("../channel-web.js", () => ({
|
||||
loginWeb: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("../channels/plugins/catalog.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../channels/plugins/catalog.js")>(
|
||||
"../channels/plugins/catalog.js",
|
||||
);
|
||||
vi.mock("../channels/plugins/catalog.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../channels/plugins/catalog.js")>();
|
||||
return {
|
||||
...actual,
|
||||
listChannelPluginCatalogEntries: ((...args) => {
|
||||
@@ -591,10 +589,8 @@ vi.mock("../channels/plugins/catalog.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../plugins/manifest-registry.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../plugins/manifest-registry.js")>(
|
||||
"../plugins/manifest-registry.js",
|
||||
);
|
||||
vi.mock("../plugins/manifest-registry.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../plugins/manifest-registry.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadPluginManifestRegistry: manifestRegistryMocks.loadPluginManifestRegistry,
|
||||
@@ -610,8 +606,8 @@ vi.mock("./onboard-helpers.js", () => ({
|
||||
detectBinary: vi.fn(async () => false),
|
||||
}));
|
||||
|
||||
vi.mock("./channel-setup/plugin-install.js", async () => {
|
||||
const actual = await vi.importActual("./channel-setup/plugin-install.js");
|
||||
vi.mock("./channel-setup/plugin-install.js", async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
ensureChannelSetupPluginInstalled: vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({
|
||||
|
||||
@@ -66,9 +66,8 @@ vi.mock("../gateway/client.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./onboard-helpers.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("./onboard-helpers.js")>("./onboard-helpers.js");
|
||||
vi.mock("./onboard-helpers.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./onboard-helpers.js")>();
|
||||
return {
|
||||
...actual,
|
||||
ensureWorkspaceAndSessions: ensureWorkspaceAndSessionsMock,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user