Compare commits

..

3 Commits

Author SHA1 Message Date
Josh Lehman
92d0e7dbe6 fix(session): address parent fork review feedback
Handle transcript-read failures when estimating parent fork token counts,
short-circuit the fallback path when the parent fork guard is disabled, and
remove the dead skip-fork log fallback now that the guarded branch only runs
with numeric parent token counts.

Regeneration-Prompt: |
  The rebased PR for the parent fork overflow guard picked up review feedback.
  Keep the original fix intact, but tighten the implementation in three small
  ways: do not pay transcript-read/token-estimation cost when
  session.parentForkMaxTokens is disabled, do not let synchronous transcript
  read failures bubble out of session initialization, and remove any dead
  parentTokens fallback text in the skip-fork log branch once that branch is
  already guarded by typeof parentTokens === "number".
2026-04-03 12:20:53 -07:00
Josh Lehman
706efe3628 docs(changelog): note parent fork overflow guard fix
Add the unreleased changelog entry for PR #60463 so the branch satisfies
this repo's changelog gate for user-facing fixes.

Regeneration-Prompt: |
  After opening PR #60463 for the parent fork overflow guard fix, this repo's
  PR workflow required a matching CHANGELOG.md line under ## Unreleased with
  the PR number and author credit. Append a concise Fixes entry describing
  that thread/session forking now falls back to transcript-estimated parent
  token counts when cached totals are stale or missing, and include
  (#60463) Thanks @jalehman on the same line.
2026-04-03 12:20:03 -07:00
Josh Lehman
4570c91651 fix(session): harden parent fork overflow guard
Prefer fresh persisted token counts when deciding whether a child session
should fork its parent transcript, and fall back to estimating token usage
from the parent transcript when the cached total is stale or missing. Add a
regression test covering a large parent transcript with stale token metadata
so forked thread sessions start fresh instead of cloning an oversized parent.

Regeneration-Prompt: |
  User asked to investigate and fix the broader OpenClaw-side fork/session
  overflow issue related to lossless-claw issue 206, working in
  ~/Projects/openclaw instead of the lossless-claw repo. Current OpenClaw
  already had a parent-fork size guard on main, so the task was not to
  reintroduce that feature, but to verify whether a gap remained.

  Investigation showed initSessionState only looked at the parent session
  entry's cached totalTokens when deciding whether to fork the parent
  transcript into a child thread session. That meant older or sparsely
  accounted sessions with totalTokens missing or marked stale could still
  clone a huge raw parent transcript and recreate the original overflow
  behavior.

  Preserve the existing configurable session.parentForkMaxTokens behavior,
  but make the guard trustworthy by preferring fresh persisted totals and
  otherwise estimating token count from the parent transcript before forking.
  Add a focused regression test that constructs a large parent transcript with
  totalTokensFresh=false and proves the child session starts fresh rather than
  forking the parent's transcript file.
2026-04-03 12:20:02 -07:00
134 changed files with 847 additions and 2188 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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",

View File

@@ -392,7 +392,7 @@ Notes:
## Manifest and scope checklist
<AccordionGroup>
<Accordion title="Slack app manifest example" defaultOpen>
<Accordion title="Slack app manifest example">
```json
{

View File

@@ -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;
}
});
};
}

View File

@@ -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,

View File

@@ -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",

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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.

View File

@@ -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", () => {

View File

@@ -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],
});

View File

@@ -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 () => {

View File

@@ -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",

View File

@@ -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",

View File

@@ -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);

View File

@@ -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: {

View File

@@ -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");

View File

@@ -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,

View File

@@ -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),

View File

@@ -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";

View File

@@ -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}`;
}

View File

@@ -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");
});
});

View File

@@ -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,

View File

@@ -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();
});

View File

@@ -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();

View File

@@ -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 () => {

View File

@@ -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,

View File

@@ -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",
}),
);
});
});

View File

@@ -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) {

View File

@@ -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");
});
});

View File

@@ -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 {

View File

@@ -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();

View File

@@ -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(

View File

@@ -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");
});
});

View File

@@ -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,

View File

@@ -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",
);
});
});
});

View File

@@ -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) {

View File

@@ -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"]);
});
});
});

View File

@@ -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

View File

@@ -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" });
});
});

View File

@@ -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" };
}

View File

@@ -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);

View File

@@ -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",

View File

@@ -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,
});

View File

@@ -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"],

View File

@@ -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";

View File

@@ -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" } }],
});
});
});

View File

@@ -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;
}
}
}
}
}

View File

@@ -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;
}

View File

@@ -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),

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 })),

View File

@@ -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 })),

View File

@@ -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;
}
}
}
}
}

View File

@@ -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);
});
});

View File

@@ -1158,7 +1158,6 @@ describe("resolveModel", () => {
runtimeHooks: {
applyProviderResolvedModelCompatWithPlugins: () => undefined,
buildProviderUnknownModelHintWithPlugin: () => undefined,
clearProviderRuntimeHookCache: () => {},
prepareProviderDynamicModel: async () => {},
runProviderDynamicModel: () => undefined,
applyProviderResolvedTransportWithPlugin: ({ provider, context }) =>

View File

@@ -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,

View File

@@ -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(),

View File

@@ -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,
);
}
});
});

View File

@@ -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),

View File

@@ -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,

View File

@@ -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(() => ({

View File

@@ -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,

View File

@@ -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 }) => {

View File

@@ -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>;
};

View File

@@ -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: {

View File

@@ -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",

View File

@@ -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,

View File

@@ -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;

View File

@@ -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")>(

View File

@@ -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");

View File

@@ -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),
});

View File

@@ -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. */

View File

@@ -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,

View File

@@ -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,
}));

View File

@@ -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,
}));

View File

@@ -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();

View File

@@ -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,

View File

@@ -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)),

View File

@@ -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,

View File

@@ -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",

View File

@@ -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,

View File

@@ -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,

View File

@@ -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),

View File

@@ -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,

View File

@@ -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,

View File

@@ -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(() => ({

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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(() => ({})),

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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>) => {

View File

@@ -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,

View File

@@ -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 }) => ({

View File

@@ -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