refactor: dedupe repeated test helpers

This commit is contained in:
Peter Steinberger
2026-04-08 08:43:59 +01:00
parent 7834140bf9
commit 95e397a266
142 changed files with 6125 additions and 10524 deletions
@@ -1,71 +1,27 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildAlibabaVideoGenerationProvider } from "./video-generation-provider.js";
import { beforeAll, describe, expect, it } from "vitest";
import {
expectDashscopeVideoTaskPoll,
expectSuccessfulDashscopeVideoResult,
mockSuccessfulDashscopeVideoTask,
} from "../../test/helpers/media-generation/dashscope-video-provider.js";
import {
getProviderHttpMocks,
installProviderHttpMockCleanup,
} from "../../test/helpers/media-generation/provider-http-mocks.js";
const {
resolveApiKeyForProviderMock,
postJsonRequestMock,
fetchWithTimeoutMock,
assertOkOrThrowHttpErrorMock,
resolveProviderHttpRequestConfigMock,
} = vi.hoisted(() => ({
resolveApiKeyForProviderMock: vi.fn(async () => ({ apiKey: "alibaba-key" })),
postJsonRequestMock: vi.fn(),
fetchWithTimeoutMock: vi.fn(),
assertOkOrThrowHttpErrorMock: vi.fn(async () => {}),
resolveProviderHttpRequestConfigMock: vi.fn((params) => ({
baseUrl: params.baseUrl ?? params.defaultBaseUrl,
allowPrivateNetwork: false,
headers: new Headers(params.defaultHeaders),
dispatcherPolicy: undefined,
})),
}));
const { postJsonRequestMock, fetchWithTimeoutMock } = getProviderHttpMocks();
vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
}));
let buildAlibabaVideoGenerationProvider: typeof import("./video-generation-provider.js").buildAlibabaVideoGenerationProvider;
vi.mock("openclaw/plugin-sdk/provider-http", () => ({
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
fetchWithTimeout: fetchWithTimeoutMock,
postJsonRequest: postJsonRequestMock,
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
}));
beforeAll(async () => {
({ buildAlibabaVideoGenerationProvider } = await import("./video-generation-provider.js"));
});
installProviderHttpMockCleanup();
describe("alibaba video generation provider", () => {
afterEach(() => {
resolveApiKeyForProviderMock.mockClear();
postJsonRequestMock.mockReset();
fetchWithTimeoutMock.mockReset();
assertOkOrThrowHttpErrorMock.mockClear();
resolveProviderHttpRequestConfigMock.mockClear();
});
it("submits async Wan generation, polls task status, and downloads the resulting video", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
request_id: "req-1",
output: {
task_id: "task-1",
},
}),
},
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock
.mockResolvedValueOnce({
json: async () => ({
output: {
task_status: "SUCCEEDED",
results: [{ video_url: "https://example.com/out.mp4" }],
},
}),
headers: new Headers(),
})
.mockResolvedValueOnce({
arrayBuffer: async () => Buffer.from("mp4-bytes"),
headers: new Headers({ "content-type": "video/mp4" }),
});
mockSuccessfulDashscopeVideoTask({ postJsonRequestMock, fetchWithTimeoutMock });
const provider = buildAlibabaVideoGenerationProvider();
const result = await provider.generateVideo({
@@ -96,22 +52,8 @@ describe("alibaba video generation provider", () => {
}),
}),
);
expect(fetchWithTimeoutMock).toHaveBeenNthCalledWith(
1,
"https://dashscope-intl.aliyuncs.com/api/v1/tasks/task-1",
expect.objectContaining({ method: "GET" }),
120000,
fetch,
);
expect(result.videos).toHaveLength(1);
expect(result.videos[0]?.mimeType).toBe("video/mp4");
expect(result.metadata).toEqual(
expect.objectContaining({
requestId: "req-1",
taskId: "task-1",
taskStatus: "SUCCEEDED",
}),
);
expectDashscopeVideoTaskPoll(fetchWithTimeoutMock);
expectSuccessfulDashscopeVideoResult(result);
});
it("fails fast when reference inputs are local buffers instead of remote URLs", async () => {
+14 -103
View File
@@ -1,29 +1,21 @@
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import { resolveProviderHttpRequestConfig } from "openclaw/plugin-sdk/provider-http";
import {
assertOkOrThrowHttpError,
postJsonRequest,
resolveProviderHttpRequestConfig,
} from "openclaw/plugin-sdk/provider-http";
import {
DEFAULT_VIDEO_GENERATION_DURATION_SECONDS,
DASHSCOPE_WAN_VIDEO_CAPABILITIES,
DASHSCOPE_WAN_VIDEO_MODELS,
DEFAULT_DASHSCOPE_WAN_VIDEO_MODEL,
DEFAULT_VIDEO_GENERATION_TIMEOUT_MS,
DEFAULT_VIDEO_RESOLUTION_TO_SIZE,
buildDashscopeVideoGenerationInput,
buildDashscopeVideoGenerationParameters,
downloadDashscopeGeneratedVideos,
extractDashscopeVideoUrls,
pollDashscopeVideoTaskUntilComplete,
runDashscopeVideoGenerationTask,
} from "openclaw/plugin-sdk/video-generation";
import type {
DashscopeVideoGenerationResponse,
VideoGenerationProvider,
VideoGenerationRequest,
VideoGenerationResult,
} from "openclaw/plugin-sdk/video-generation";
const DEFAULT_ALIBABA_VIDEO_BASE_URL = "https://dashscope-intl.aliyuncs.com";
const DEFAULT_ALIBABA_VIDEO_MODEL = "wan2.6-t2v";
const DEFAULT_ALIBABA_VIDEO_MODEL = DEFAULT_DASHSCOPE_WAN_VIDEO_MODEL;
function resolveAlibabaVideoBaseUrl(req: VideoGenerationRequest): string {
return req.cfg?.models?.providers?.alibaba?.baseUrl?.trim() || DEFAULT_ALIBABA_VIDEO_BASE_URL;
@@ -38,45 +30,13 @@ export function buildAlibabaVideoGenerationProvider(): VideoGenerationProvider {
id: "alibaba",
label: "Alibaba Model Studio",
defaultModel: DEFAULT_ALIBABA_VIDEO_MODEL,
models: ["wan2.6-t2v", "wan2.6-i2v", "wan2.6-r2v", "wan2.6-r2v-flash", "wan2.7-r2v"],
models: [...DASHSCOPE_WAN_VIDEO_MODELS],
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({
provider: "alibaba",
agentDir,
}),
capabilities: {
generate: {
maxVideos: 1,
maxDurationSeconds: 10,
supportsSize: true,
supportsAspectRatio: true,
supportsResolution: true,
supportsAudio: true,
supportsWatermark: true,
},
imageToVideo: {
enabled: true,
maxVideos: 1,
maxInputImages: 1,
maxDurationSeconds: 10,
supportsSize: true,
supportsAspectRatio: true,
supportsResolution: true,
supportsAudio: true,
supportsWatermark: true,
},
videoToVideo: {
enabled: true,
maxVideos: 1,
maxInputVideos: 4,
maxDurationSeconds: 10,
supportsSize: true,
supportsAspectRatio: true,
supportsResolution: true,
supportsAudio: true,
supportsWatermark: true,
},
},
capabilities: DASHSCOPE_WAN_VIDEO_CAPABILITIES,
async generateVideo(req): Promise<VideoGenerationResult> {
const fetchFn = fetch;
const auth = await resolveApiKeyForProvider({
@@ -105,68 +65,19 @@ export function buildAlibabaVideoGenerationProvider(): VideoGenerationProvider {
});
const model = req.model?.trim() || DEFAULT_ALIBABA_VIDEO_MODEL;
const { response, release } = await postJsonRequest({
return await runDashscopeVideoGenerationTask({
providerLabel: "Alibaba Wan",
model,
req,
url: `${resolveDashscopeAigcApiBaseUrl(baseUrl)}/api/v1/services/aigc/video-generation/video-synthesis`,
headers,
body: {
model,
input: buildDashscopeVideoGenerationInput({
providerLabel: "Alibaba Wan",
req,
}),
parameters: buildDashscopeVideoGenerationParameters(
{
...req,
durationSeconds: req.durationSeconds ?? DEFAULT_VIDEO_GENERATION_DURATION_SECONDS,
},
DEFAULT_VIDEO_RESOLUTION_TO_SIZE,
),
},
baseUrl: resolveDashscopeAigcApiBaseUrl(baseUrl),
timeoutMs: req.timeoutMs,
fetchFn,
allowPrivateNetwork,
dispatcherPolicy,
defaultTimeoutMs: DEFAULT_VIDEO_GENERATION_TIMEOUT_MS,
});
try {
await assertOkOrThrowHttpError(response, "Alibaba Wan video generation failed");
const submitted = (await response.json()) as DashscopeVideoGenerationResponse;
const taskId = submitted.output?.task_id?.trim();
if (!taskId) {
throw new Error("Alibaba Wan video generation response missing task_id");
}
const completed = await pollDashscopeVideoTaskUntilComplete({
providerLabel: "Alibaba Wan",
taskId,
headers,
timeoutMs: req.timeoutMs,
fetchFn,
baseUrl: resolveDashscopeAigcApiBaseUrl(baseUrl),
defaultTimeoutMs: DEFAULT_VIDEO_GENERATION_TIMEOUT_MS,
});
const urls = extractDashscopeVideoUrls(completed);
if (urls.length === 0) {
throw new Error("Alibaba Wan video generation completed without output video URLs");
}
const videos = await downloadDashscopeGeneratedVideos({
providerLabel: "Alibaba Wan",
urls,
timeoutMs: req.timeoutMs,
fetchFn,
defaultTimeoutMs: DEFAULT_VIDEO_GENERATION_TIMEOUT_MS,
});
return {
videos,
model,
metadata: {
requestId: submitted.request_id,
taskId,
taskStatus: completed.output?.task_status,
},
};
} finally {
await release();
}
},
};
}
@@ -0,0 +1,107 @@
import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeBlueBubblesServerUrl } from "./types.js";
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
export function normalizeBlueBubblesPrivateNetworkAliases<T extends object | undefined>(
config: T,
): T {
const record = asRecord(config);
if (!record) {
return config;
}
const network = asRecord(record.network);
const canonicalValue =
typeof network?.dangerouslyAllowPrivateNetwork === "boolean"
? network.dangerouslyAllowPrivateNetwork
: typeof network?.allowPrivateNetwork === "boolean"
? network.allowPrivateNetwork
: typeof record.dangerouslyAllowPrivateNetwork === "boolean"
? record.dangerouslyAllowPrivateNetwork
: typeof record.allowPrivateNetwork === "boolean"
? record.allowPrivateNetwork
: undefined;
if (canonicalValue === undefined) {
return config;
}
const {
allowPrivateNetwork: _legacyFlatAllow,
dangerouslyAllowPrivateNetwork: _legacyFlatDanger,
...rest
} = record;
const {
allowPrivateNetwork: _legacyNetworkAllow,
dangerouslyAllowPrivateNetwork: _legacyNetworkDanger,
...restNetwork
} = network ?? {};
return {
...rest,
network: {
...restNetwork,
dangerouslyAllowPrivateNetwork: canonicalValue,
},
} as T;
}
export function normalizeBlueBubblesAccountsMap<T extends object | undefined>(
accounts: Record<string, T> | undefined,
): Record<string, T> | undefined {
if (!accounts) {
return undefined;
}
return Object.fromEntries(
Object.entries(accounts).map(([accountKey, accountConfig]) => [
accountKey,
normalizeBlueBubblesPrivateNetworkAliases(accountConfig),
]),
);
}
export function resolveBlueBubblesPrivateNetworkConfigValue(
config: object | null | undefined,
): boolean | undefined {
const record = asRecord(config);
if (!record) {
return undefined;
}
const network = asRecord(record.network);
if (typeof network?.dangerouslyAllowPrivateNetwork === "boolean") {
return network.dangerouslyAllowPrivateNetwork;
}
if (typeof network?.allowPrivateNetwork === "boolean") {
return network.allowPrivateNetwork;
}
if (typeof record.dangerouslyAllowPrivateNetwork === "boolean") {
return record.dangerouslyAllowPrivateNetwork;
}
if (typeof record.allowPrivateNetwork === "boolean") {
return record.allowPrivateNetwork;
}
return undefined;
}
export function resolveBlueBubblesEffectiveAllowPrivateNetworkFromConfig(params: {
baseUrl?: string;
config?: object | null;
}): boolean {
const configuredValue = resolveBlueBubblesPrivateNetworkConfigValue(params.config);
if (configuredValue !== undefined) {
return configuredValue;
}
if (!params.baseUrl) {
return false;
}
try {
const hostname = new URL(normalizeBlueBubblesServerUrl(params.baseUrl)).hostname.trim();
return Boolean(hostname) && isBlockedHostnameOrIp(hostname);
} catch {
return false;
}
}
+9 -96
View File
@@ -5,8 +5,13 @@ import {
} from "openclaw/plugin-sdk/account-resolution";
import { resolveChannelStreamingChunkMode } from "openclaw/plugin-sdk/channel-streaming";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import {
normalizeBlueBubblesAccountsMap,
normalizeBlueBubblesPrivateNetworkAliases,
resolveBlueBubblesEffectiveAllowPrivateNetworkFromConfig,
resolveBlueBubblesPrivateNetworkConfigValue as resolveBlueBubblesPrivateNetworkConfigValueFromRecord,
} from "./accounts-normalization.js";
import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js";
import { normalizeBlueBubblesServerUrl, type BlueBubblesAccountConfig } from "./types.js";
@@ -25,76 +30,13 @@ const {
} = createAccountListHelpers("bluebubbles");
export { listBlueBubblesAccountIds, resolveDefaultBlueBubblesAccountId };
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function normalizeBlueBubblesPrivateNetworkAliases(
config: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
const record = asRecord(config);
if (!record) {
return config;
}
const network = asRecord(record.network);
const canonicalValue =
typeof network?.dangerouslyAllowPrivateNetwork === "boolean"
? network.dangerouslyAllowPrivateNetwork
: typeof network?.allowPrivateNetwork === "boolean"
? network.allowPrivateNetwork
: typeof record.dangerouslyAllowPrivateNetwork === "boolean"
? record.dangerouslyAllowPrivateNetwork
: typeof record.allowPrivateNetwork === "boolean"
? record.allowPrivateNetwork
: undefined;
if (canonicalValue === undefined) {
return config;
}
const {
allowPrivateNetwork: _legacyFlatAllow,
dangerouslyAllowPrivateNetwork: _legacyFlatDanger,
...rest
} = record;
const {
allowPrivateNetwork: _legacyNetworkAllow,
dangerouslyAllowPrivateNetwork: _legacyNetworkDanger,
...restNetwork
} = network ?? {};
return {
...rest,
network: {
...restNetwork,
dangerouslyAllowPrivateNetwork: canonicalValue,
},
};
}
function normalizeBlueBubblesAccountsMap(
accounts: Record<string, Partial<BlueBubblesAccountConfig>> | undefined,
): Record<string, Partial<BlueBubblesAccountConfig>> | undefined {
if (!accounts) {
return undefined;
}
return Object.fromEntries(
Object.entries(accounts).map(([accountKey, accountConfig]) => [
accountKey,
normalizeBlueBubblesPrivateNetworkAliases(accountConfig) as Partial<BlueBubblesAccountConfig>,
]),
);
}
function mergeBlueBubblesAccountConfig(
cfg: OpenClawConfig,
accountId: string,
): BlueBubblesAccountConfig {
const channelConfig = normalizeBlueBubblesPrivateNetworkAliases(
cfg.channels?.bluebubbles as BlueBubblesAccountConfig | undefined,
) as BlueBubblesAccountConfig | undefined;
);
const accounts = normalizeBlueBubblesAccountsMap(
cfg.channels?.bluebubbles?.accounts as
| Record<string, Partial<BlueBubblesAccountConfig>>
@@ -141,43 +83,14 @@ export function resolveBlueBubblesAccount(params: {
export function resolveBlueBubblesPrivateNetworkConfigValue(
config: BlueBubblesAccountConfig | null | undefined,
): boolean | undefined {
const record = asRecord(config);
if (!record) {
return undefined;
}
const network = asRecord(record.network);
if (typeof network?.dangerouslyAllowPrivateNetwork === "boolean") {
return network.dangerouslyAllowPrivateNetwork;
}
if (typeof network?.allowPrivateNetwork === "boolean") {
return network.allowPrivateNetwork;
}
if (typeof record.dangerouslyAllowPrivateNetwork === "boolean") {
return record.dangerouslyAllowPrivateNetwork;
}
if (typeof record.allowPrivateNetwork === "boolean") {
return record.allowPrivateNetwork;
}
return undefined;
return resolveBlueBubblesPrivateNetworkConfigValueFromRecord(config);
}
export function resolveBlueBubblesEffectiveAllowPrivateNetwork(params: {
baseUrl?: string;
config?: BlueBubblesAccountConfig | null;
}): boolean {
const configuredValue = resolveBlueBubblesPrivateNetworkConfigValue(params.config);
if (configuredValue !== undefined) {
return configuredValue;
}
if (!params.baseUrl) {
return false;
}
try {
const hostname = new URL(normalizeBlueBubblesServerUrl(params.baseUrl)).hostname.trim();
return Boolean(hostname) && isBlockedHostnameOrIp(hostname);
} catch {
return false;
}
return resolveBlueBubblesEffectiveAllowPrivateNetworkFromConfig(params);
}
export function listEnabledBlueBubblesAccounts(cfg: OpenClawConfig): ResolvedBlueBubblesAccount[] {
+7 -107
View File
@@ -1,7 +1,12 @@
import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime";
import type { Mock } from "vitest";
import { afterEach, beforeEach, vi } from "vitest";
import { _setFetchGuardForTesting, normalizeBlueBubblesServerUrl } from "./types.js";
import {
normalizeBlueBubblesAccountsMap,
normalizeBlueBubblesPrivateNetworkAliases,
resolveBlueBubblesEffectiveAllowPrivateNetworkFromConfig,
resolveBlueBubblesPrivateNetworkConfigValue as resolveBlueBubblesPrivateNetworkConfigValueFromConfig,
} from "./accounts-normalization.js";
import { _setFetchGuardForTesting } from "./types.js";
export const BLUE_BUBBLES_PRIVATE_API_STATUS = {
enabled: true,
@@ -28,69 +33,6 @@ export function mockBlueBubblesPrivateApiStatusOnce(
mock.mockReturnValueOnce(value);
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function normalizeBlueBubblesPrivateNetworkAliases(
config: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
const record = asRecord(config);
if (!record) {
return config;
}
const network = asRecord(record.network);
const canonicalValue =
typeof network?.dangerouslyAllowPrivateNetwork === "boolean"
? network.dangerouslyAllowPrivateNetwork
: typeof network?.allowPrivateNetwork === "boolean"
? network.allowPrivateNetwork
: typeof record.dangerouslyAllowPrivateNetwork === "boolean"
? record.dangerouslyAllowPrivateNetwork
: typeof record.allowPrivateNetwork === "boolean"
? record.allowPrivateNetwork
: undefined;
if (canonicalValue === undefined) {
return config;
}
const {
allowPrivateNetwork: _legacyFlatAllow,
dangerouslyAllowPrivateNetwork: _legacyFlatDanger,
...rest
} = record;
const {
allowPrivateNetwork: _legacyNetworkAllow,
dangerouslyAllowPrivateNetwork: _legacyNetworkDanger,
...restNetwork
} = network ?? {};
return {
...rest,
network: {
...restNetwork,
dangerouslyAllowPrivateNetwork: canonicalValue,
},
};
}
function normalizeBlueBubblesAccountsMap(
accounts: Record<string, Record<string, unknown> | undefined> | undefined,
): Record<string, Record<string, unknown> | undefined> | undefined {
if (!accounts) {
return undefined;
}
return Object.fromEntries(
Object.entries(accounts).map(([accountKey, accountConfig]) => [
accountKey,
normalizeBlueBubblesPrivateNetworkAliases(accountConfig),
]),
);
}
export function resolveBlueBubblesAccountFromConfig(params: {
cfg?: { channels?: { bluebubbles?: Record<string, unknown> } };
accountId?: string;
@@ -127,48 +69,6 @@ export function resolveBlueBubblesAccountFromConfig(params: {
};
}
function resolveBlueBubblesPrivateNetworkConfigValueFromConfig(
config: Record<string, unknown> | undefined,
): boolean | undefined {
const record = asRecord(config);
if (!record) {
return undefined;
}
const network = asRecord(record.network);
if (typeof network?.dangerouslyAllowPrivateNetwork === "boolean") {
return network.dangerouslyAllowPrivateNetwork;
}
if (typeof network?.allowPrivateNetwork === "boolean") {
return network.allowPrivateNetwork;
}
if (typeof record.dangerouslyAllowPrivateNetwork === "boolean") {
return record.dangerouslyAllowPrivateNetwork;
}
if (typeof record.allowPrivateNetwork === "boolean") {
return record.allowPrivateNetwork;
}
return undefined;
}
function resolveBlueBubblesEffectiveAllowPrivateNetworkFromConfig(params: {
baseUrl?: string;
config?: Record<string, unknown>;
}) {
const configuredValue = resolveBlueBubblesPrivateNetworkConfigValueFromConfig(params.config);
if (configuredValue !== undefined) {
return configuredValue;
}
if (!params.baseUrl) {
return false;
}
try {
const hostname = new URL(normalizeBlueBubblesServerUrl(params.baseUrl)).hostname.trim();
return Boolean(hostname) && isBlockedHostnameOrIp(hostname);
} catch {
return false;
}
}
export function createBlueBubblesAccountsMockModule() {
return {
resolveBlueBubblesAccount: vi.fn(resolveBlueBubblesAccountFromConfig),
@@ -1,59 +1,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
type RemoteProfileTestDeps = {
chromeModule: typeof import("./chrome.js");
InvalidBrowserNavigationUrlError: typeof import("./navigation-guard.js").InvalidBrowserNavigationUrlError;
pwAiModule: typeof import("./pw-ai-module.js");
closePlaywrightBrowserConnection: typeof import("./pw-session.js").closePlaywrightBrowserConnection;
createBrowserRouteContext: typeof import("./server-context.js").createBrowserRouteContext;
createJsonListFetchMock: typeof import("./server-context.remote-tab-ops.harness.js").createJsonListFetchMock;
createRemoteRouteHarness: typeof import("./server-context.remote-tab-ops.harness.js").createRemoteRouteHarness;
createSequentialPageLister: typeof import("./server-context.remote-tab-ops.harness.js").createSequentialPageLister;
makeState: typeof import("./server-context.remote-tab-ops.harness.js").makeState;
originalFetch: typeof import("./server-context.remote-tab-ops.harness.js").originalFetch;
};
async function loadRemoteProfileTestDeps(): Promise<RemoteProfileTestDeps> {
vi.resetModules();
await import("./server-context.chrome-test-harness.js");
const chromeModule = await import("./chrome.js");
const { InvalidBrowserNavigationUrlError } = await import("./navigation-guard.js");
const pwAiModule = await import("./pw-ai-module.js");
const { closePlaywrightBrowserConnection } = await import("./pw-session.js");
const { createBrowserRouteContext } = await import("./server-context.js");
const {
createJsonListFetchMock,
createRemoteRouteHarness,
createSequentialPageLister,
makeState,
originalFetch,
} = await import("./server-context.remote-tab-ops.harness.js");
return {
chromeModule,
InvalidBrowserNavigationUrlError,
pwAiModule,
closePlaywrightBrowserConnection,
createBrowserRouteContext,
createJsonListFetchMock,
createRemoteRouteHarness,
createSequentialPageLister,
makeState,
originalFetch,
};
}
import { describe, expect, it, vi } from "vitest";
import {
installRemoteProfileTestLifecycle,
loadRemoteProfileTestDeps,
type RemoteProfileTestDeps,
} from "./server-context.remote-profile-tab-ops.test-helpers.js";
const deps: RemoteProfileTestDeps = await loadRemoteProfileTestDeps();
beforeEach(() => {
vi.clearAllMocks();
globalThis.fetch = deps.originalFetch;
});
afterEach(async () => {
await deps.closePlaywrightBrowserConnection().catch(() => {});
globalThis.fetch = deps.originalFetch;
vi.restoreAllMocks();
});
installRemoteProfileTestLifecycle(deps);
describe("browser remote profile fallback and attachOnly behavior", () => {
it("uses profile-level attachOnly when global attachOnly is false", async () => {
@@ -1,59 +1,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
type RemoteProfileTestDeps = {
chromeModule: typeof import("./chrome.js");
InvalidBrowserNavigationUrlError: typeof import("./navigation-guard.js").InvalidBrowserNavigationUrlError;
pwAiModule: typeof import("./pw-ai-module.js");
closePlaywrightBrowserConnection: typeof import("./pw-session.js").closePlaywrightBrowserConnection;
createBrowserRouteContext: typeof import("./server-context.js").createBrowserRouteContext;
createJsonListFetchMock: typeof import("./server-context.remote-tab-ops.harness.js").createJsonListFetchMock;
createRemoteRouteHarness: typeof import("./server-context.remote-tab-ops.harness.js").createRemoteRouteHarness;
createSequentialPageLister: typeof import("./server-context.remote-tab-ops.harness.js").createSequentialPageLister;
makeState: typeof import("./server-context.remote-tab-ops.harness.js").makeState;
originalFetch: typeof import("./server-context.remote-tab-ops.harness.js").originalFetch;
};
async function loadRemoteProfileTestDeps(): Promise<RemoteProfileTestDeps> {
vi.resetModules();
await import("./server-context.chrome-test-harness.js");
const chromeModule = await import("./chrome.js");
const { InvalidBrowserNavigationUrlError } = await import("./navigation-guard.js");
const pwAiModule = await import("./pw-ai-module.js");
const { closePlaywrightBrowserConnection } = await import("./pw-session.js");
const { createBrowserRouteContext } = await import("./server-context.js");
const {
createJsonListFetchMock,
createRemoteRouteHarness,
createSequentialPageLister,
makeState,
originalFetch,
} = await import("./server-context.remote-tab-ops.harness.js");
return {
chromeModule,
InvalidBrowserNavigationUrlError,
pwAiModule,
closePlaywrightBrowserConnection,
createBrowserRouteContext,
createJsonListFetchMock,
createRemoteRouteHarness,
createSequentialPageLister,
makeState,
originalFetch,
};
}
import { describe, expect, it, vi } from "vitest";
import {
installRemoteProfileTestLifecycle,
loadRemoteProfileTestDeps,
type RemoteProfileTestDeps,
} from "./server-context.remote-profile-tab-ops.test-helpers.js";
const deps: RemoteProfileTestDeps = await loadRemoteProfileTestDeps();
beforeEach(() => {
vi.clearAllMocks();
globalThis.fetch = deps.originalFetch;
});
afterEach(async () => {
await deps.closePlaywrightBrowserConnection().catch(() => {});
globalThis.fetch = deps.originalFetch;
vi.restoreAllMocks();
});
installRemoteProfileTestLifecycle(deps);
describe("browser remote profile tab ops via Playwright", () => {
it("uses Playwright tab operations when available", async () => {
@@ -0,0 +1,56 @@
import { afterEach, beforeEach, vi } from "vitest";
export type RemoteProfileTestDeps = {
chromeModule: typeof import("./chrome.js");
InvalidBrowserNavigationUrlError: typeof import("./navigation-guard.js").InvalidBrowserNavigationUrlError;
pwAiModule: typeof import("./pw-ai-module.js");
closePlaywrightBrowserConnection: typeof import("./pw-session.js").closePlaywrightBrowserConnection;
createBrowserRouteContext: typeof import("./server-context.js").createBrowserRouteContext;
createJsonListFetchMock: typeof import("./server-context.remote-tab-ops.harness.js").createJsonListFetchMock;
createRemoteRouteHarness: typeof import("./server-context.remote-tab-ops.harness.js").createRemoteRouteHarness;
createSequentialPageLister: typeof import("./server-context.remote-tab-ops.harness.js").createSequentialPageLister;
makeState: typeof import("./server-context.remote-tab-ops.harness.js").makeState;
originalFetch: typeof import("./server-context.remote-tab-ops.harness.js").originalFetch;
};
export async function loadRemoteProfileTestDeps(): Promise<RemoteProfileTestDeps> {
vi.resetModules();
await import("./server-context.chrome-test-harness.js");
const chromeModule = await import("./chrome.js");
const { InvalidBrowserNavigationUrlError } = await import("./navigation-guard.js");
const pwAiModule = await import("./pw-ai-module.js");
const { closePlaywrightBrowserConnection } = await import("./pw-session.js");
const { createBrowserRouteContext } = await import("./server-context.js");
const {
createJsonListFetchMock,
createRemoteRouteHarness,
createSequentialPageLister,
makeState,
originalFetch,
} = await import("./server-context.remote-tab-ops.harness.js");
return {
chromeModule,
InvalidBrowserNavigationUrlError,
pwAiModule,
closePlaywrightBrowserConnection,
createBrowserRouteContext,
createJsonListFetchMock,
createRemoteRouteHarness,
createSequentialPageLister,
makeState,
originalFetch,
};
}
export function installRemoteProfileTestLifecycle(deps: RemoteProfileTestDeps): void {
beforeEach(() => {
vi.clearAllMocks();
globalThis.fetch = deps.originalFetch;
});
afterEach(async () => {
await deps.closePlaywrightBrowserConnection().catch(() => {});
globalThis.fetch = deps.originalFetch;
vi.restoreAllMocks();
});
}
@@ -1,45 +1,20 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildBytePlusVideoGenerationProvider } from "./video-generation-provider.js";
import { beforeAll, describe, expect, it, vi } from "vitest";
import {
getProviderHttpMocks,
installProviderHttpMockCleanup,
} from "../../test/helpers/media-generation/provider-http-mocks.js";
const {
resolveApiKeyForProviderMock,
postJsonRequestMock,
fetchWithTimeoutMock,
assertOkOrThrowHttpErrorMock,
resolveProviderHttpRequestConfigMock,
} = vi.hoisted(() => ({
resolveApiKeyForProviderMock: vi.fn(async () => ({ apiKey: "byteplus-key" })),
postJsonRequestMock: vi.fn(),
fetchWithTimeoutMock: vi.fn(),
assertOkOrThrowHttpErrorMock: vi.fn(async () => {}),
resolveProviderHttpRequestConfigMock: vi.fn((params) => ({
baseUrl: params.baseUrl ?? params.defaultBaseUrl,
allowPrivateNetwork: false,
headers: new Headers(params.defaultHeaders),
dispatcherPolicy: undefined,
})),
}));
const { postJsonRequestMock, fetchWithTimeoutMock } = getProviderHttpMocks();
vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
}));
let buildBytePlusVideoGenerationProvider: typeof import("./video-generation-provider.js").buildBytePlusVideoGenerationProvider;
vi.mock("openclaw/plugin-sdk/provider-http", () => ({
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
fetchWithTimeout: fetchWithTimeoutMock,
postJsonRequest: postJsonRequestMock,
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
}));
beforeAll(async () => {
({ buildBytePlusVideoGenerationProvider } = await import("./video-generation-provider.js"));
});
installProviderHttpMockCleanup();
describe("byteplus video generation provider", () => {
afterEach(() => {
resolveApiKeyForProviderMock.mockClear();
postJsonRequestMock.mockReset();
fetchWithTimeoutMock.mockReset();
assertOkOrThrowHttpErrorMock.mockClear();
resolveProviderHttpRequestConfigMock.mockClear();
});
it("creates a content-generation task, polls, and downloads the video", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
+29 -38
View File
@@ -62,6 +62,31 @@ function resolveGoogleChatAccountImpl(params: { cfg: OpenClawConfig; accountId?:
};
}
function mockGoogleChatOutboundSpaceResolution() {
resolveGoogleChatOutboundSpaceMock.mockImplementation(async ({ target }: { target: string }) => {
const normalized = normalizeGoogleChatTarget(target);
if (!normalized) {
throw new Error("Missing Google Chat target.");
}
return normalized.toLowerCase().startsWith("users/")
? `spaces/DM-${normalized.slice("users/".length)}`
: normalized.replace(/\/messages\/.+$/, "");
});
}
function mockGoogleChatMediaLoaders() {
loadOutboundMediaFromUrlMock.mockImplementation(async (mediaUrl: string) => ({
buffer: Buffer.from("default-bytes"),
fileName: mediaUrl.split("/").pop() || "attachment",
contentType: "application/octet-stream",
}));
fetchRemoteMediaMock.mockImplementation(async () => ({
buffer: Buffer.from("remote-bytes"),
fileName: "remote.png",
contentType: "image/png",
}));
}
vi.mock("./channel.runtime.js", () => {
return {
googleChatChannelRuntime: {
@@ -136,48 +161,14 @@ vi.mock("./channel.deps.runtime.js", () => {
});
resolveGoogleChatAccountMock.mockImplementation(resolveGoogleChatAccountImpl);
resolveGoogleChatOutboundSpaceMock.mockImplementation(async ({ target }: { target: string }) => {
const normalized = normalizeGoogleChatTarget(target);
if (!normalized) {
throw new Error("Missing Google Chat target.");
}
return normalized.toLowerCase().startsWith("users/")
? `spaces/DM-${normalized.slice("users/".length)}`
: normalized.replace(/\/messages\/.+$/, "");
});
loadOutboundMediaFromUrlMock.mockImplementation(async (mediaUrl: string) => ({
buffer: Buffer.from("default-bytes"),
fileName: mediaUrl.split("/").pop() || "attachment",
contentType: "application/octet-stream",
}));
fetchRemoteMediaMock.mockImplementation(async () => ({
buffer: Buffer.from("remote-bytes"),
fileName: "remote.png",
contentType: "image/png",
}));
mockGoogleChatOutboundSpaceResolution();
mockGoogleChatMediaLoaders();
afterEach(() => {
vi.clearAllMocks();
resolveGoogleChatAccountMock.mockImplementation(resolveGoogleChatAccountImpl);
resolveGoogleChatOutboundSpaceMock.mockImplementation(async ({ target }: { target: string }) => {
const normalized = normalizeGoogleChatTarget(target);
if (!normalized) {
throw new Error("Missing Google Chat target.");
}
return normalized.toLowerCase().startsWith("users/")
? `spaces/DM-${normalized.slice("users/".length)}`
: normalized.replace(/\/messages\/.+$/, "");
});
loadOutboundMediaFromUrlMock.mockImplementation(async (mediaUrl: string) => ({
buffer: Buffer.from("default-bytes"),
fileName: mediaUrl.split("/").pop() || "attachment",
contentType: "application/octet-stream",
}));
fetchRemoteMediaMock.mockImplementation(async () => ({
buffer: Buffer.from("remote-bytes"),
fileName: "remote.png",
contentType: "image/png",
}));
mockGoogleChatOutboundSpaceResolution();
mockGoogleChatMediaLoaders();
});
function createGoogleChatCfg(): OpenClawConfig {
+53 -110
View File
@@ -3,7 +3,7 @@ import type { LobsterRunner } from "./lobster-runner.js";
import { resumeManagedLobsterFlow, runManagedLobsterFlow } from "./lobster-taskflow.js";
import { createFakeTaskFlow } from "./taskflow-test-helpers.js";
function _expectManagedFlowFailure(
function expectManagedFlowFailure(
result: Awaited<ReturnType<typeof runManagedLobsterFlow | typeof resumeManagedLobsterFlow>>,
) {
expect(result.ok).toBe(false);
@@ -18,6 +18,45 @@ function createRunner(result: Awaited<ReturnType<LobsterRunner["run"]>>): Lobste
};
}
function createRunFlowParams(
taskFlow: ReturnType<typeof createFakeTaskFlow>,
runner: LobsterRunner,
): Parameters<typeof runManagedLobsterFlow>[0] {
return {
taskFlow,
runner,
runnerParams: {
action: "run",
pipeline: "noop",
cwd: process.cwd(),
timeoutMs: 1000,
maxStdoutBytes: 4096,
},
controllerId: "tests/lobster",
goal: "Run Lobster workflow",
};
}
function createResumeFlowParams(
taskFlow: ReturnType<typeof createFakeTaskFlow>,
runner: LobsterRunner,
): Parameters<typeof resumeManagedLobsterFlow>[0] {
return {
taskFlow,
runner,
flowId: "flow-1",
expectedRevision: 4,
runnerParams: {
action: "resume",
token: "resume-1",
approve: true,
cwd: process.cwd(),
timeoutMs: 1000,
maxStdoutBytes: 4096,
},
};
}
describe("runManagedLobsterFlow", () => {
it("creates a flow and finishes it when Lobster succeeds", async () => {
const taskFlow = createFakeTaskFlow();
@@ -28,19 +67,7 @@ describe("runManagedLobsterFlow", () => {
requiresApproval: null,
});
const result = await runManagedLobsterFlow({
taskFlow,
runner,
runnerParams: {
action: "run",
pipeline: "noop",
cwd: process.cwd(),
timeoutMs: 1000,
maxStdoutBytes: 4096,
},
controllerId: "tests/lobster",
goal: "Run Lobster workflow",
});
const result = await runManagedLobsterFlow(createRunFlowParams(taskFlow, runner));
expect(result.ok).toBe(true);
expect(taskFlow.createManaged).toHaveBeenCalledWith({
@@ -69,19 +96,7 @@ describe("runManagedLobsterFlow", () => {
},
});
const result = await runManagedLobsterFlow({
taskFlow,
runner,
runnerParams: {
action: "run",
pipeline: "noop",
cwd: process.cwd(),
timeoutMs: 1000,
maxStdoutBytes: 4096,
},
controllerId: "tests/lobster",
goal: "Run Lobster workflow",
});
const result = await runManagedLobsterFlow(createRunFlowParams(taskFlow, runner));
expect(result.ok).toBe(true);
expect(taskFlow.setWaiting).toHaveBeenCalledWith({
@@ -107,24 +122,9 @@ describe("runManagedLobsterFlow", () => {
},
});
const result = await runManagedLobsterFlow({
taskFlow,
runner,
runnerParams: {
action: "run",
pipeline: "noop",
cwd: process.cwd(),
timeoutMs: 1000,
maxStdoutBytes: 4096,
},
controllerId: "tests/lobster",
goal: "Run Lobster workflow",
});
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error("expected managed Lobster flow to fail");
}
const result = expectManagedFlowFailure(
await runManagedLobsterFlow(createRunFlowParams(taskFlow, runner)),
);
expect(result.error.message).toBe("boom");
expect(taskFlow.fail).toHaveBeenCalledWith({
flowId: "flow-1",
@@ -138,24 +138,9 @@ describe("runManagedLobsterFlow", () => {
run: vi.fn().mockRejectedValue(new Error("crashed")),
};
const result = await runManagedLobsterFlow({
taskFlow,
runner,
runnerParams: {
action: "run",
pipeline: "noop",
cwd: process.cwd(),
timeoutMs: 1000,
maxStdoutBytes: 4096,
},
controllerId: "tests/lobster",
goal: "Run Lobster workflow",
});
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error("expected managed Lobster flow to fail");
}
const result = expectManagedFlowFailure(
await runManagedLobsterFlow(createRunFlowParams(taskFlow, runner)),
);
expect(result.error.message).toBe("crashed");
expect(taskFlow.fail).toHaveBeenCalledWith({
flowId: "flow-1",
@@ -174,20 +159,7 @@ describe("resumeManagedLobsterFlow", () => {
requiresApproval: null,
});
const result = await resumeManagedLobsterFlow({
taskFlow,
runner,
flowId: "flow-1",
expectedRevision: 4,
runnerParams: {
action: "resume",
token: "resume-1",
approve: true,
cwd: process.cwd(),
timeoutMs: 1000,
maxStdoutBytes: 4096,
},
});
const result = await resumeManagedLobsterFlow(createResumeFlowParams(taskFlow, runner));
expect(result.ok).toBe(true);
expect(taskFlow.resume).toHaveBeenCalledWith({
@@ -216,25 +188,9 @@ describe("resumeManagedLobsterFlow", () => {
requiresApproval: null,
});
const result = await resumeManagedLobsterFlow({
taskFlow,
runner,
flowId: "flow-1",
expectedRevision: 4,
runnerParams: {
action: "resume",
token: "resume-1",
approve: true,
cwd: process.cwd(),
timeoutMs: 1000,
maxStdoutBytes: 4096,
},
});
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error("expected resumed Lobster flow to fail");
}
const result = expectManagedFlowFailure(
await resumeManagedLobsterFlow(createResumeFlowParams(taskFlow, runner)),
);
expect(result.error.message).toMatch(/revision_conflict/);
expect(runner.run).not.toHaveBeenCalled();
});
@@ -253,20 +209,7 @@ describe("resumeManagedLobsterFlow", () => {
},
});
const result = await resumeManagedLobsterFlow({
taskFlow,
runner,
flowId: "flow-1",
expectedRevision: 4,
runnerParams: {
action: "resume",
token: "resume-1",
approve: true,
cwd: process.cwd(),
timeoutMs: 1000,
maxStdoutBytes: 4096,
},
});
const result = await resumeManagedLobsterFlow(createResumeFlowParams(taskFlow, runner));
expect(result.ok).toBe(true);
expect(taskFlow.setWaiting).toHaveBeenCalledWith({
+128 -188
View File
@@ -33,6 +33,124 @@ const envKeys = [
getMatrixScopedEnvVarNames("team-ops").accessToken,
];
type MatrixRoomScopeKey = "groups" | "rooms";
function createMatrixAccountConfig(accessToken: string) {
return {
homeserver: "https://matrix.example.org",
accessToken,
};
}
function createMatrixScopedEntriesConfig(scopeKey: MatrixRoomScopeKey): CoreConfig {
return {
channels: {
matrix: {
[scopeKey]: {
"!default-room:example.org": {
enabled: true,
account: "default",
},
"!axis-room:example.org": {
enabled: true,
account: "axis",
},
"!unassigned-room:example.org": {
enabled: true,
},
},
accounts: {
default: createMatrixAccountConfig("default-token"),
axis: createMatrixAccountConfig("axis-token"),
},
},
},
} as unknown as CoreConfig;
}
function createMatrixTopLevelDefaultScopedEntriesConfig(scopeKey: MatrixRoomScopeKey): CoreConfig {
return {
channels: {
matrix: {
...createMatrixAccountConfig("default-token"),
[scopeKey]: {
"!default-room:example.org": {
enabled: true,
account: "default",
},
"!ops-room:example.org": {
enabled: true,
account: "ops",
},
"!shared-room:example.org": {
enabled: true,
},
},
accounts: {
ops: createMatrixAccountConfig("ops-token"),
},
},
},
} as unknown as CoreConfig;
}
function expectMatrixScopedEntries(
cfg: CoreConfig,
scopeKey: MatrixRoomScopeKey,
accountId: string,
expected: Record<string, { enabled: true; account?: string }>,
): void {
expect(resolveMatrixAccount({ cfg, accountId }).config[scopeKey]).toEqual(expected);
}
function expectMultiAccountMatrixScopedEntries(
cfg: CoreConfig,
scopeKey: MatrixRoomScopeKey,
): void {
expectMatrixScopedEntries(cfg, scopeKey, "default", {
"!default-room:example.org": {
enabled: true,
account: "default",
},
"!unassigned-room:example.org": {
enabled: true,
},
});
expectMatrixScopedEntries(cfg, scopeKey, "axis", {
"!axis-room:example.org": {
enabled: true,
account: "axis",
},
"!unassigned-room:example.org": {
enabled: true,
},
});
}
function expectTopLevelDefaultMatrixScopedEntries(
cfg: CoreConfig,
scopeKey: MatrixRoomScopeKey,
): void {
expectMatrixScopedEntries(cfg, scopeKey, "default", {
"!default-room:example.org": {
enabled: true,
account: "default",
},
"!shared-room:example.org": {
enabled: true,
},
});
expectMatrixScopedEntries(cfg, scopeKey, "ops", {
"!ops-room:example.org": {
enabled: true,
account: "ops",
},
"!shared-room:example.org": {
enabled: true,
},
});
}
describe("resolveMatrixAccount", () => {
let prevEnv: Record<string, string | undefined> = {};
@@ -471,203 +589,25 @@ describe("resolveMatrixAccount", () => {
});
it("filters channel-level groups by room account in multi-account setups", () => {
const cfg = {
channels: {
matrix: {
groups: {
"!default-room:example.org": {
enabled: true,
account: "default",
},
"!axis-room:example.org": {
enabled: true,
account: "axis",
},
"!unassigned-room:example.org": {
enabled: true,
},
},
accounts: {
default: {
homeserver: "https://matrix.example.org",
accessToken: "default-token",
},
axis: {
homeserver: "https://matrix.example.org",
accessToken: "axis-token",
},
},
},
},
} as unknown as CoreConfig;
expect(resolveMatrixAccount({ cfg, accountId: "default" }).config.groups).toEqual({
"!default-room:example.org": {
enabled: true,
account: "default",
},
"!unassigned-room:example.org": {
enabled: true,
},
});
expect(resolveMatrixAccount({ cfg, accountId: "axis" }).config.groups).toEqual({
"!axis-room:example.org": {
enabled: true,
account: "axis",
},
"!unassigned-room:example.org": {
enabled: true,
},
});
expectMultiAccountMatrixScopedEntries(createMatrixScopedEntriesConfig("groups"), "groups");
});
it("filters channel-level groups when the default account is configured at the top level", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: "default-token",
groups: {
"!default-room:example.org": {
enabled: true,
account: "default",
},
"!ops-room:example.org": {
enabled: true,
account: "ops",
},
"!shared-room:example.org": {
enabled: true,
},
},
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
},
},
},
} as unknown as CoreConfig;
expect(resolveMatrixAccount({ cfg, accountId: "default" }).config.groups).toEqual({
"!default-room:example.org": {
enabled: true,
account: "default",
},
"!shared-room:example.org": {
enabled: true,
},
});
expect(resolveMatrixAccount({ cfg, accountId: "ops" }).config.groups).toEqual({
"!ops-room:example.org": {
enabled: true,
account: "ops",
},
"!shared-room:example.org": {
enabled: true,
},
});
expectTopLevelDefaultMatrixScopedEntries(
createMatrixTopLevelDefaultScopedEntriesConfig("groups"),
"groups",
);
});
it("filters legacy channel-level rooms by room account in multi-account setups", () => {
const cfg = {
channels: {
matrix: {
rooms: {
"!default-room:example.org": {
enabled: true,
account: "default",
},
"!axis-room:example.org": {
enabled: true,
account: "axis",
},
"!unassigned-room:example.org": {
enabled: true,
},
},
accounts: {
default: {
homeserver: "https://matrix.example.org",
accessToken: "default-token",
},
axis: {
homeserver: "https://matrix.example.org",
accessToken: "axis-token",
},
},
},
},
} as unknown as CoreConfig;
expect(resolveMatrixAccount({ cfg, accountId: "default" }).config.rooms).toEqual({
"!default-room:example.org": {
enabled: true,
account: "default",
},
"!unassigned-room:example.org": {
enabled: true,
},
});
expect(resolveMatrixAccount({ cfg, accountId: "axis" }).config.rooms).toEqual({
"!axis-room:example.org": {
enabled: true,
account: "axis",
},
"!unassigned-room:example.org": {
enabled: true,
},
});
expectMultiAccountMatrixScopedEntries(createMatrixScopedEntriesConfig("rooms"), "rooms");
});
it("filters legacy channel-level rooms when the default account is configured at the top level", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: "default-token",
rooms: {
"!default-room:example.org": {
enabled: true,
account: "default",
},
"!ops-room:example.org": {
enabled: true,
account: "ops",
},
"!shared-room:example.org": {
enabled: true,
},
},
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
},
},
},
} as unknown as CoreConfig;
expect(resolveMatrixAccount({ cfg, accountId: "default" }).config.rooms).toEqual({
"!default-room:example.org": {
enabled: true,
account: "default",
},
"!shared-room:example.org": {
enabled: true,
},
});
expect(resolveMatrixAccount({ cfg, accountId: "ops" }).config.rooms).toEqual({
"!ops-room:example.org": {
enabled: true,
account: "ops",
},
"!shared-room:example.org": {
enabled: true,
},
});
expectTopLevelDefaultMatrixScopedEntries(
createMatrixTopLevelDefaultScopedEntriesConfig("rooms"),
"rooms",
);
});
it("honors injected env when scoping room entries in multi-account setups", () => {
@@ -8,6 +8,61 @@ import {
import type { MatrixRawEvent } from "./types.js";
describe("createMatrixRoomMessageHandler inbound body formatting", () => {
type MatrixHandlerHarness = ReturnType<typeof createMatrixHandlerTestHarness>;
type FinalizedReplyContext = {
ReplyToBody?: string;
ReplyToSender?: string;
ThreadStarterBody?: string;
};
function createQuotedReplyVisibilityHarness(contextVisibility: "allowlist" | "allowlist_quote") {
return createMatrixHandlerTestHarness({
client: {
getEvent: async () =>
createMatrixTextMessageEvent({
eventId: "$quoted",
sender: "@mallory:example.org",
body: "Quoted payload",
}),
},
isDirectMessage: false,
cfg: {
channels: {
matrix: {
contextVisibility,
},
},
},
groupPolicy: "allowlist",
groupAllowFrom: ["@alice:example.org"],
roomsConfig: { "*": {} },
replyToMode: "all",
getMemberDisplayName: async (_roomId, userId) =>
userId === "@alice:example.org" ? "Alice" : "Mallory",
});
}
async function sendQuotedReply(handler: MatrixHandlerHarness["handler"]) {
await handler(
"!room:example.org",
createMatrixTextMessageEvent({
eventId: "$reply1",
sender: "@alice:example.org",
body: "@room follow up",
relatesTo: {
"m.in_reply_to": { event_id: "$quoted" },
},
mentions: { room: true },
}),
);
}
function latestFinalizedReplyContext(
finalizeInboundContext: MatrixHandlerHarness["finalizeInboundContext"],
) {
return vi.mocked(finalizeInboundContext).mock.calls.at(-1)?.[0] as FinalizedReplyContext;
}
beforeEach(() => {
installMatrixMonitorTestRuntime({
matchesMentionPatterns: () => false,
@@ -319,95 +374,22 @@ describe("createMatrixRoomMessageHandler inbound body formatting", () => {
});
it("drops quoted reply context fetched from non-allowlisted room senders", async () => {
const { handler, finalizeInboundContext } = createMatrixHandlerTestHarness({
client: {
getEvent: async () =>
createMatrixTextMessageEvent({
eventId: "$quoted",
sender: "@mallory:example.org",
body: "Quoted payload",
}),
},
isDirectMessage: false,
cfg: {
channels: {
matrix: {
contextVisibility: "allowlist",
},
},
},
groupPolicy: "allowlist",
groupAllowFrom: ["@alice:example.org"],
roomsConfig: { "*": {} },
replyToMode: "all",
getMemberDisplayName: async (_roomId, userId) =>
userId === "@alice:example.org" ? "Alice" : "Mallory",
});
const { handler, finalizeInboundContext } = createQuotedReplyVisibilityHarness("allowlist");
await handler(
"!room:example.org",
createMatrixTextMessageEvent({
eventId: "$reply1",
sender: "@alice:example.org",
body: "@room follow up",
relatesTo: {
"m.in_reply_to": { event_id: "$quoted" },
},
mentions: { room: true },
}),
);
await sendQuotedReply(handler);
const finalized = vi.mocked(finalizeInboundContext).mock.calls.at(-1)?.[0] as {
ReplyToBody?: string;
ReplyToSender?: string;
};
const finalized = latestFinalizedReplyContext(finalizeInboundContext);
expect(finalized.ReplyToBody).toBeUndefined();
expect(finalized.ReplyToSender).toBeUndefined();
});
it("keeps quoted reply context in allowlist_quote mode", async () => {
const { handler, finalizeInboundContext } = createMatrixHandlerTestHarness({
client: {
getEvent: async () =>
createMatrixTextMessageEvent({
eventId: "$quoted",
sender: "@mallory:example.org",
body: "Quoted payload",
}),
},
isDirectMessage: false,
cfg: {
channels: {
matrix: {
contextVisibility: "allowlist_quote",
},
},
},
groupPolicy: "allowlist",
groupAllowFrom: ["@alice:example.org"],
roomsConfig: { "*": {} },
replyToMode: "all",
getMemberDisplayName: async (_roomId, userId) =>
userId === "@alice:example.org" ? "Alice" : "Mallory",
});
const { handler, finalizeInboundContext } =
createQuotedReplyVisibilityHarness("allowlist_quote");
await handler(
"!room:example.org",
createMatrixTextMessageEvent({
eventId: "$reply1",
sender: "@alice:example.org",
body: "@room follow up",
relatesTo: {
"m.in_reply_to": { event_id: "$quoted" },
},
mentions: { room: true },
}),
);
await sendQuotedReply(handler);
const finalized = vi.mocked(finalizeInboundContext).mock.calls.at(-1)?.[0] as {
ReplyToBody?: string;
ReplyToSender?: string;
};
const finalized = latestFinalizedReplyContext(finalizeInboundContext);
expect(finalized.ReplyToBody).toBe("Quoted payload");
expect(finalized.ReplyToSender).toBe("Mallory");
});
@@ -68,6 +68,46 @@ function deferred<T>() {
return { promise, resolve };
}
function createFinalDeliveryFailureHandler(finalizeInboundContext: (ctx: unknown) => unknown) {
let capturedOnError:
| ((err: unknown, info: { kind: "tool" | "block" | "final" }) => void)
| undefined;
return createMatrixHandlerTestHarness({
historyLimit: 20,
groupPolicy: "open",
isDirectMessage: false,
finalizeInboundContext,
dispatchReplyFromConfig: async () => ({
queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 },
}),
createReplyDispatcherWithTyping: (params?: {
onError?: (err: unknown, info: { kind: "tool" | "block" | "final" }) => void;
}) => {
capturedOnError = params?.onError;
return {
dispatcher: {},
replyOptions: {},
markDispatchIdle: () => {},
markRunComplete: () => {},
};
},
withReplyDispatcher: async <T>(params: {
dispatcher: { markComplete?: () => void; waitForIdle?: () => Promise<void> };
run: () => Promise<T>;
onSettled?: () => void | Promise<void>;
}) => {
const result = await params.run();
capturedOnError?.(new Error("simulated delivery failure"), { kind: "final" });
params.dispatcher.markComplete?.();
await params.dispatcher.waitForIdle?.();
await params.onSettled?.();
return result;
},
});
}
describe("matrix group chat history — scenario 1: basic accumulation", () => {
it("pending messages appear in InboundHistory; trigger itself does not", async () => {
const finalizeInboundContext = vi.fn((ctx: unknown) => ctx);
@@ -447,45 +487,8 @@ describe("matrix group chat history — scenario 2: race condition safety", () =
});
it("watermark does not advance when final reply delivery fails (retry sees same history)", async () => {
// Capture the onError callback so we can fire a simulated final delivery failure
let capturedOnError:
| ((err: unknown, info: { kind: "tool" | "block" | "final" }) => void)
| undefined;
const finalizeInboundContext = vi.fn((ctx: unknown) => ctx);
const { handler } = createMatrixHandlerTestHarness({
historyLimit: 20,
groupPolicy: "open",
isDirectMessage: false,
finalizeInboundContext,
dispatchReplyFromConfig: async () => ({
queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 },
}),
createReplyDispatcherWithTyping: (params?: {
onError?: (err: unknown, info: { kind: "tool" | "block" | "final" }) => void;
}) => {
capturedOnError = params?.onError;
return {
dispatcher: {},
replyOptions: {},
markDispatchIdle: () => {},
markRunComplete: () => {},
};
},
withReplyDispatcher: async <T>(params: {
dispatcher: { markComplete?: () => void; waitForIdle?: () => Promise<void> };
run: () => Promise<T>;
onSettled?: () => void | Promise<void>;
}) => {
const result = await params.run();
capturedOnError?.(new Error("simulated delivery failure"), { kind: "final" });
params.dispatcher.markComplete?.();
await params.dispatcher.waitForIdle?.();
await params.onSettled?.();
return result;
},
});
const { handler } = createFinalDeliveryFailureHandler(finalizeInboundContext);
await handler(
DEFAULT_ROOM,
@@ -519,44 +522,8 @@ describe("matrix group chat history — scenario 2: race condition safety", () =
});
it("retrying the same failed trigger reuses the original history window", async () => {
let capturedOnError:
| ((err: unknown, info: { kind: "tool" | "block" | "final" }) => void)
| undefined;
const finalizeInboundContext = vi.fn((ctx: unknown) => ctx);
const { handler } = createMatrixHandlerTestHarness({
historyLimit: 20,
groupPolicy: "open",
isDirectMessage: false,
finalizeInboundContext,
dispatchReplyFromConfig: async () => ({
queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 },
}),
createReplyDispatcherWithTyping: (params?: {
onError?: (err: unknown, info: { kind: "tool" | "block" | "final" }) => void;
}) => {
capturedOnError = params?.onError;
return {
dispatcher: {},
replyOptions: {},
markDispatchIdle: () => {},
markRunComplete: () => {},
};
},
withReplyDispatcher: async <T>(params: {
dispatcher: { markComplete?: () => void; waitForIdle?: () => Promise<void> };
run: () => Promise<T>;
onSettled?: () => void | Promise<void>;
}) => {
const result = await params.run();
capturedOnError?.(new Error("simulated delivery failure"), { kind: "final" });
params.dispatcher.markComplete?.();
await params.dispatcher.waitForIdle?.();
await params.onSettled?.();
return result;
},
});
const { handler } = createFinalDeliveryFailureHandler(finalizeInboundContext);
await handler(
DEFAULT_ROOM,
@@ -8,6 +8,10 @@ import type { CoreConfig } from "../../types.js";
import { handleInboundMatrixReaction } from "./reaction-events.js";
const resolveMatrixApproval = vi.fn();
type MatrixReactionParams = Parameters<typeof handleInboundMatrixReaction>[0];
type MatrixReactionClient = MatrixReactionParams["client"];
type MatrixReactionCore = MatrixReactionParams["core"];
type MatrixReactionEvent = MatrixReactionParams["event"];
vi.mock("../../exec-approval-resolver.js", () => ({
isApprovalNotFoundError: (err: unknown) =>
@@ -56,49 +60,87 @@ function buildCore() {
} as unknown as Parameters<typeof handleInboundMatrixReaction>[0]["core"];
}
function createReactionClient(
getEvent: ReturnType<typeof vi.fn> = vi.fn(),
): MatrixReactionClient & { getEvent: ReturnType<typeof vi.fn> } {
return { getEvent } as unknown as MatrixReactionClient & {
getEvent: ReturnType<typeof vi.fn>;
};
}
function createReactionEvent(
params: {
eventId?: string;
targetEventId?: string;
reactionKey?: string;
} = {},
): MatrixReactionEvent {
return {
event_id: params.eventId ?? "$reaction-1",
sender: "@owner:example.org",
type: "m.reaction",
origin_server_ts: 123,
content: {
"m.relates_to": {
rel_type: "m.annotation",
event_id: params.targetEventId ?? "$approval-msg",
key: params.reactionKey ?? "✅",
},
},
} as MatrixReactionEvent;
}
async function handleReaction(params: {
client: MatrixReactionClient;
core: MatrixReactionCore;
cfg?: CoreConfig;
targetEventId?: string;
reactionKey?: string;
}): Promise<void> {
await handleInboundMatrixReaction({
client: params.client,
core: params.core,
cfg: params.cfg ?? buildConfig(),
accountId: "default",
roomId: "!ops:example.org",
event: createReactionEvent({
targetEventId: params.targetEventId,
reactionKey: params.reactionKey,
}),
senderId: "@owner:example.org",
senderLabel: "Owner",
selfUserId: "@bot:example.org",
isDirectMessage: false,
logVerboseMessage: vi.fn(),
});
}
describe("matrix approval reactions", () => {
it("resolves approval reactions instead of enqueueing a generic reaction event", async () => {
const core = buildCore();
const cfg = buildConfig();
registerMatrixApprovalReactionTarget({
roomId: "!ops:example.org",
eventId: "$approval-msg",
approvalId: "req-123",
allowedDecisions: ["allow-once", "allow-always", "deny"],
});
const client = {
getEvent: vi.fn().mockResolvedValue({
const client = createReactionClient(
vi.fn().mockResolvedValue({
event_id: "$approval-msg",
sender: "@bot:example.org",
content: { body: "approval prompt" },
}),
} as unknown as Parameters<typeof handleInboundMatrixReaction>[0]["client"];
);
await handleInboundMatrixReaction({
await handleReaction({
client,
core,
cfg: buildConfig(),
accountId: "default",
roomId: "!ops:example.org",
event: {
event_id: "$reaction-1",
origin_server_ts: 123,
content: {
"m.relates_to": {
rel_type: "m.annotation",
event_id: "$approval-msg",
key: "✅",
},
},
} as never,
senderId: "@owner:example.org",
senderLabel: "Owner",
selfUserId: "@bot:example.org",
isDirectMessage: false,
logVerboseMessage: vi.fn(),
cfg,
});
expect(resolveMatrixApproval).toHaveBeenCalledWith({
cfg: buildConfig(),
cfg,
approvalId: "req-123",
decision: "allow-once",
senderId: "@owner:example.org",
@@ -108,38 +150,21 @@ describe("matrix approval reactions", () => {
it("keeps ordinary reactions on bot messages as generic reaction events", async () => {
const core = buildCore();
const client = {
getEvent: vi.fn().mockResolvedValue({
const client = createReactionClient(
vi.fn().mockResolvedValue({
event_id: "$msg-1",
sender: "@bot:example.org",
content: {
body: "normal bot message",
},
}),
} as unknown as Parameters<typeof handleInboundMatrixReaction>[0]["client"];
);
await handleInboundMatrixReaction({
await handleReaction({
client,
core,
cfg: buildConfig(),
accountId: "default",
roomId: "!ops:example.org",
event: {
event_id: "$reaction-1",
origin_server_ts: 123,
content: {
"m.relates_to": {
rel_type: "m.annotation",
event_id: "$msg-1",
key: "👍",
},
},
} as never,
senderId: "@owner:example.org",
senderLabel: "Owner",
selfUserId: "@bot:example.org",
isDirectMessage: false,
logVerboseMessage: vi.fn(),
targetEventId: "$msg-1",
reactionKey: "👍",
});
expect(resolveMatrixApproval).not.toHaveBeenCalled();
@@ -165,36 +190,19 @@ describe("matrix approval reactions", () => {
approvalId: "req-123",
allowedDecisions: ["deny"],
});
const client = {
getEvent: vi.fn().mockResolvedValue({
const client = createReactionClient(
vi.fn().mockResolvedValue({
event_id: "$approval-msg",
sender: "@bot:example.org",
content: { body: "approval prompt" },
}),
} as unknown as Parameters<typeof handleInboundMatrixReaction>[0]["client"];
);
await handleInboundMatrixReaction({
await handleReaction({
client,
core,
cfg,
accountId: "default",
roomId: "!ops:example.org",
event: {
event_id: "$reaction-1",
origin_server_ts: 123,
content: {
"m.relates_to": {
rel_type: "m.annotation",
event_id: "$approval-msg",
key: "❌",
},
},
} as never,
senderId: "@owner:example.org",
senderLabel: "Owner",
selfUserId: "@bot:example.org",
isDirectMessage: false,
logVerboseMessage: vi.fn(),
reactionKey: "",
});
expect(resolveMatrixApproval).toHaveBeenCalledWith({
@@ -214,32 +222,11 @@ describe("matrix approval reactions", () => {
approvalId: "req-123",
allowedDecisions: ["allow-once"],
});
const client = {
getEvent: vi.fn().mockRejectedValue(new Error("boom")),
} as unknown as Parameters<typeof handleInboundMatrixReaction>[0]["client"];
const client = createReactionClient(vi.fn().mockRejectedValue(new Error("boom")));
await handleInboundMatrixReaction({
await handleReaction({
client,
core,
cfg: buildConfig(),
accountId: "default",
roomId: "!ops:example.org",
event: {
event_id: "$reaction-1",
origin_server_ts: 123,
content: {
"m.relates_to": {
rel_type: "m.annotation",
event_id: "$approval-msg",
key: "✅",
},
},
} as never,
senderId: "@owner:example.org",
senderLabel: "Owner",
selfUserId: "@bot:example.org",
isDirectMessage: false,
logVerboseMessage: vi.fn(),
});
expect(client.getEvent).not.toHaveBeenCalled();
@@ -266,32 +253,13 @@ describe("matrix approval reactions", () => {
approvalId: "plugin:req-123",
allowedDecisions: ["allow-once", "deny"],
});
const client = {
getEvent: vi.fn(),
} as unknown as Parameters<typeof handleInboundMatrixReaction>[0]["client"];
const client = createReactionClient();
await handleInboundMatrixReaction({
await handleReaction({
client,
core,
cfg,
accountId: "default",
roomId: "!ops:example.org",
event: {
event_id: "$reaction-1",
origin_server_ts: 123,
content: {
"m.relates_to": {
rel_type: "m.annotation",
event_id: "$plugin-approval-msg",
key: "✅",
},
},
} as never,
senderId: "@owner:example.org",
senderLabel: "Owner",
selfUserId: "@bot:example.org",
isDirectMessage: false,
logVerboseMessage: vi.fn(),
targetEventId: "$plugin-approval-msg",
});
expect(client.getEvent).not.toHaveBeenCalled();
@@ -315,32 +283,12 @@ describe("matrix approval reactions", () => {
approvalId: "req-123",
allowedDecisions: ["deny"],
});
const client = {
getEvent: vi.fn(),
} as unknown as Parameters<typeof handleInboundMatrixReaction>[0]["client"];
const client = createReactionClient();
await handleInboundMatrixReaction({
await handleReaction({
client,
core,
cfg: buildConfig(),
accountId: "default",
roomId: "!ops:example.org",
event: {
event_id: "$reaction-1",
origin_server_ts: 123,
content: {
"m.relates_to": {
rel_type: "m.annotation",
event_id: "$approval-msg",
key: "❌",
},
},
} as never,
senderId: "@owner:example.org",
senderLabel: "Owner",
selfUserId: "@bot:example.org",
isDirectMessage: false,
logVerboseMessage: vi.fn(),
reactionKey: "❌",
});
expect(client.getEvent).not.toHaveBeenCalled();
@@ -361,32 +309,14 @@ describe("matrix approval reactions", () => {
throw new Error("matrix config missing");
}
matrixCfg.reactionNotifications = "off";
const client = {
getEvent: vi.fn(),
} as unknown as Parameters<typeof handleInboundMatrixReaction>[0]["client"];
const client = createReactionClient();
await handleInboundMatrixReaction({
await handleReaction({
client,
core,
cfg,
accountId: "default",
roomId: "!ops:example.org",
event: {
event_id: "$reaction-1",
origin_server_ts: 123,
content: {
"m.relates_to": {
rel_type: "m.annotation",
event_id: "$msg-1",
key: "👍",
},
},
} as never,
senderId: "@owner:example.org",
senderLabel: "Owner",
selfUserId: "@bot:example.org",
isDirectMessage: false,
logVerboseMessage: vi.fn(),
targetEventId: "$msg-1",
reactionKey: "👍",
});
expect(client.getEvent).not.toHaveBeenCalled();
@@ -2,32 +2,51 @@ import { describe, expect, it, vi } from "vitest";
import type { MatrixClient } from "../sdk.js";
import { createMatrixRoomInfoResolver } from "./room-info.js";
function createClientStub() {
type RoomStateHandler = (
roomId: string,
eventType: string,
stateKey: string,
) => Promise<Record<string, unknown>>;
type RoomInfoClientStub = MatrixClient & {
getRoomStateEvent: ReturnType<typeof vi.fn>;
};
function createRoomStateClient(handler: RoomStateHandler): RoomInfoClientStub {
return {
getRoomStateEvent: vi.fn(
async (
roomId: string,
eventType: string,
stateKey: string,
): Promise<Record<string, unknown>> => {
if (eventType === "m.room.name") {
return { name: `Room ${roomId}` };
}
if (eventType === "m.room.canonical_alias") {
return {
alias: `#alias-${roomId}:example.org`,
alt_aliases: [`#alt-${roomId}:example.org`],
};
}
if (eventType === "m.room.member") {
return { displayname: `Display ${roomId}:${stateKey}` };
}
return {};
},
),
} as unknown as MatrixClient & {
getRoomStateEvent: ReturnType<typeof vi.fn>;
};
getRoomStateEvent: vi.fn(handler),
} as unknown as RoomInfoClientStub;
}
function createClientStub() {
return createRoomStateClient(async (roomId, eventType, stateKey) => {
if (eventType === "m.room.name") {
return { name: `Room ${roomId}` };
}
if (eventType === "m.room.canonical_alias") {
return {
alias: `#alias-${roomId}:example.org`,
alt_aliases: [`#alt-${roomId}:example.org`],
};
}
if (eventType === "m.room.member") {
return { displayname: `Display ${roomId}:${stateKey}` };
}
return {};
});
}
function createMissingMetadataError() {
const err = new Error("M_NOT_FOUND");
Object.assign(err, {
statusCode: 404,
body: { errcode: "M_NOT_FOUND" },
});
return err;
}
function getRoomStateCallCount(client: RoomInfoClientStub, eventType: string) {
return client.getRoomStateEvent.mock.calls.filter(([, type]) => type === eventType).length;
}
describe("createMatrixRoomInfoResolver", () => {
@@ -59,18 +78,7 @@ describe("createMatrixRoomInfoResolver", () => {
});
it("caches fallback user IDs when member display names are missing", async () => {
const client = {
getRoomStateEvent: vi.fn(
async (_roomId: string, eventType: string): Promise<Record<string, unknown>> => {
if (eventType === "m.room.member") {
return {};
}
return {};
},
),
} as unknown as MatrixClient & {
getRoomStateEvent: ReturnType<typeof vi.fn>;
};
const client = createRoomStateClient(async () => ({}));
const resolver = createMatrixRoomInfoResolver(client);
await expect(
@@ -84,16 +92,12 @@ describe("createMatrixRoomInfoResolver", () => {
});
it("marks unresolved room metadata when room info lookups fail", async () => {
const client = {
getRoomStateEvent: vi.fn(async (_roomId: string, eventType: string) => {
if (eventType === "m.room.member") {
return {};
}
throw new Error("room info unavailable");
}),
} as unknown as MatrixClient & {
getRoomStateEvent: ReturnType<typeof vi.fn>;
};
const client = createRoomStateClient(async (_roomId, eventType) => {
if (eventType === "m.room.member") {
return {};
}
throw new Error("room info unavailable");
});
const resolver = createMatrixRoomInfoResolver(client);
await expect(
@@ -106,21 +110,12 @@ describe("createMatrixRoomInfoResolver", () => {
});
it("treats missing room metadata as resolved-empty state", async () => {
const client = {
getRoomStateEvent: vi.fn(async (_roomId: string, eventType: string) => {
if (eventType === "m.room.name" || eventType === "m.room.canonical_alias") {
const err = new Error("M_NOT_FOUND");
Object.assign(err, {
statusCode: 404,
body: { errcode: "M_NOT_FOUND" },
});
throw err;
}
return {};
}),
} as unknown as MatrixClient & {
getRoomStateEvent: ReturnType<typeof vi.fn>;
};
const client = createRoomStateClient(async (_roomId, eventType) => {
if (eventType === "m.room.name" || eventType === "m.room.canonical_alias") {
throw createMissingMetadataError();
}
return {};
});
const resolver = createMatrixRoomInfoResolver(client);
await expect(
@@ -133,34 +128,24 @@ describe("createMatrixRoomInfoResolver", () => {
});
it("retries room metadata after a transient lookup failure", async () => {
const client = {
getRoomStateEvent: vi.fn(async (_roomId: string, eventType: string) => {
if (eventType === "m.room.name") {
if (
client.getRoomStateEvent.mock.calls.filter(([, type]) => type === eventType).length ===
1
) {
throw new Error("name lookup unavailable");
}
return { name: "Recovered Room" };
const client = createRoomStateClient(async (_roomId, eventType) => {
if (eventType === "m.room.name") {
if (getRoomStateCallCount(client, eventType) === 1) {
throw new Error("name lookup unavailable");
}
if (eventType === "m.room.canonical_alias") {
if (
client.getRoomStateEvent.mock.calls.filter(([, type]) => type === eventType).length ===
1
) {
throw new Error("alias lookup unavailable");
}
return {
alias: "#recovered:example.org",
alt_aliases: ["#alt-recovered:example.org"],
};
return { name: "Recovered Room" };
}
if (eventType === "m.room.canonical_alias") {
if (getRoomStateCallCount(client, eventType) === 1) {
throw new Error("alias lookup unavailable");
}
return {};
}),
} as unknown as MatrixClient & {
getRoomStateEvent: ReturnType<typeof vi.fn>;
};
return {
alias: "#recovered:example.org",
alt_aliases: ["#alt-recovered:example.org"],
};
}
return {};
});
const resolver = createMatrixRoomInfoResolver(client);
await expect(
@@ -182,13 +167,9 @@ describe("createMatrixRoomInfoResolver", () => {
});
it("caches fallback user IDs when member display-name lookups fail", async () => {
const client = {
getRoomStateEvent: vi.fn(async (): Promise<Record<string, unknown>> => {
throw new Error("member lookup failed");
}),
} as unknown as MatrixClient & {
getRoomStateEvent: ReturnType<typeof vi.fn>;
};
const client = createRoomStateClient(async () => {
throw new Error("member lookup failed");
});
const resolver = createMatrixRoomInfoResolver(client);
await expect(
@@ -17,6 +17,33 @@ const baseCfg = {
},
} satisfies OpenClawConfig;
type RouteBinding = NonNullable<OpenClawConfig["bindings"]>[number];
type RoutePeer = NonNullable<RouteBinding["match"]["peer"]>;
function matrixBinding(
agentId: string,
peer?: RoutePeer,
type?: RouteBinding["type"],
): RouteBinding {
return {
...(type ? { type } : {}),
agentId,
match: {
channel: "matrix",
accountId: "ops",
...(peer ? { peer } : {}),
},
} as RouteBinding;
}
function senderPeer(id = "@alice:example.org"): RoutePeer {
return { kind: "direct", id };
}
function dmRoomPeer(id = "!dm:example.org"): RoutePeer {
return { kind: "channel", id };
}
function resolveDmRoute(
cfg: OpenClawConfig,
opts: {
@@ -46,22 +73,8 @@ describe("resolveMatrixInboundRoute", () => {
const cfg = {
...baseCfg,
bindings: [
{
agentId: "room-agent",
match: {
channel: "matrix",
accountId: "ops",
peer: { kind: "channel", id: "!dm:example.org" },
},
},
{
agentId: "sender-agent",
match: {
channel: "matrix",
accountId: "ops",
peer: { kind: "direct", id: "@alice:example.org" },
},
},
matrixBinding("room-agent", dmRoomPeer()),
matrixBinding("sender-agent", senderPeer()),
],
} satisfies OpenClawConfig;
@@ -76,23 +89,7 @@ describe("resolveMatrixInboundRoute", () => {
it("uses the DM room as a parent-peer fallback before account-level bindings", () => {
const cfg = {
...baseCfg,
bindings: [
{
agentId: "acp-agent",
match: {
channel: "matrix",
accountId: "ops",
},
},
{
agentId: "room-agent",
match: {
channel: "matrix",
accountId: "ops",
peer: { kind: "channel", id: "!dm:example.org" },
},
},
],
bindings: [matrixBinding("acp-agent"), matrixBinding("room-agent", dmRoomPeer())],
} satisfies OpenClawConfig;
const { route, configuredBinding } = resolveDmRoute(cfg);
@@ -106,16 +103,7 @@ describe("resolveMatrixInboundRoute", () => {
it("can isolate Matrix DMs per room without changing agent selection", () => {
const cfg = {
...baseCfg,
bindings: [
{
agentId: "sender-agent",
match: {
channel: "matrix",
accountId: "ops",
peer: { kind: "direct", id: "@alice:example.org" },
},
},
],
bindings: [matrixBinding("sender-agent", senderPeer())],
} satisfies OpenClawConfig;
const { route, configuredBinding } = resolveDmRoute(cfg, {
@@ -134,23 +122,8 @@ describe("resolveMatrixInboundRoute", () => {
const cfg = {
...baseCfg,
bindings: [
{
agentId: "room-agent",
match: {
channel: "matrix",
accountId: "ops",
peer: { kind: "channel", id: "!dm:example.org" },
},
},
{
type: "acp",
agentId: "acp-agent",
match: {
channel: "matrix",
accountId: "ops",
peer: { kind: "channel", id: "!dm:example.org" },
},
},
matrixBinding("room-agent", dmRoomPeer()),
matrixBinding("acp-agent", dmRoomPeer(), "acp"),
],
} satisfies OpenClawConfig;
@@ -167,23 +140,8 @@ describe("resolveMatrixInboundRoute", () => {
const cfg = {
...baseCfg,
bindings: [
{
agentId: "room-agent",
match: {
channel: "matrix",
accountId: "ops",
peer: { kind: "channel", id: "!dm:example.org" },
},
},
{
type: "acp",
agentId: "acp-agent",
match: {
channel: "matrix",
accountId: "ops",
peer: { kind: "channel", id: "!dm:example.org" },
},
},
matrixBinding("room-agent", dmRoomPeer()),
matrixBinding("acp-agent", dmRoomPeer(), "acp"),
],
} satisfies OpenClawConfig;
@@ -227,22 +185,8 @@ describe("resolveMatrixInboundRoute", () => {
const cfg = {
...baseCfg,
bindings: [
{
agentId: "sender-agent",
match: {
channel: "matrix",
accountId: "ops",
peer: { kind: "direct", id: "@alice:example.org" },
},
},
{
agentId: "room-agent",
match: {
channel: "matrix",
accountId: "ops",
peer: { kind: "channel", id: "!dm:example.org" },
},
},
matrixBinding("sender-agent", senderPeer()),
matrixBinding("room-agent", dmRoomPeer()),
],
} satisfies OpenClawConfig;
@@ -11,6 +11,7 @@ import {
resolveMatrixStoragePaths,
writeStorageMeta,
} from "./client/storage.js";
import type { MatrixAuth, MatrixStoragePaths } from "./client/types.js";
import {
createMatrixThreadBindingManager,
resetMatrixThreadBindingsForTests,
@@ -45,6 +46,11 @@ describe("matrix thread bindings", () => {
const idleTimeoutMs = 24 * 60 * 60 * 1000;
const matrixClient = {} as never;
function resetThreadBindingAdapters() {
__testing.resetSessionBindingAdaptersForTests();
resetMatrixThreadBindingsForTests();
}
function currentThreadConversation(params?: {
conversationId?: string;
parentConversationId?: string;
@@ -57,17 +63,32 @@ describe("matrix thread bindings", () => {
};
}
async function createStaticThreadBindingManager() {
function createBindingManager(
params: {
auth?: MatrixAuth;
stateDir?: string;
idleTimeoutMs?: number;
maxAgeMs?: number;
enableSweeper?: boolean;
logVerboseMessage?: (message: string) => void;
} = {},
) {
return createMatrixThreadBindingManager({
accountId,
auth,
auth: params.auth ?? auth,
client: matrixClient,
idleTimeoutMs,
maxAgeMs: 0,
enableSweeper: false,
...(params.stateDir ? { stateDir: params.stateDir } : {}),
idleTimeoutMs: params.idleTimeoutMs ?? idleTimeoutMs,
maxAgeMs: params.maxAgeMs ?? 0,
enableSweeper: params.enableSweeper ?? false,
...(params.logVerboseMessage ? { logVerboseMessage: params.logVerboseMessage } : {}),
});
}
async function createStaticThreadBindingManager() {
return createBindingManager();
}
async function bindCurrentThread(params?: {
targetSessionKey?: string;
conversationId?: string;
@@ -95,6 +116,16 @@ describe("matrix thread bindings", () => {
});
}
function writeAuthStorageMeta(authForMeta: MatrixAuth, storagePaths: MatrixStoragePaths) {
writeStorageMeta({
storagePaths,
homeserver: authForMeta.homeserver,
userId: authForMeta.userId,
accountId: authForMeta.accountId,
deviceId: authForMeta.deviceId ?? null,
});
}
async function readPersistedLastActivityAt(bindingsPath: string) {
const raw = await fs.readFile(bindingsPath, "utf-8");
const parsed = JSON.parse(raw) as {
@@ -103,10 +134,32 @@ describe("matrix thread bindings", () => {
return parsed.bindings?.[0]?.lastActivityAt;
}
async function expectPersistedThreadBinding(
bindingsPath: string,
expected: {
conversationId: string;
targetSessionKey: string;
parentConversationId?: string;
},
) {
await vi.waitFor(async () => {
const persistedRaw = await fs.readFile(bindingsPath, "utf-8");
expect(JSON.parse(persistedRaw)).toMatchObject({
version: 1,
bindings: [
expect.objectContaining({
conversationId: expected.conversationId,
parentConversationId: expected.parentConversationId ?? "!room:example",
targetSessionKey: expected.targetSessionKey,
}),
],
});
});
}
beforeEach(() => {
stateDir = fsSync.mkdtempSync(path.join(os.tmpdir(), "matrix-thread-bindings-"));
__testing.resetSessionBindingAdaptersForTests();
resetMatrixThreadBindingsForTests();
resetThreadBindingAdapters();
sendMessageMatrixMock.mockClear();
renameMock.mockReset();
renameMock.mockImplementation(actualRename);
@@ -384,50 +437,19 @@ describe("matrix thread bindings", () => {
accessToken: "token-new",
};
const initialManager = await createMatrixThreadBindingManager({
accountId: "ops",
auth: initialAuth,
client: {} as never,
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
enableSweeper: false,
});
const initialManager = await createBindingManager({ auth: initialAuth });
await getSessionBindingService().bind({
targetSessionKey: "agent:ops:subagent:child",
targetKind: "subagent",
conversation: {
channel: "matrix",
accountId: "ops",
conversationId: "$thread",
parentConversationId: "!room:example",
},
placement: "current",
});
await bindCurrentThread();
const initialStoragePaths = resolveMatrixStoragePaths({
...initialAuth,
env: process.env,
});
writeStorageMeta({
storagePaths: initialStoragePaths,
homeserver: initialAuth.homeserver,
userId: initialAuth.userId,
accountId: initialAuth.accountId,
deviceId: null,
});
writeAuthStorageMeta(initialAuth, initialStoragePaths);
initialManager.stop();
resetMatrixThreadBindingsForTests();
__testing.resetSessionBindingAdaptersForTests();
resetThreadBindingAdapters();
await createMatrixThreadBindingManager({
accountId: "ops",
auth: rotatedAuth,
client: {} as never,
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
enableSweeper: false,
});
await createBindingManager({ auth: rotatedAuth });
expect(
getSessionBindingService().resolveByConversation({
@@ -461,64 +483,24 @@ describe("matrix thread bindings", () => {
deviceId: "DEVICE123",
};
const initialManager = await createMatrixThreadBindingManager({
accountId: "ops",
auth: initialAuth,
client: {} as never,
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
enableSweeper: false,
});
const initialManager = await createBindingManager({ auth: initialAuth });
await getSessionBindingService().bind({
targetSessionKey: "agent:ops:subagent:child",
targetKind: "subagent",
conversation: {
channel: "matrix",
accountId: "ops",
conversationId: "$thread",
parentConversationId: "!room:example",
},
placement: "current",
});
await bindCurrentThread();
const initialStoragePaths = resolveMatrixStoragePaths({
...initialAuth,
env: process.env,
});
writeStorageMeta({
storagePaths: initialStoragePaths,
homeserver: initialAuth.homeserver,
userId: initialAuth.userId,
accountId: initialAuth.accountId,
deviceId: initialAuth.deviceId,
});
writeAuthStorageMeta(initialAuth, initialStoragePaths);
const initialBindingsPath = path.join(initialStoragePaths.rootDir, "thread-bindings.json");
await vi.waitFor(async () => {
const persistedRaw = await fs.readFile(initialBindingsPath, "utf-8");
expect(JSON.parse(persistedRaw)).toMatchObject({
version: 1,
bindings: [
expect.objectContaining({
conversationId: "$thread",
parentConversationId: "!room:example",
targetSessionKey: "agent:ops:subagent:child",
}),
],
});
await expectPersistedThreadBinding(initialBindingsPath, {
conversationId: "$thread",
targetSessionKey: "agent:ops:subagent:child",
});
initialManager.stop();
resetMatrixThreadBindingsForTests();
__testing.resetSessionBindingAdaptersForTests();
resetThreadBindingAdapters();
await createMatrixThreadBindingManager({
accountId: "ops",
auth: rotatedAuth,
client: {} as never,
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
enableSweeper: false,
});
await createBindingManager({ auth: rotatedAuth });
expect(
getSessionBindingService().resolveByConversation({
@@ -547,36 +529,14 @@ describe("matrix thread bindings", () => {
path.join(os.tmpdir(), "matrix-thread-bindings-replacement-"),
);
const initialManager = await createMatrixThreadBindingManager({
accountId: "ops",
auth,
client: {} as never,
const initialManager = await createBindingManager({
stateDir: initialStateDir,
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
enableSweeper: false,
});
await getSessionBindingService().bind({
targetSessionKey: "agent:ops:subagent:child",
targetKind: "subagent",
conversation: {
channel: "matrix",
accountId: "ops",
conversationId: "$thread",
parentConversationId: "!room:example",
},
placement: "current",
});
await bindCurrentThread();
const replacementManager = await createMatrixThreadBindingManager({
accountId: "ops",
auth,
client: {} as never,
const replacementManager = await createBindingManager({
stateDir: replacementStateDir,
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
enableSweeper: false,
});
expect(replacementManager).not.toBe(initialManager);
@@ -590,46 +550,18 @@ describe("matrix thread bindings", () => {
}),
).toBeNull();
await getSessionBindingService().bind({
await bindCurrentThread({
targetSessionKey: "agent:ops:subagent:replacement",
targetKind: "subagent",
conversation: {
channel: "matrix",
accountId: "ops",
conversationId: "$thread-2",
parentConversationId: "!room:example",
},
placement: "current",
conversationId: "$thread-2",
});
await vi.waitFor(async () => {
const replacementRaw = await fs.readFile(
resolveBindingsFilePath(replacementStateDir),
"utf-8",
);
expect(JSON.parse(replacementRaw)).toMatchObject({
version: 1,
bindings: [
expect.objectContaining({
conversationId: "$thread-2",
parentConversationId: "!room:example",
targetSessionKey: "agent:ops:subagent:replacement",
}),
],
});
await expectPersistedThreadBinding(resolveBindingsFilePath(replacementStateDir), {
conversationId: "$thread-2",
targetSessionKey: "agent:ops:subagent:replacement",
});
await vi.waitFor(async () => {
const initialRaw = await fs.readFile(resolveBindingsFilePath(initialStateDir), "utf-8");
expect(JSON.parse(initialRaw)).toMatchObject({
version: 1,
bindings: [
expect.objectContaining({
conversationId: "$thread",
parentConversationId: "!room:example",
targetSessionKey: "agent:ops:subagent:child",
}),
],
});
await expectPersistedThreadBinding(resolveBindingsFilePath(initialStateDir), {
conversationId: "$thread",
targetSessionKey: "agent:ops:subagent:child",
});
});
+159 -327
View File
@@ -6,6 +6,25 @@ import type { OpenClawConfig } from "./runtime-api.js";
import { resolveMatrixOutboundSessionRoute } from "./session-route.js";
const tempDirs = new Set<string>();
const currentDmSessionKey = "agent:main:matrix:channel:!dm:example.org";
type MatrixChannelConfig = NonNullable<NonNullable<OpenClawConfig["channels"]>["matrix"]>;
const perRoomDmMatrixConfig = {
dm: {
sessionScope: "per-room",
},
} satisfies MatrixChannelConfig;
const defaultAccountPerRoomDmMatrixConfig = {
defaultAccount: "ops",
accounts: {
ops: {
dm: {
sessionScope: "per-room",
},
},
},
} satisfies MatrixChannelConfig;
function createTempStore(entries: Record<string, unknown>): string {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-session-route-"));
@@ -15,6 +34,98 @@ function createTempStore(entries: Record<string, unknown>): string {
return storePath;
}
function createMatrixRouteConfig(
entries: Record<string, unknown>,
matrix: MatrixChannelConfig = perRoomDmMatrixConfig,
): OpenClawConfig {
return {
session: {
store: createTempStore(entries),
},
channels: {
matrix,
},
} satisfies OpenClawConfig;
}
function createStoredDirectDmSession(
params: {
from?: string;
to?: string;
accountId?: string | null;
nativeChannelId?: string;
nativeDirectUserId?: string;
lastTo?: string;
lastAccountId?: string;
} = {},
): Record<string, unknown> {
const accountId = params.accountId === null ? undefined : (params.accountId ?? "ops");
const to = params.to ?? "room:!dm:example.org";
const accountMetadata = accountId ? { accountId } : {};
const nativeMetadata = {
...(params.nativeChannelId ? { nativeChannelId: params.nativeChannelId } : {}),
...(params.nativeDirectUserId ? { nativeDirectUserId: params.nativeDirectUserId } : {}),
};
return {
sessionId: "sess-1",
updatedAt: Date.now(),
chatType: "direct",
origin: {
chatType: "direct",
from: params.from ?? "matrix:@alice:example.org",
to,
...nativeMetadata,
...accountMetadata,
},
deliveryContext: {
channel: "matrix",
to,
...accountMetadata,
},
...(params.lastTo ? { lastTo: params.lastTo } : {}),
...(params.lastAccountId ? { lastAccountId: params.lastAccountId } : {}),
};
}
function createStoredChannelSession(): Record<string, unknown> {
return {
sessionId: "sess-1",
updatedAt: Date.now(),
chatType: "channel",
origin: {
chatType: "channel",
from: "matrix:channel:!ops:example.org",
to: "room:!ops:example.org",
nativeChannelId: "!ops:example.org",
nativeDirectUserId: "@alice:example.org",
accountId: "ops",
},
deliveryContext: {
channel: "matrix",
to: "room:!ops:example.org",
accountId: "ops",
},
lastTo: "room:!ops:example.org",
lastAccountId: "ops",
};
}
function resolveUserRoute(params: { cfg: OpenClawConfig; accountId?: string; target?: string }) {
const target = params.target ?? "@alice:example.org";
return resolveMatrixOutboundSessionRoute({
cfg: params.cfg,
agentId: "main",
...(params.accountId ? { accountId: params.accountId } : {}),
currentSessionKey: currentDmSessionKey,
target,
resolvedTarget: {
to: target,
kind: "user",
source: "normalized",
},
});
}
afterEach(() => {
for (const tempDir of tempDirs) {
fs.rmSync(tempDir, { recursive: true, force: true });
@@ -24,53 +135,18 @@ afterEach(() => {
describe("resolveMatrixOutboundSessionRoute", () => {
it("reuses the current DM room session for same-user sends when Matrix DMs are per-room", () => {
const storePath = createTempStore({
"agent:main:matrix:channel:!dm:example.org": {
sessionId: "sess-1",
updatedAt: Date.now(),
chatType: "direct",
origin: {
chatType: "direct",
from: "matrix:@alice:example.org",
to: "room:!dm:example.org",
accountId: "ops",
},
deliveryContext: {
channel: "matrix",
to: "room:!dm:example.org",
accountId: "ops",
},
},
const cfg = createMatrixRouteConfig({
[currentDmSessionKey]: createStoredDirectDmSession(),
});
const cfg = {
session: {
store: storePath,
},
channels: {
matrix: {
dm: {
sessionScope: "per-room",
},
},
},
} satisfies OpenClawConfig;
const route = resolveMatrixOutboundSessionRoute({
const route = resolveUserRoute({
cfg,
agentId: "main",
accountId: "ops",
currentSessionKey: "agent:main:matrix:channel:!dm:example.org",
target: "@alice:example.org",
resolvedTarget: {
to: "@alice:example.org",
kind: "user",
source: "normalized",
},
});
expect(route).toMatchObject({
sessionKey: "agent:main:matrix:channel:!dm:example.org",
baseSessionKey: "agent:main:matrix:channel:!dm:example.org",
sessionKey: currentDmSessionKey,
baseSessionKey: currentDmSessionKey,
peer: { kind: "channel", id: "!dm:example.org" },
chatType: "direct",
from: "matrix:@alice:example.org",
@@ -79,48 +155,13 @@ describe("resolveMatrixOutboundSessionRoute", () => {
});
it("falls back to user-scoped routing when the current session is for another DM peer", () => {
const storePath = createTempStore({
"agent:main:matrix:channel:!dm:example.org": {
sessionId: "sess-1",
updatedAt: Date.now(),
chatType: "direct",
origin: {
chatType: "direct",
from: "matrix:@bob:example.org",
to: "room:!dm:example.org",
accountId: "ops",
},
deliveryContext: {
channel: "matrix",
to: "room:!dm:example.org",
accountId: "ops",
},
},
const cfg = createMatrixRouteConfig({
[currentDmSessionKey]: createStoredDirectDmSession({ from: "matrix:@bob:example.org" }),
});
const cfg = {
session: {
store: storePath,
},
channels: {
matrix: {
dm: {
sessionScope: "per-room",
},
},
},
} satisfies OpenClawConfig;
const route = resolveMatrixOutboundSessionRoute({
const route = resolveUserRoute({
cfg,
agentId: "main",
accountId: "ops",
currentSessionKey: "agent:main:matrix:channel:!dm:example.org",
target: "@alice:example.org",
resolvedTarget: {
to: "@alice:example.org",
kind: "user",
source: "normalized",
},
});
expect(route).toMatchObject({
@@ -134,48 +175,13 @@ describe("resolveMatrixOutboundSessionRoute", () => {
});
it("falls back to user-scoped routing when the current session belongs to another Matrix account", () => {
const storePath = createTempStore({
"agent:main:matrix:channel:!dm:example.org": {
sessionId: "sess-1",
updatedAt: Date.now(),
chatType: "direct",
origin: {
chatType: "direct",
from: "matrix:@alice:example.org",
to: "room:!dm:example.org",
accountId: "ops",
},
deliveryContext: {
channel: "matrix",
to: "room:!dm:example.org",
accountId: "ops",
},
},
const cfg = createMatrixRouteConfig({
[currentDmSessionKey]: createStoredDirectDmSession(),
});
const cfg = {
session: {
store: storePath,
},
channels: {
matrix: {
dm: {
sessionScope: "per-room",
},
},
},
} satisfies OpenClawConfig;
const route = resolveMatrixOutboundSessionRoute({
const route = resolveUserRoute({
cfg,
agentId: "main",
accountId: "support",
currentSessionKey: "agent:main:matrix:channel:!dm:example.org",
target: "@alice:example.org",
resolvedTarget: {
to: "@alice:example.org",
kind: "user",
source: "normalized",
},
});
expect(route).toMatchObject({
@@ -189,57 +195,25 @@ describe("resolveMatrixOutboundSessionRoute", () => {
});
it("reuses the canonical DM room after user-target outbound metadata overwrites latest to fields", () => {
const storePath = createTempStore({
"agent:main:matrix:channel:!dm:example.org": {
sessionId: "sess-1",
updatedAt: Date.now(),
chatType: "direct",
origin: {
chatType: "direct",
from: "matrix:@bob:example.org",
to: "room:@bob:example.org",
nativeChannelId: "!dm:example.org",
nativeDirectUserId: "@alice:example.org",
accountId: "ops",
},
deliveryContext: {
channel: "matrix",
to: "room:@bob:example.org",
accountId: "ops",
},
const cfg = createMatrixRouteConfig({
[currentDmSessionKey]: createStoredDirectDmSession({
from: "matrix:@bob:example.org",
to: "room:@bob:example.org",
nativeChannelId: "!dm:example.org",
nativeDirectUserId: "@alice:example.org",
lastTo: "room:@bob:example.org",
lastAccountId: "ops",
},
}),
});
const cfg = {
session: {
store: storePath,
},
channels: {
matrix: {
dm: {
sessionScope: "per-room",
},
},
},
} satisfies OpenClawConfig;
const route = resolveMatrixOutboundSessionRoute({
const route = resolveUserRoute({
cfg,
agentId: "main",
accountId: "ops",
currentSessionKey: "agent:main:matrix:channel:!dm:example.org",
target: "@alice:example.org",
resolvedTarget: {
to: "@alice:example.org",
kind: "user",
source: "normalized",
},
});
expect(route).toMatchObject({
sessionKey: "agent:main:matrix:channel:!dm:example.org",
baseSessionKey: "agent:main:matrix:channel:!dm:example.org",
sessionKey: currentDmSessionKey,
baseSessionKey: currentDmSessionKey,
peer: { kind: "channel", id: "!dm:example.org" },
chatType: "direct",
from: "matrix:@alice:example.org",
@@ -248,52 +222,21 @@ describe("resolveMatrixOutboundSessionRoute", () => {
});
it("does not reuse the canonical DM room for a different Matrix user after latest metadata drift", () => {
const storePath = createTempStore({
"agent:main:matrix:channel:!dm:example.org": {
sessionId: "sess-1",
updatedAt: Date.now(),
chatType: "direct",
origin: {
chatType: "direct",
from: "matrix:@bob:example.org",
to: "room:@bob:example.org",
nativeChannelId: "!dm:example.org",
nativeDirectUserId: "@alice:example.org",
accountId: "ops",
},
deliveryContext: {
channel: "matrix",
to: "room:@bob:example.org",
accountId: "ops",
},
const cfg = createMatrixRouteConfig({
[currentDmSessionKey]: createStoredDirectDmSession({
from: "matrix:@bob:example.org",
to: "room:@bob:example.org",
nativeChannelId: "!dm:example.org",
nativeDirectUserId: "@alice:example.org",
lastTo: "room:@bob:example.org",
lastAccountId: "ops",
},
}),
});
const cfg = {
session: {
store: storePath,
},
channels: {
matrix: {
dm: {
sessionScope: "per-room",
},
},
},
} satisfies OpenClawConfig;
const route = resolveMatrixOutboundSessionRoute({
const route = resolveUserRoute({
cfg,
agentId: "main",
accountId: "ops",
currentSessionKey: "agent:main:matrix:channel:!dm:example.org",
target: "@bob:example.org",
resolvedTarget: {
to: "@bob:example.org",
kind: "user",
source: "normalized",
},
});
expect(route).toMatchObject({
@@ -307,52 +250,13 @@ describe("resolveMatrixOutboundSessionRoute", () => {
});
it("does not reuse a room after the session metadata was overwritten by a non-DM Matrix send", () => {
const storePath = createTempStore({
"agent:main:matrix:channel:!dm:example.org": {
sessionId: "sess-1",
updatedAt: Date.now(),
chatType: "channel",
origin: {
chatType: "channel",
from: "matrix:channel:!ops:example.org",
to: "room:!ops:example.org",
nativeChannelId: "!ops:example.org",
nativeDirectUserId: "@alice:example.org",
accountId: "ops",
},
deliveryContext: {
channel: "matrix",
to: "room:!ops:example.org",
accountId: "ops",
},
lastTo: "room:!ops:example.org",
lastAccountId: "ops",
},
const cfg = createMatrixRouteConfig({
[currentDmSessionKey]: createStoredChannelSession(),
});
const cfg = {
session: {
store: storePath,
},
channels: {
matrix: {
dm: {
sessionScope: "per-room",
},
},
},
} satisfies OpenClawConfig;
const route = resolveMatrixOutboundSessionRoute({
const route = resolveUserRoute({
cfg,
agentId: "main",
accountId: "ops",
currentSessionKey: "agent:main:matrix:channel:!dm:example.org",
target: "@alice:example.org",
resolvedTarget: {
to: "@alice:example.org",
kind: "user",
source: "normalized",
},
});
expect(route).toMatchObject({
@@ -366,57 +270,20 @@ describe("resolveMatrixOutboundSessionRoute", () => {
});
it("uses the effective default Matrix account when accountId is omitted", () => {
const storePath = createTempStore({
"agent:main:matrix:channel:!dm:example.org": {
sessionId: "sess-1",
updatedAt: Date.now(),
chatType: "direct",
origin: {
chatType: "direct",
from: "matrix:@alice:example.org",
to: "room:!dm:example.org",
accountId: "ops",
},
deliveryContext: {
channel: "matrix",
to: "room:!dm:example.org",
accountId: "ops",
},
const cfg = createMatrixRouteConfig(
{
[currentDmSessionKey]: createStoredDirectDmSession(),
},
});
const cfg = {
session: {
store: storePath,
},
channels: {
matrix: {
defaultAccount: "ops",
accounts: {
ops: {
dm: {
sessionScope: "per-room",
},
},
},
},
},
} satisfies OpenClawConfig;
defaultAccountPerRoomDmMatrixConfig,
);
const route = resolveMatrixOutboundSessionRoute({
const route = resolveUserRoute({
cfg,
agentId: "main",
currentSessionKey: "agent:main:matrix:channel:!dm:example.org",
target: "@alice:example.org",
resolvedTarget: {
to: "@alice:example.org",
kind: "user",
source: "normalized",
},
});
expect(route).toMatchObject({
sessionKey: "agent:main:matrix:channel:!dm:example.org",
baseSessionKey: "agent:main:matrix:channel:!dm:example.org",
sessionKey: currentDmSessionKey,
baseSessionKey: currentDmSessionKey,
peer: { kind: "channel", id: "!dm:example.org" },
chatType: "direct",
from: "matrix:@alice:example.org",
@@ -425,55 +292,20 @@ describe("resolveMatrixOutboundSessionRoute", () => {
});
it("reuses the current DM room when stored account metadata is missing", () => {
const storePath = createTempStore({
"agent:main:matrix:channel:!dm:example.org": {
sessionId: "sess-1",
updatedAt: Date.now(),
chatType: "direct",
origin: {
chatType: "direct",
from: "matrix:@alice:example.org",
to: "room:!dm:example.org",
},
deliveryContext: {
channel: "matrix",
to: "room:!dm:example.org",
},
const cfg = createMatrixRouteConfig(
{
[currentDmSessionKey]: createStoredDirectDmSession({ accountId: null }),
},
});
const cfg = {
session: {
store: storePath,
},
channels: {
matrix: {
defaultAccount: "ops",
accounts: {
ops: {
dm: {
sessionScope: "per-room",
},
},
},
},
},
} satisfies OpenClawConfig;
defaultAccountPerRoomDmMatrixConfig,
);
const route = resolveMatrixOutboundSessionRoute({
const route = resolveUserRoute({
cfg,
agentId: "main",
currentSessionKey: "agent:main:matrix:channel:!dm:example.org",
target: "@alice:example.org",
resolvedTarget: {
to: "@alice:example.org",
kind: "user",
source: "normalized",
},
});
expect(route).toMatchObject({
sessionKey: "agent:main:matrix:channel:!dm:example.org",
baseSessionKey: "agent:main:matrix:channel:!dm:example.org",
sessionKey: currentDmSessionKey,
baseSessionKey: currentDmSessionKey,
peer: { kind: "channel", id: "!dm:example.org" },
chatType: "direct",
from: "matrix:@alice:example.org",
+39 -48
View File
@@ -2,6 +2,39 @@ import { describe, expect, it } from "vitest";
import { matrixSetupAdapter } from "./setup-core.js";
import type { CoreConfig } from "./types.js";
function applyOpsAccountConfig(cfg: CoreConfig): CoreConfig {
return matrixSetupAdapter.applyAccountConfig({
cfg,
accountId: "ops",
input: {
name: "Ops",
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
}) as CoreConfig;
}
function expectPromotedDefaultAccount(next: CoreConfig): void {
expect(next.channels?.matrix?.accounts?.Default).toMatchObject({
enabled: true,
deviceName: "Legacy raw key",
homeserver: "https://matrix.example.org",
userId: "@default:example.org",
accessToken: "default-token",
avatarUrl: "mxc://example.org/default-avatar",
});
expect(next.channels?.matrix?.accounts?.default).toBeUndefined();
}
function expectOpsAccount(next: CoreConfig): void {
expect(next.channels?.matrix?.accounts?.ops).toMatchObject({
name: "Ops",
enabled: true,
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
});
}
describe("matrixSetupAdapter", () => {
it("moves legacy default config before writing a named account", () => {
const cfg = {
@@ -63,31 +96,10 @@ describe("matrixSetupAdapter", () => {
},
} as CoreConfig;
const next = matrixSetupAdapter.applyAccountConfig({
cfg,
accountId: "ops",
input: {
name: "Ops",
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
}) as CoreConfig;
const next = applyOpsAccountConfig(cfg);
expect(next.channels?.matrix?.accounts?.Default).toMatchObject({
enabled: true,
deviceName: "Legacy raw key",
homeserver: "https://matrix.example.org",
userId: "@default:example.org",
accessToken: "default-token",
avatarUrl: "mxc://example.org/default-avatar",
});
expect(next.channels?.matrix?.accounts?.default).toBeUndefined();
expect(next.channels?.matrix?.accounts?.ops).toMatchObject({
name: "Ops",
enabled: true,
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
});
expectPromotedDefaultAccount(next);
expectOpsAccount(next);
});
it("reuses an existing raw default-like key during promotion when defaultAccount is unset", () => {
@@ -112,35 +124,14 @@ describe("matrixSetupAdapter", () => {
},
} as CoreConfig;
const next = matrixSetupAdapter.applyAccountConfig({
cfg,
accountId: "ops",
input: {
name: "Ops",
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
}) as CoreConfig;
const next = applyOpsAccountConfig(cfg);
expect(next.channels?.matrix?.accounts?.Default).toMatchObject({
enabled: true,
deviceName: "Legacy raw key",
homeserver: "https://matrix.example.org",
userId: "@default:example.org",
accessToken: "default-token",
avatarUrl: "mxc://example.org/default-avatar",
});
expect(next.channels?.matrix?.accounts?.default).toBeUndefined();
expectPromotedDefaultAccount(next);
expect(next.channels?.matrix?.accounts?.support).toMatchObject({
homeserver: "https://matrix.example.org",
accessToken: "support-token",
});
expect(next.channels?.matrix?.accounts?.ops).toMatchObject({
name: "Ops",
enabled: true,
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
});
expectOpsAccount(next);
});
it("clears stored auth fields when switching an account to env-backed auth", () => {
+26 -42
View File
@@ -14,6 +14,30 @@ import { createMemoryWikiTestHarness } from "./test-helpers.js";
const { createVault } = createMemoryWikiTestHarness();
async function resolveBridgeMissingArtifactsStatus() {
const config = resolveMemoryWikiConfig(
{
vaultMode: "bridge",
bridge: {
enabled: true,
readMemoryArtifacts: true,
},
},
{ homedir: "/Users/tester" },
);
return resolveMemoryWikiStatus(config, {
appConfig: {
agents: {
list: [{ id: "main", default: true, workspace: "/tmp/workspace" }],
},
} as OpenClawConfig,
listPublicArtifacts: async () => [],
pathExists: async () => true,
resolveCommand: async () => null,
});
}
describe("resolveMemoryWikiStatus", () => {
it("reports missing vault and missing requested obsidian cli", async () => {
const config = resolveMemoryWikiConfig(
@@ -61,27 +85,7 @@ describe("resolveMemoryWikiStatus", () => {
});
it("warns when bridge mode has no exported memory artifacts", async () => {
const config = resolveMemoryWikiConfig(
{
vaultMode: "bridge",
bridge: {
enabled: true,
readMemoryArtifacts: true,
},
},
{ homedir: "/Users/tester" },
);
const status = await resolveMemoryWikiStatus(config, {
appConfig: {
agents: {
list: [{ id: "main", default: true, workspace: "/tmp/workspace" }],
},
} as OpenClawConfig,
listPublicArtifacts: async () => [],
pathExists: async () => true,
resolveCommand: async () => null,
});
const status = await resolveBridgeMissingArtifactsStatus();
expect(status.bridgePublicArtifactCount).toBe(0);
expect(status.warnings.map((warning) => warning.code)).toContain("bridge-artifacts-missing");
@@ -235,27 +239,7 @@ describe("memory wiki doctor", () => {
});
it("suggests bridge fixes when no public artifacts are exported", async () => {
const config = resolveMemoryWikiConfig(
{
vaultMode: "bridge",
bridge: {
enabled: true,
readMemoryArtifacts: true,
},
},
{ homedir: "/Users/tester" },
);
const status = await resolveMemoryWikiStatus(config, {
appConfig: {
agents: {
list: [{ id: "main", default: true, workspace: "/tmp/workspace" }],
},
} as OpenClawConfig,
listPublicArtifacts: async () => [],
pathExists: async () => true,
resolveCommand: async () => null,
});
const status = await resolveBridgeMissingArtifactsStatus();
const report = buildMemoryWikiDoctorReport(status);
expect(report.fixes.map((fix) => fix.code)).toContain("bridge-artifacts-missing");
@@ -1,60 +1,42 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildMinimaxMusicGenerationProvider } from "./music-generation-provider.js";
import { beforeAll, describe, expect, it, vi } from "vitest";
import {
getMinimaxProviderHttpMocks,
installMinimaxProviderHttpMockCleanup,
loadMinimaxMusicGenerationProviderModule,
} from "./provider-http.test-helpers.js";
const {
resolveApiKeyForProviderMock,
postJsonRequestMock,
fetchWithTimeoutMock,
assertOkOrThrowHttpErrorMock,
resolveProviderHttpRequestConfigMock,
} = vi.hoisted(() => ({
resolveApiKeyForProviderMock: vi.fn(async () => ({ apiKey: "minimax-key" })),
postJsonRequestMock: vi.fn(),
fetchWithTimeoutMock: vi.fn(),
assertOkOrThrowHttpErrorMock: vi.fn(async () => {}),
resolveProviderHttpRequestConfigMock: vi.fn((params) => ({
baseUrl: params.baseUrl ?? params.defaultBaseUrl,
allowPrivateNetwork: false,
headers: new Headers(params.defaultHeaders),
dispatcherPolicy: undefined,
})),
}));
const { postJsonRequestMock, fetchWithTimeoutMock } = getMinimaxProviderHttpMocks();
vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
}));
let buildMinimaxMusicGenerationProvider: Awaited<
ReturnType<typeof loadMinimaxMusicGenerationProviderModule>
>["buildMinimaxMusicGenerationProvider"];
vi.mock("openclaw/plugin-sdk/provider-http", () => ({
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
fetchWithTimeout: fetchWithTimeoutMock,
postJsonRequest: postJsonRequestMock,
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
}));
beforeAll(async () => {
({ buildMinimaxMusicGenerationProvider } = await loadMinimaxMusicGenerationProviderModule());
});
installMinimaxProviderHttpMockCleanup();
function mockMusicGenerationResponse(json: Record<string, unknown>): void {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => json,
},
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock.mockResolvedValue({
headers: new Headers({ "content-type": "audio/mpeg" }),
arrayBuffer: async () => Buffer.from("mp3-bytes"),
});
}
describe("minimax music generation provider", () => {
afterEach(() => {
resolveApiKeyForProviderMock.mockClear();
postJsonRequestMock.mockReset();
fetchWithTimeoutMock.mockReset();
assertOkOrThrowHttpErrorMock.mockClear();
resolveProviderHttpRequestConfigMock.mockClear();
});
it("creates music and downloads the generated track", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
task_id: "task-123",
audio_url: "https://example.com/out.mp3",
lyrics: "our city wakes",
base_resp: { status_code: 0 },
}),
},
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock.mockResolvedValue({
headers: new Headers({ "content-type": "audio/mpeg" }),
arrayBuffer: async () => Buffer.from("mp3-bytes"),
mockMusicGenerationResponse({
task_id: "task-123",
audio_url: "https://example.com/out.mp3",
lyrics: "our city wakes",
base_resp: { status_code: 0 },
});
const provider = buildMinimaxMusicGenerationProvider();
@@ -98,20 +80,11 @@ describe("minimax music generation provider", () => {
});
it("downloads tracks when url output is returned in data.audio", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
data: {
audio: "https://example.com/url-audio.mp3",
},
base_resp: { status_code: 0 },
}),
mockMusicGenerationResponse({
data: {
audio: "https://example.com/url-audio.mp3",
},
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock.mockResolvedValue({
headers: new Headers({ "content-type": "audio/mpeg" }),
arrayBuffer: async () => Buffer.from("mp3-bytes"),
base_resp: { status_code: 0 },
});
const provider = buildMinimaxMusicGenerationProvider();
@@ -148,19 +121,10 @@ describe("minimax music generation provider", () => {
});
it("uses lyrics optimizer when lyrics are omitted", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
task_id: "task-456",
audio_url: "https://example.com/out.mp3",
base_resp: { status_code: 0 },
}),
},
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock.mockResolvedValue({
headers: new Headers({ "content-type": "audio/mpeg" }),
arrayBuffer: async () => Buffer.from("mp3-bytes"),
mockMusicGenerationResponse({
task_id: "task-456",
audio_url: "https://example.com/out.mp3",
base_resp: { status_code: 0 },
});
const provider = buildMinimaxMusicGenerationProvider();
@@ -0,0 +1,15 @@
import {
getProviderHttpMocks,
installProviderHttpMockCleanup,
} from "../../test/helpers/media-generation/provider-http-mocks.js";
export const getMinimaxProviderHttpMocks = getProviderHttpMocks;
export const installMinimaxProviderHttpMockCleanup = installProviderHttpMockCleanup;
export function loadMinimaxMusicGenerationProviderModule() {
return import("./music-generation-provider.js");
}
export function loadMinimaxVideoGenerationProviderModule() {
return import("./video-generation-provider.js");
}
@@ -1,45 +1,23 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildMinimaxVideoGenerationProvider } from "./video-generation-provider.js";
import { beforeAll, describe, expect, it, vi } from "vitest";
import {
getMinimaxProviderHttpMocks,
installMinimaxProviderHttpMockCleanup,
loadMinimaxVideoGenerationProviderModule,
} from "./provider-http.test-helpers.js";
const {
resolveApiKeyForProviderMock,
postJsonRequestMock,
fetchWithTimeoutMock,
assertOkOrThrowHttpErrorMock,
resolveProviderHttpRequestConfigMock,
} = vi.hoisted(() => ({
resolveApiKeyForProviderMock: vi.fn(async () => ({ apiKey: "minimax-key" })),
postJsonRequestMock: vi.fn(),
fetchWithTimeoutMock: vi.fn(),
assertOkOrThrowHttpErrorMock: vi.fn(async () => {}),
resolveProviderHttpRequestConfigMock: vi.fn((params) => ({
baseUrl: params.baseUrl ?? params.defaultBaseUrl,
allowPrivateNetwork: false,
headers: new Headers(params.defaultHeaders),
dispatcherPolicy: undefined,
})),
}));
const { postJsonRequestMock, fetchWithTimeoutMock } = getMinimaxProviderHttpMocks();
vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
}));
let buildMinimaxVideoGenerationProvider: Awaited<
ReturnType<typeof loadMinimaxVideoGenerationProviderModule>
>["buildMinimaxVideoGenerationProvider"];
vi.mock("openclaw/plugin-sdk/provider-http", () => ({
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
fetchWithTimeout: fetchWithTimeoutMock,
postJsonRequest: postJsonRequestMock,
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
}));
beforeAll(async () => {
({ buildMinimaxVideoGenerationProvider } = await loadMinimaxVideoGenerationProviderModule());
});
installMinimaxProviderHttpMockCleanup();
describe("minimax video generation provider", () => {
afterEach(() => {
resolveApiKeyForProviderMock.mockClear();
postJsonRequestMock.mockReset();
fetchWithTimeoutMock.mockReset();
assertOkOrThrowHttpErrorMock.mockClear();
resolveProviderHttpRequestConfigMock.mockClear();
});
it("creates a task, polls status, and downloads the generated video", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
+79 -213
View File
@@ -62,6 +62,47 @@ function mockBinaryResponse(data: Uint8Array, status = 200) {
return new Response(Buffer.from(data) as BodyInit, { status });
}
type GuardedFetchParams = { url: string; init?: RequestInit };
function guardedFetchResult(params: GuardedFetchParams, response: Response) {
return {
response,
release: async () => {},
finalUrl: params.url,
};
}
function mockGraphMediaFetch(options: {
messageId: string;
messageResponse?: unknown;
hostedContents?: unknown[];
valueResponses?: Record<string, Response>;
fetchCalls?: string[];
}) {
vi.mocked(fetchWithSsrFGuard).mockImplementation(async (params: GuardedFetchParams) => {
options.fetchCalls?.push(params.url);
const url = params.url;
if (url.endsWith(`/messages/${options.messageId}`) && !url.includes("hostedContents")) {
return guardedFetchResult(
params,
mockFetchResponse(options.messageResponse ?? { body: {}, attachments: [] }),
);
}
if (url.endsWith("/hostedContents")) {
return guardedFetchResult(params, mockFetchResponse({ value: options.hostedContents ?? [] }));
}
for (const [fragment, response] of Object.entries(options.valueResponses ?? {})) {
if (url.includes(fragment)) {
return guardedFetchResult(params, response);
}
}
if (url.endsWith("/attachments")) {
return guardedFetchResult(params, mockFetchResponse({ value: [] }));
}
return guardedFetchResult(params, mockFetchResponse({}, 404));
});
}
describe("downloadMSTeamsGraphMedia hosted content $value fallback", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -72,49 +113,13 @@ describe("downloadMSTeamsGraphMedia hosted content $value fallback", () => {
const fetchCalls: string[] = [];
vi.mocked(fetchWithSsrFGuard).mockImplementation(async (params: { url: string }) => {
fetchCalls.push(params.url);
const url = params.url;
// Main message fetch
if (url.endsWith("/messages/msg-1") && !url.includes("hostedContents")) {
return {
response: mockFetchResponse({ body: {}, attachments: [] }),
release: async () => {},
finalUrl: params.url,
};
}
// hostedContents collection
if (url.endsWith("/hostedContents")) {
return {
response: mockFetchResponse({
value: [{ id: "hosted-123", contentType: "image/png", contentBytes: null }],
}),
release: async () => {},
finalUrl: params.url,
};
}
// $value endpoint (the fallback being tested)
if (url.includes("/hostedContents/hosted-123/$value")) {
return {
response: mockBinaryResponse(imageBytes),
release: async () => {},
finalUrl: params.url,
};
}
// attachments collection
if (url.endsWith("/attachments")) {
return {
response: mockFetchResponse({ value: [] }),
release: async () => {},
finalUrl: params.url,
};
}
return {
response: mockFetchResponse({}, 404),
release: async () => {},
finalUrl: params.url,
};
mockGraphMediaFetch({
messageId: "msg-1",
hostedContents: [{ id: "hosted-123", contentType: "image/png", contentBytes: null }],
valueResponses: {
"/hostedContents/hosted-123/$value": mockBinaryResponse(imageBytes),
},
fetchCalls,
});
const result = await downloadMSTeamsGraphMedia({
@@ -131,36 +136,9 @@ describe("downloadMSTeamsGraphMedia hosted content $value fallback", () => {
});
it("skips hosted content when contentBytes is null and id is missing", async () => {
vi.mocked(fetchWithSsrFGuard).mockImplementation(async (params: { url: string }) => {
const url = params.url;
if (url.endsWith("/messages/msg-2") && !url.includes("hostedContents")) {
return {
response: mockFetchResponse({ body: {}, attachments: [] }),
release: async () => {},
finalUrl: params.url,
};
}
if (url.endsWith("/hostedContents")) {
return {
response: mockFetchResponse({
value: [{ contentType: "image/png", contentBytes: null }],
}),
release: async () => {},
finalUrl: params.url,
};
}
if (url.endsWith("/attachments")) {
return {
response: mockFetchResponse({ value: [] }),
release: async () => {},
finalUrl: params.url,
};
}
return {
response: mockFetchResponse({}, 404),
release: async () => {},
finalUrl: params.url,
};
mockGraphMediaFetch({
messageId: "msg-2",
hostedContents: [{ contentType: "image/png", contentBytes: null }],
});
const result = await downloadMSTeamsGraphMedia({
@@ -176,49 +154,19 @@ describe("downloadMSTeamsGraphMedia hosted content $value fallback", () => {
it("skips $value content when Content-Length exceeds maxBytes", async () => {
const fetchCalls: string[] = [];
vi.mocked(fetchWithSsrFGuard).mockImplementation(async (params: { url: string }) => {
fetchCalls.push(params.url);
const url = params.url;
if (url.endsWith("/messages/msg-cl") && !url.includes("hostedContents")) {
return {
response: mockFetchResponse({ body: {}, attachments: [] }),
release: async () => {},
finalUrl: params.url,
};
}
if (url.endsWith("/hostedContents")) {
return {
response: mockFetchResponse({
value: [{ id: "hosted-big", contentType: "image/png", contentBytes: null }],
}),
release: async () => {},
finalUrl: params.url,
};
}
if (url.includes("/hostedContents/hosted-big/$value")) {
// Return a response whose Content-Length exceeds maxBytes
const data = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
return {
response: new Response(Buffer.from(data) as BodyInit, {
mockGraphMediaFetch({
messageId: "msg-cl",
hostedContents: [{ id: "hosted-big", contentType: "image/png", contentBytes: null }],
valueResponses: {
"/hostedContents/hosted-big/$value": new Response(
Buffer.from(new Uint8Array([0x89, 0x50, 0x4e, 0x47])) as BodyInit,
{
status: 200,
headers: { "content-length": "999999999" },
}),
release: async () => {},
finalUrl: params.url,
};
}
if (url.endsWith("/attachments")) {
return {
response: mockFetchResponse({ value: [] }),
release: async () => {},
finalUrl: params.url,
};
}
return {
response: mockFetchResponse({}, 404),
release: async () => {},
finalUrl: params.url,
};
},
),
},
fetchCalls,
});
const result = await downloadMSTeamsGraphMedia({
@@ -237,37 +185,10 @@ describe("downloadMSTeamsGraphMedia hosted content $value fallback", () => {
const fetchCalls: string[] = [];
const base64Png = Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString("base64");
vi.mocked(fetchWithSsrFGuard).mockImplementation(async (params: { url: string }) => {
fetchCalls.push(params.url);
const url = params.url;
if (url.endsWith("/messages/msg-3") && !url.includes("hostedContents")) {
return {
response: mockFetchResponse({ body: {}, attachments: [] }),
release: async () => {},
finalUrl: params.url,
};
}
if (url.endsWith("/hostedContents")) {
return {
response: mockFetchResponse({
value: [{ id: "hosted-456", contentType: "image/png", contentBytes: base64Png }],
}),
release: async () => {},
finalUrl: params.url,
};
}
if (url.endsWith("/attachments")) {
return {
response: mockFetchResponse({ value: [] }),
release: async () => {},
finalUrl: params.url,
};
}
return {
response: mockFetchResponse({}, 404),
release: async () => {},
finalUrl: params.url,
};
mockGraphMediaFetch({
messageId: "msg-3",
hostedContents: [{ id: "hosted-456", contentType: "image/png", contentBytes: base64Png }],
fetchCalls,
});
const result = await downloadMSTeamsGraphMedia({
@@ -283,37 +204,7 @@ describe("downloadMSTeamsGraphMedia hosted content $value fallback", () => {
});
it("adds the OpenClaw User-Agent to guarded Graph attachment fetches", async () => {
vi.mocked(fetchWithSsrFGuard).mockImplementation(
async (params: { url: string; init?: RequestInit }) => {
const url = params.url;
if (url.endsWith("/messages/msg-ua") && !url.includes("hostedContents")) {
return {
response: mockFetchResponse({ body: {}, attachments: [] }),
release: async () => {},
finalUrl: params.url,
};
}
if (url.endsWith("/hostedContents")) {
return {
response: mockFetchResponse({ value: [] }),
release: async () => {},
finalUrl: params.url,
};
}
if (url.endsWith("/attachments")) {
return {
response: mockFetchResponse({ value: [] }),
release: async () => {},
finalUrl: params.url,
};
}
return {
response: mockFetchResponse({}, 404),
release: async () => {},
finalUrl: params.url,
};
},
);
mockGraphMediaFetch({ messageId: "msg-ua" });
await downloadMSTeamsGraphMedia({
messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-ua",
@@ -333,43 +224,18 @@ describe("downloadMSTeamsGraphMedia hosted content $value fallback", () => {
});
it("adds the OpenClaw User-Agent to Graph shares downloads for reference attachments", async () => {
vi.mocked(fetchWithSsrFGuard).mockImplementation(async (params: { url: string }) => {
const url = params.url;
if (url.endsWith("/messages/msg-share") && !url.includes("hostedContents")) {
return {
response: mockFetchResponse({
body: {},
attachments: [
{
contentType: "reference",
contentUrl: "https://tenant.sharepoint.com/file.docx",
name: "file.docx",
},
],
}),
release: async () => {},
finalUrl: params.url,
};
}
if (url.endsWith("/hostedContents")) {
return {
response: mockFetchResponse({ value: [] }),
release: async () => {},
finalUrl: params.url,
};
}
if (url.endsWith("/attachments")) {
return {
response: mockFetchResponse({ value: [] }),
release: async () => {},
finalUrl: params.url,
};
}
return {
response: mockFetchResponse({}, 404),
release: async () => {},
finalUrl: params.url,
};
mockGraphMediaFetch({
messageId: "msg-share",
messageResponse: {
body: {},
attachments: [
{
contentType: "reference",
contentUrl: "https://tenant.sharepoint.com/file.docx",
name: "file.docx",
},
],
},
});
vi.mocked(safeFetchWithPolicy).mockResolvedValue(new Response(null, { status: 200 }));
vi.mocked(downloadAndStoreMSTeamsRemoteMedia).mockImplementation(async (params) => {
@@ -1,48 +1,28 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeAll, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
import {
pinMessageMSTeams,
reactMessageMSTeams,
unpinMessageMSTeams,
unreactMessageMSTeams,
} from "./graph-messages.js";
CHANNEL_TO,
CHAT_ID,
TOKEN,
type GraphMessagesTestModule,
getGraphMessagesMockState,
installGraphMessagesMockDefaults,
loadGraphMessagesTestModule,
} from "./graph-messages.test-helpers.js";
const mockState = vi.hoisted(() => ({
resolveGraphToken: vi.fn(),
fetchGraphJson: vi.fn(),
postGraphJson: vi.fn(),
postGraphBetaJson: vi.fn(),
deleteGraphRequest: vi.fn(),
findPreferredDmByUserId: vi.fn(),
}));
const mockState = getGraphMessagesMockState();
installGraphMessagesMockDefaults();
let pinMessageMSTeams: GraphMessagesTestModule["pinMessageMSTeams"];
let reactMessageMSTeams: GraphMessagesTestModule["reactMessageMSTeams"];
let unpinMessageMSTeams: GraphMessagesTestModule["unpinMessageMSTeams"];
let unreactMessageMSTeams: GraphMessagesTestModule["unreactMessageMSTeams"];
vi.mock("./graph.js", () => {
return {
resolveGraphToken: mockState.resolveGraphToken,
fetchGraphJson: mockState.fetchGraphJson,
postGraphJson: mockState.postGraphJson,
postGraphBetaJson: mockState.postGraphBetaJson,
deleteGraphRequest: mockState.deleteGraphRequest,
escapeOData: vi.fn((value: string) => value.replaceAll("'", "''")),
};
beforeAll(async () => {
({ pinMessageMSTeams, reactMessageMSTeams, unpinMessageMSTeams, unreactMessageMSTeams } =
await loadGraphMessagesTestModule());
});
vi.mock("./conversation-store-fs.js", () => ({
createMSTeamsConversationStoreFs: () => ({
findPreferredDmByUserId: mockState.findPreferredDmByUserId,
}),
}));
const TOKEN = "test-graph-token";
const CHAT_ID = "19:abc@thread.tacv2";
const CHANNEL_TO = "team-id-1/channel-id-1";
describe("pinMessageMSTeams", () => {
beforeEach(() => {
vi.clearAllMocks();
mockState.resolveGraphToken.mockResolvedValue(TOKEN);
});
it("pins a message in a chat", async () => {
mockState.postGraphJson.mockResolvedValue({ id: "pinned-1" });
@@ -79,11 +59,6 @@ describe("pinMessageMSTeams", () => {
});
describe("unpinMessageMSTeams", () => {
beforeEach(() => {
vi.clearAllMocks();
mockState.resolveGraphToken.mockResolvedValue(TOKEN);
});
it("unpins a message from a chat", async () => {
mockState.deleteGraphRequest.mockResolvedValue(undefined);
@@ -118,11 +93,6 @@ describe("unpinMessageMSTeams", () => {
});
describe("reactMessageMSTeams", () => {
beforeEach(() => {
vi.clearAllMocks();
mockState.resolveGraphToken.mockResolvedValue(TOKEN);
});
it("sets a like reaction on a chat message", async () => {
mockState.postGraphBetaJson.mockResolvedValue(undefined);
@@ -211,11 +181,6 @@ describe("reactMessageMSTeams", () => {
});
describe("unreactMessageMSTeams", () => {
beforeEach(() => {
vi.clearAllMocks();
mockState.resolveGraphToken.mockResolvedValue(TOKEN);
});
it("removes a reaction from a chat message", async () => {
mockState.postGraphBetaJson.mockResolvedValue(undefined);
@@ -1,43 +1,27 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeAll, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
import { getMessageMSTeams, listPinsMSTeams, listReactionsMSTeams } from "./graph-messages.js";
import {
CHANNEL_TO,
CHAT_ID,
TOKEN,
type GraphMessagesTestModule,
getGraphMessagesMockState,
installGraphMessagesMockDefaults,
loadGraphMessagesTestModule,
} from "./graph-messages.test-helpers.js";
const mockState = vi.hoisted(() => ({
resolveGraphToken: vi.fn(),
fetchGraphJson: vi.fn(),
postGraphJson: vi.fn(),
postGraphBetaJson: vi.fn(),
deleteGraphRequest: vi.fn(),
findPreferredDmByUserId: vi.fn(),
}));
const mockState = getGraphMessagesMockState();
installGraphMessagesMockDefaults();
let getMessageMSTeams: GraphMessagesTestModule["getMessageMSTeams"];
let listPinsMSTeams: GraphMessagesTestModule["listPinsMSTeams"];
let listReactionsMSTeams: GraphMessagesTestModule["listReactionsMSTeams"];
vi.mock("./graph.js", () => {
return {
resolveGraphToken: mockState.resolveGraphToken,
fetchGraphJson: mockState.fetchGraphJson,
postGraphJson: mockState.postGraphJson,
postGraphBetaJson: mockState.postGraphBetaJson,
deleteGraphRequest: mockState.deleteGraphRequest,
escapeOData: vi.fn((value: string) => value.replaceAll("'", "''")),
};
beforeAll(async () => {
({ getMessageMSTeams, listPinsMSTeams, listReactionsMSTeams } =
await loadGraphMessagesTestModule());
});
vi.mock("./conversation-store-fs.js", () => ({
createMSTeamsConversationStoreFs: () => ({
findPreferredDmByUserId: mockState.findPreferredDmByUserId,
}),
}));
const TOKEN = "test-graph-token";
const CHAT_ID = "19:abc@thread.tacv2";
const CHANNEL_TO = "team-id-1/channel-id-1";
describe("getMessageMSTeams", () => {
beforeEach(() => {
vi.clearAllMocks();
mockState.resolveGraphToken.mockResolvedValue(TOKEN);
});
it("resolves user: target using graphChatId from store", async () => {
mockState.findPreferredDmByUserId.mockResolvedValue({
conversationId: "a:bot-framework-dm-id",
@@ -186,11 +170,6 @@ describe("getMessageMSTeams", () => {
});
describe("listPinsMSTeams", () => {
beforeEach(() => {
vi.clearAllMocks();
mockState.resolveGraphToken.mockResolvedValue(TOKEN);
});
it("lists pinned messages in a chat", async () => {
mockState.fetchGraphJson.mockResolvedValue({
value: [
@@ -233,11 +212,6 @@ describe("listPinsMSTeams", () => {
});
describe("listReactionsMSTeams", () => {
beforeEach(() => {
vi.clearAllMocks();
mockState.resolveGraphToken.mockResolvedValue(TOKEN);
});
it("lists reactions grouped by type with user details", async () => {
mockState.fetchGraphJson.mockResolvedValue({
id: "msg-1",
@@ -1,43 +1,23 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeAll, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
import { searchMessagesMSTeams } from "./graph-messages.js";
import {
CHANNEL_TO,
CHAT_ID,
type GraphMessagesTestModule,
getGraphMessagesMockState,
installGraphMessagesMockDefaults,
loadGraphMessagesTestModule,
} from "./graph-messages.test-helpers.js";
const mockState = vi.hoisted(() => ({
resolveGraphToken: vi.fn(),
fetchGraphJson: vi.fn(),
postGraphJson: vi.fn(),
postGraphBetaJson: vi.fn(),
deleteGraphRequest: vi.fn(),
findPreferredDmByUserId: vi.fn(),
}));
const mockState = getGraphMessagesMockState();
installGraphMessagesMockDefaults();
let searchMessagesMSTeams: GraphMessagesTestModule["searchMessagesMSTeams"];
vi.mock("./graph.js", () => {
return {
resolveGraphToken: mockState.resolveGraphToken,
fetchGraphJson: mockState.fetchGraphJson,
postGraphJson: mockState.postGraphJson,
postGraphBetaJson: mockState.postGraphBetaJson,
deleteGraphRequest: mockState.deleteGraphRequest,
escapeOData: vi.fn((value: string) => value.replaceAll("'", "''")),
};
beforeAll(async () => {
({ searchMessagesMSTeams } = await loadGraphMessagesTestModule());
});
vi.mock("./conversation-store-fs.js", () => ({
createMSTeamsConversationStoreFs: () => ({
findPreferredDmByUserId: mockState.findPreferredDmByUserId,
}),
}));
const TOKEN = "test-graph-token";
const CHAT_ID = "19:abc@thread.tacv2";
const CHANNEL_TO = "team-id-1/channel-id-1";
describe("searchMessagesMSTeams", () => {
beforeEach(() => {
vi.clearAllMocks();
mockState.resolveGraphToken.mockResolvedValue(TOKEN);
});
it("searches chat messages with query string", async () => {
mockState.fetchGraphJson.mockResolvedValue({
value: [
@@ -0,0 +1,48 @@
import { beforeEach, vi } from "vitest";
const graphMessagesMockState = vi.hoisted(() => ({
resolveGraphToken: vi.fn(),
fetchGraphJson: vi.fn(),
postGraphJson: vi.fn(),
postGraphBetaJson: vi.fn(),
deleteGraphRequest: vi.fn(),
findPreferredDmByUserId: vi.fn(),
}));
vi.mock("./graph.js", () => {
return {
resolveGraphToken: graphMessagesMockState.resolveGraphToken,
fetchGraphJson: graphMessagesMockState.fetchGraphJson,
postGraphJson: graphMessagesMockState.postGraphJson,
postGraphBetaJson: graphMessagesMockState.postGraphBetaJson,
deleteGraphRequest: graphMessagesMockState.deleteGraphRequest,
escapeOData: vi.fn((value: string) => value.replaceAll("'", "''")),
};
});
vi.mock("./conversation-store-fs.js", () => ({
createMSTeamsConversationStoreFs: () => ({
findPreferredDmByUserId: graphMessagesMockState.findPreferredDmByUserId,
}),
}));
export const TOKEN = "test-graph-token";
export const CHAT_ID = "19:abc@thread.tacv2";
export const CHANNEL_TO = "team-id-1/channel-id-1";
export function getGraphMessagesMockState(): typeof graphMessagesMockState {
return graphMessagesMockState;
}
export type GraphMessagesTestModule = typeof import("./graph-messages.js");
export function loadGraphMessagesTestModule(): Promise<GraphMessagesTestModule> {
return import("./graph-messages.js");
}
export function installGraphMessagesMockDefaults(): void {
beforeEach(() => {
vi.clearAllMocks();
graphMessagesMockState.resolveGraphToken.mockResolvedValue(TOKEN);
});
}
+52 -80
View File
@@ -196,6 +196,48 @@ describe("msteams messenger", () => {
serviceUrl: "https://service.example.com",
};
async function sendAndCaptureRevokeFallbackReference(
conversation: StoredConversationReference["conversation"],
) {
const proactiveSent: string[] = [];
let capturedReference: unknown;
const conversationRef: StoredConversationReference = {
activityId: "activity456",
user: { id: "user123", name: "User" },
agent: { id: "bot123", name: "Bot" },
conversation,
channelId: "msteams",
serviceUrl: "https://service.example.com",
};
const adapter: MSTeamsAdapter = {
continueConversation: async (_appId, reference, logic) => {
capturedReference = reference;
await logic({
sendActivity: createRecordedSendActivity(proactiveSent),
updateActivity: noopUpdateActivity,
deleteActivity: noopDeleteActivity,
});
},
process: async () => {},
updateActivity: noopUpdateActivity,
deleteActivity: noopDeleteActivity,
};
await sendMSTeamsMessages({
replyStyle: "thread",
adapter,
appId: "app123",
conversationRef,
context: createRevokedThreadContext(),
messages: [{ text: "hello" }],
});
return {
proactiveSent,
reference: capturedReference as { conversation?: { id?: string }; activityId?: string },
};
}
it("sends thread messages via the provided context", async () => {
const sent: string[] = [];
const ctx = {
@@ -409,97 +451,27 @@ describe("msteams messenger", () => {
});
it("reconstructs threaded conversation ID for channel revoke fallback", async () => {
const proactiveSent: string[] = [];
let capturedReference: unknown;
const channelRef: StoredConversationReference = {
activityId: "activity456",
user: { id: "user123", name: "User" },
agent: { id: "bot123", name: "Bot" },
conversation: {
id: "19:abc@thread.tacv2;messageid=deadbeef",
conversationType: "channel",
},
channelId: "msteams",
serviceUrl: "https://service.example.com",
};
const ctx = createRevokedThreadContext();
const adapter: MSTeamsAdapter = {
continueConversation: async (_appId, reference, logic) => {
capturedReference = reference;
await logic({
sendActivity: createRecordedSendActivity(proactiveSent),
updateActivity: noopUpdateActivity,
deleteActivity: noopDeleteActivity,
});
},
process: async () => {},
updateActivity: noopUpdateActivity,
deleteActivity: noopDeleteActivity,
};
await sendMSTeamsMessages({
replyStyle: "thread",
adapter,
appId: "app123",
conversationRef: channelRef,
context: ctx,
messages: [{ text: "hello" }],
const { proactiveSent, reference } = await sendAndCaptureRevokeFallbackReference({
id: "19:abc@thread.tacv2;messageid=deadbeef",
conversationType: "channel",
});
expect(proactiveSent).toEqual(["hello"]);
const ref = capturedReference as { conversation?: { id?: string }; activityId?: string };
// Conversation ID should include the thread suffix for channel messages
expect(ref.conversation?.id).toBe("19:abc@thread.tacv2;messageid=activity456");
expect(ref.activityId).toBeUndefined();
expect(reference.conversation?.id).toBe("19:abc@thread.tacv2;messageid=activity456");
expect(reference.activityId).toBeUndefined();
});
it("does not add thread suffix for group chat revoke fallback", async () => {
const proactiveSent: string[] = [];
let capturedReference: unknown;
const groupRef: StoredConversationReference = {
activityId: "activity789",
user: { id: "user123", name: "User" },
agent: { id: "bot123", name: "Bot" },
conversation: {
id: "19:group123@thread.v2",
conversationType: "groupChat",
},
channelId: "msteams",
serviceUrl: "https://service.example.com",
};
const ctx = createRevokedThreadContext();
const adapter: MSTeamsAdapter = {
continueConversation: async (_appId, reference, logic) => {
capturedReference = reference;
await logic({
sendActivity: createRecordedSendActivity(proactiveSent),
updateActivity: noopUpdateActivity,
deleteActivity: noopDeleteActivity,
});
},
process: async () => {},
updateActivity: noopUpdateActivity,
deleteActivity: noopDeleteActivity,
};
await sendMSTeamsMessages({
replyStyle: "thread",
adapter,
appId: "app123",
conversationRef: groupRef,
context: ctx,
messages: [{ text: "hello" }],
const { proactiveSent, reference } = await sendAndCaptureRevokeFallbackReference({
id: "19:group123@thread.v2",
conversationType: "groupChat",
});
expect(proactiveSent).toEqual(["hello"]);
const ref = capturedReference as { conversation?: { id?: string }; activityId?: string };
// Group chat should NOT have thread suffix — flat conversation
expect(ref.conversation?.id).toBe("19:group123@thread.v2");
expect(ref.activityId).toBeUndefined();
expect(reference.conversation?.id).toBe("19:group123@thread.v2");
expect(reference.activityId).toBeUndefined();
});
it("retries top-level sends on transient (5xx)", async () => {
@@ -33,6 +33,21 @@ vi.mock("./send.runtime.js", () => {
const { sendMessageNextcloudTalk, sendReactionNextcloudTalk } = await import("./send.js");
function expectProvidedMessageCfgThreading(cfg: unknown): void {
expectProvidedCfgSkipsRuntimeLoad({
loadConfig: hoisted.loadConfig,
resolveAccount: hoisted.resolveNextcloudTalkAccount,
cfg,
accountId: "work",
});
expect(hoisted.resolveMarkdownTableMode).toHaveBeenCalledWith({
cfg,
channel: "nextcloud-talk",
accountId: "default",
});
expect(hoisted.convertMarkdownTables).toHaveBeenCalledWith("hello", "preserve");
}
describe("nextcloud-talk send cfg threading", () => {
const fetchMock = vi.fn<typeof fetch>();
const defaultAccount = {
@@ -41,6 +56,17 @@ describe("nextcloud-talk send cfg threading", () => {
secret: "secret-value",
};
function mockNextcloudMessageResponse(messageId: number, timestamp: number): void {
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
ocs: { data: { id: messageId, timestamp } },
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
}
beforeEach(() => {
vi.stubGlobal("fetch", fetchMock);
// Route the SSRF guard mock through the global fetch mock.
@@ -66,32 +92,14 @@ describe("nextcloud-talk send cfg threading", () => {
it("uses provided cfg for sendMessage and skips runtime loadConfig", async () => {
const cfg = { source: "provided" } as const;
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
ocs: { data: { id: 12345, timestamp: 1_706_000_000 } },
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
mockNextcloudMessageResponse(12345, 1_706_000_000);
const result = await sendMessageNextcloudTalk("room:abc123", "hello", {
cfg,
accountId: "work",
});
expectProvidedCfgSkipsRuntimeLoad({
loadConfig: hoisted.loadConfig,
resolveAccount: hoisted.resolveNextcloudTalkAccount,
cfg,
accountId: "work",
});
expect(hoisted.resolveMarkdownTableMode).toHaveBeenCalledWith({
cfg,
channel: "nextcloud-talk",
accountId: "default",
});
expect(hoisted.convertMarkdownTables).toHaveBeenCalledWith("hello", "preserve");
expectProvidedMessageCfgThreading(cfg);
expect(hoisted.record).toHaveBeenCalledWith({
channel: "nextcloud-talk",
accountId: "default",
@@ -110,32 +118,14 @@ describe("nextcloud-talk send cfg threading", () => {
hoisted.record.mockImplementation(() => {
throw new Error("Nextcloud Talk runtime not initialized");
});
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
ocs: { data: { id: 12346, timestamp: 1_706_000_001 } },
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
mockNextcloudMessageResponse(12346, 1_706_000_001);
const result = await sendMessageNextcloudTalk("room:abc123", "hello", {
cfg,
accountId: "work",
});
expectProvidedCfgSkipsRuntimeLoad({
loadConfig: hoisted.loadConfig,
resolveAccount: hoisted.resolveNextcloudTalkAccount,
cfg,
accountId: "work",
});
expect(hoisted.resolveMarkdownTableMode).toHaveBeenCalledWith({
cfg,
channel: "nextcloud-talk",
accountId: "default",
});
expect(hoisted.convertMarkdownTables).toHaveBeenCalledWith("hello", "preserve");
expectProvidedMessageCfgThreading(cfg);
expect(result).toEqual({
messageId: "12346",
roomToken: "abc123",
+40 -54
View File
@@ -28,6 +28,40 @@ function createCfg() {
};
}
function installOutboundRuntime(convertMarkdownTables = vi.fn((text: string) => text)) {
const resolveMarkdownTableMode = vi.fn(() => "off");
setNostrRuntime({
channel: {
text: {
resolveMarkdownTableMode,
convertMarkdownTables,
},
},
reply: {},
} as unknown as PluginRuntime);
return { resolveMarkdownTableMode, convertMarkdownTables };
}
async function startOutboundAccount(accountId?: string) {
const sendDm = vi.fn(async () => {});
const bus = {
sendDm,
close: vi.fn(),
getMetrics: vi.fn(() => ({ counters: {} })),
publishProfile: vi.fn(),
getProfileState: vi.fn(async () => null),
};
mocks.startNostrBus.mockResolvedValueOnce(bus as unknown);
const cleanup = (await startNostrGatewayAccount(
createStartAccountContext({
account: buildResolvedNostrAccount(accountId ? { accountId } : undefined),
}),
)) as { stop: () => void };
return { cleanup, sendDm };
}
describe("nostr outbound cfg threading", () => {
afterEach(() => {
mocks.normalizePubkey.mockClear();
@@ -35,33 +69,10 @@ describe("nostr outbound cfg threading", () => {
});
it("uses resolved cfg when converting markdown tables before send", async () => {
const resolveMarkdownTableMode = vi.fn(() => "off");
const convertMarkdownTables = vi.fn((text: string) => `converted:${text}`);
setNostrRuntime({
channel: {
text: {
resolveMarkdownTableMode,
convertMarkdownTables,
},
},
reply: {},
} as unknown as PluginRuntime);
const sendDm = vi.fn(async () => {});
const bus = {
sendDm,
close: vi.fn(),
getMetrics: vi.fn(() => ({ counters: {} })),
publishProfile: vi.fn(),
getProfileState: vi.fn(async () => null),
};
mocks.startNostrBus.mockResolvedValueOnce(bus as unknown);
const cleanup = (await startNostrGatewayAccount(
createStartAccountContext({
account: buildResolvedNostrAccount(),
}),
)) as { stop: () => void };
const { resolveMarkdownTableMode, convertMarkdownTables } = installOutboundRuntime(
vi.fn((text: string) => `converted:${text}`),
);
const { cleanup, sendDm } = await startOutboundAccount();
const cfg = createCfg();
await nostrOutboundAdapter.sendText({
@@ -84,33 +95,8 @@ describe("nostr outbound cfg threading", () => {
});
it("uses the configured defaultAccount when accountId is omitted", async () => {
const resolveMarkdownTableMode = vi.fn(() => "off");
const convertMarkdownTables = vi.fn((text: string) => text);
setNostrRuntime({
channel: {
text: {
resolveMarkdownTableMode,
convertMarkdownTables,
},
},
reply: {},
} as unknown as PluginRuntime);
const sendDm = vi.fn(async () => {});
const bus = {
sendDm,
close: vi.fn(),
getMetrics: vi.fn(() => ({ counters: {} })),
publishProfile: vi.fn(),
getProfileState: vi.fn(async () => null),
};
mocks.startNostrBus.mockResolvedValueOnce(bus as unknown);
const cleanup = (await startNostrGatewayAccount(
createStartAccountContext({
account: buildResolvedNostrAccount({ accountId: "work" }),
}),
)) as { stop: () => void };
const { resolveMarkdownTableMode } = installOutboundRuntime();
const { cleanup, sendDm } = await startOutboundAccount("work");
const cfg = {
channels: {
@@ -15,6 +15,42 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
}));
type OllamaProviderConfigOverride = Partial<{
api: "ollama";
apiKey: string;
baseUrl: string;
models: NonNullable<
NonNullable<NonNullable<OpenClawConfig["models"]>["providers"]>[string]
>["models"];
}>;
function createOllamaConfig(provider: OllamaProviderConfigOverride = {}): OpenClawConfig {
return {
models: {
providers: {
ollama: {
baseUrl: "http://ollama.local:11434/v1",
api: "ollama",
models: [],
...provider,
},
},
},
};
}
function createSetupNotes() {
const notes: Array<{ title?: string; message: string }> = [];
return {
notes,
prompter: {
note: async (message: string, title?: string) => {
notes.push({ title, message });
},
},
};
}
describe("ollama web search provider", () => {
beforeEach(() => {
fetchWithSsrFGuardMock.mockReset();
@@ -77,17 +113,7 @@ describe("ollama web search provider", () => {
const provider = createOllamaWebSearchProvider();
const tool = provider.createTool({
config: {
models: {
providers: {
ollama: {
baseUrl: "http://ollama.local:11434/v1",
api: "ollama",
models: [],
},
},
},
},
config: createOllamaConfig(),
} as never);
if (!tool) {
throw new Error("Expected tool definition");
@@ -137,26 +163,12 @@ describe("ollama web search provider", () => {
it("warns when Ollama is not reachable during setup without cancelling", async () => {
fetchWithSsrFGuardMock.mockRejectedValueOnce(new Error("connect failed"));
const notes: Array<{ title?: string; message: string }> = [];
const config: OpenClawConfig = {
models: {
providers: {
ollama: {
baseUrl: "http://ollama.local:11434/v1",
api: "ollama",
models: [],
},
},
},
};
const config = createOllamaConfig();
const { notes, prompter } = createSetupNotes();
const next = await testing.warnOllamaWebSearchPrereqs({
config,
prompter: {
note: async (message: string, title?: string) => {
notes.push({ title, message });
},
},
prompter,
});
expect(next).toBe(config);
@@ -172,18 +184,12 @@ describe("ollama web search provider", () => {
const original = process.env.OLLAMA_API_KEY;
try {
process.env.OLLAMA_API_KEY = "real-secret-from-env";
const key = testing.resolveOllamaWebSearchApiKey({
models: {
providers: {
ollama: {
apiKey: "OLLAMA_API_KEY",
baseUrl: "http://localhost:11434",
api: "ollama",
models: [],
},
},
},
});
const key = testing.resolveOllamaWebSearchApiKey(
createOllamaConfig({
apiKey: "OLLAMA_API_KEY",
baseUrl: "http://localhost:11434",
}),
);
expect(key).toBe("real-secret-from-env");
} finally {
if (original === undefined) {
@@ -214,26 +220,12 @@ describe("ollama web search provider", () => {
release: vi.fn(async () => {}),
});
const notes: Array<{ title?: string; message: string }> = [];
const config: OpenClawConfig = {
models: {
providers: {
ollama: {
baseUrl: "http://ollama.local:11434/v1",
api: "ollama",
models: [],
},
},
},
};
const config = createOllamaConfig();
const { notes, prompter } = createSetupNotes();
const next = await testing.warnOllamaWebSearchPrereqs({
config,
prompter: {
note: async (message: string, title?: string) => {
notes.push({ title, message });
},
},
prompter,
});
expect(next).toBe(config);
@@ -1,45 +1,21 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildOpenAIVideoGenerationProvider } from "./video-generation-provider.js";
import { beforeAll, describe, expect, it, vi } from "vitest";
import {
getProviderHttpMocks,
installProviderHttpMockCleanup,
} from "../../test/helpers/media-generation/provider-http-mocks.js";
const {
resolveApiKeyForProviderMock,
postJsonRequestMock,
fetchWithTimeoutMock,
assertOkOrThrowHttpErrorMock,
resolveProviderHttpRequestConfigMock,
} = vi.hoisted(() => ({
resolveApiKeyForProviderMock: vi.fn(async () => ({ apiKey: "openai-key" })),
postJsonRequestMock: vi.fn(),
fetchWithTimeoutMock: vi.fn(),
assertOkOrThrowHttpErrorMock: vi.fn(async () => {}),
resolveProviderHttpRequestConfigMock: vi.fn((params) => ({
baseUrl: params.baseUrl ?? params.defaultBaseUrl,
allowPrivateNetwork: false,
headers: new Headers(params.defaultHeaders),
dispatcherPolicy: undefined,
})),
}));
const { postJsonRequestMock, fetchWithTimeoutMock, resolveProviderHttpRequestConfigMock } =
getProviderHttpMocks();
vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
}));
let buildOpenAIVideoGenerationProvider: typeof import("./video-generation-provider.js").buildOpenAIVideoGenerationProvider;
vi.mock("openclaw/plugin-sdk/provider-http", () => ({
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
fetchWithTimeout: fetchWithTimeoutMock,
postJsonRequest: postJsonRequestMock,
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
}));
beforeAll(async () => {
({ buildOpenAIVideoGenerationProvider } = await import("./video-generation-provider.js"));
});
installProviderHttpMockCleanup();
describe("openai video generation provider", () => {
afterEach(() => {
resolveApiKeyForProviderMock.mockClear();
postJsonRequestMock.mockReset();
fetchWithTimeoutMock.mockReset();
assertOkOrThrowHttpErrorMock.mockClear();
resolveProviderHttpRequestConfigMock.mockClear();
});
it("uses JSON for text-only Sora requests", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
+45 -79
View File
@@ -535,6 +535,49 @@ export async function startQaLabServer(params?: {
})();
return runnerModelCatalogPromise;
};
async function runSelfCheck(): Promise<QaSelfCheckResult> {
latestScenarioRun = withQaLabRunCounts({
kind: "self-check",
status: "running",
startedAt: new Date().toISOString(),
scenarios: [
{
id: "qa-self-check",
name: "Synthetic Slack-class roundtrip",
status: "running",
},
],
});
const result = await runQaSelfCheckAgainstState({
state,
cfg: gateway?.cfg ?? createQaLabConfig(listenUrl),
outputPath: params?.outputPath,
repoRoot,
});
latestScenarioRun = withQaLabRunCounts({
kind: "self-check",
status: "completed",
startedAt: latestScenarioRun.startedAt,
finishedAt: new Date().toISOString(),
scenarios: [
{
id: "qa-self-check",
name: result.scenarioResult.name,
status: result.scenarioResult.status,
details: result.scenarioResult.details,
steps: result.scenarioResult.steps,
},
],
});
latestReport = {
outputPath: result.outputPath,
markdown: result.report,
generatedAt: new Date().toISOString(),
};
return result;
}
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://127.0.0.1");
@@ -644,44 +687,7 @@ export async function startQaLabServer(params?: {
writeError(res, 409, "QA suite run already in progress");
return;
}
latestScenarioRun = withQaLabRunCounts({
kind: "self-check",
status: "running",
startedAt: new Date().toISOString(),
scenarios: [
{
id: "qa-self-check",
name: "Synthetic Slack-class roundtrip",
status: "running",
},
],
});
const result = await runQaSelfCheckAgainstState({
state,
cfg: gateway?.cfg ?? createQaLabConfig(listenUrl),
outputPath: params?.outputPath,
repoRoot,
});
latestScenarioRun = withQaLabRunCounts({
kind: "self-check",
status: "completed",
startedAt: latestScenarioRun.startedAt,
finishedAt: new Date().toISOString(),
scenarios: [
{
id: "qa-self-check",
name: result.scenarioResult.name,
status: result.scenarioResult.status,
details: result.scenarioResult.details,
steps: result.scenarioResult.steps,
},
],
});
latestReport = {
outputPath: result.outputPath,
markdown: result.report,
generatedAt: new Date().toISOString(),
};
const result = await runSelfCheck();
writeJson(res, 200, serializeSelfCheck(result));
return;
}
@@ -846,47 +852,7 @@ export async function startQaLabServer(params?: {
setLatestReport(next: QaLabLatestReport | null) {
latestReport = next;
},
async runSelfCheck() {
latestScenarioRun = withQaLabRunCounts({
kind: "self-check",
status: "running",
startedAt: new Date().toISOString(),
scenarios: [
{
id: "qa-self-check",
name: "Synthetic Slack-class roundtrip",
status: "running",
},
],
});
const result = await runQaSelfCheckAgainstState({
state,
cfg: gateway?.cfg ?? createQaLabConfig(listenUrl),
outputPath: params?.outputPath,
repoRoot,
});
latestScenarioRun = withQaLabRunCounts({
kind: "self-check",
status: "completed",
startedAt: latestScenarioRun.startedAt,
finishedAt: new Date().toISOString(),
scenarios: [
{
id: "qa-self-check",
name: result.scenarioResult.name,
status: result.scenarioResult.status,
details: result.scenarioResult.details,
steps: result.scenarioResult.steps,
},
],
});
latestReport = {
outputPath: result.outputPath,
markdown: result.report,
generatedAt: new Date().toISOString(),
};
return result;
},
runSelfCheck,
async stop() {
await gateway?.stop();
await new Promise<void>((resolve, reject) =>
+34 -80
View File
@@ -11,6 +11,32 @@ import {
describe("qqbot local media path remapping", () => {
const createdPaths: string[] = [];
function createOpenClawTestRoot() {
const actualHome = getHomeDir();
const openclawDir = path.join(actualHome, ".openclaw");
fs.mkdirSync(openclawDir, { recursive: true });
const testRoot = fs.mkdtempSync(path.join(openclawDir, "qqbot-platform-test-"));
createdPaths.push(testRoot);
return { actualHome, testRootName: path.basename(testRoot) };
}
function createQqbotMediaFile(fileName: string) {
const { actualHome, testRootName } = createOpenClawTestRoot();
const mediaFile = path.join(
actualHome,
".openclaw",
"media",
"qqbot",
"downloads",
testRootName,
fileName,
);
fs.mkdirSync(path.dirname(mediaFile), { recursive: true });
fs.writeFileSync(mediaFile, "image", "utf8");
createdPaths.push(path.dirname(mediaFile));
return { actualHome, testRootName, mediaFile };
}
afterEach(() => {
vi.restoreAllMocks();
for (const target of createdPaths.splice(0)) {
@@ -19,24 +45,7 @@ describe("qqbot local media path remapping", () => {
});
it("remaps missing workspace media paths to the real media directory", () => {
const actualHome = getHomeDir();
const openclawDir = path.join(actualHome, ".openclaw");
fs.mkdirSync(openclawDir, { recursive: true });
const testRoot = fs.mkdtempSync(path.join(openclawDir, "qqbot-platform-test-"));
createdPaths.push(testRoot);
const mediaFile = path.join(
actualHome,
".openclaw",
"media",
"qqbot",
"downloads",
path.basename(testRoot),
"example.png",
);
fs.mkdirSync(path.dirname(mediaFile), { recursive: true });
fs.writeFileSync(mediaFile, "image", "utf8");
createdPaths.push(path.dirname(mediaFile));
const { actualHome, testRootName, mediaFile } = createQqbotMediaFile("example.png");
const missingWorkspacePath = path.join(
actualHome,
@@ -44,7 +53,7 @@ describe("qqbot local media path remapping", () => {
"workspace",
"qqbot",
"downloads",
path.basename(testRoot),
testRootName,
"example.png",
);
@@ -52,24 +61,7 @@ describe("qqbot local media path remapping", () => {
});
it("leaves existing media paths unchanged", () => {
const actualHome = getHomeDir();
const openclawDir = path.join(actualHome, ".openclaw");
fs.mkdirSync(openclawDir, { recursive: true });
const testRoot = fs.mkdtempSync(path.join(openclawDir, "qqbot-platform-test-"));
createdPaths.push(testRoot);
const mediaFile = path.join(
actualHome,
".openclaw",
"media",
"qqbot",
"downloads",
path.basename(testRoot),
"existing.png",
);
fs.mkdirSync(path.dirname(mediaFile), { recursive: true });
fs.writeFileSync(mediaFile, "image", "utf8");
createdPaths.push(path.dirname(mediaFile));
const { mediaFile } = createQqbotMediaFile("existing.png");
expect(resolveQQBotLocalMediaPath(mediaFile)).toBe(mediaFile);
});
@@ -99,41 +91,20 @@ describe("qqbot local media path remapping", () => {
});
it("allows structured payload files inside the QQ Bot media directory", () => {
const actualHome = getHomeDir();
const openclawDir = path.join(actualHome, ".openclaw");
fs.mkdirSync(openclawDir, { recursive: true });
const testRoot = fs.mkdtempSync(path.join(openclawDir, "qqbot-platform-test-"));
createdPaths.push(testRoot);
const mediaFile = path.join(
actualHome,
".openclaw",
"media",
"qqbot",
"downloads",
path.basename(testRoot),
"allowed.png",
);
fs.mkdirSync(path.dirname(mediaFile), { recursive: true });
fs.writeFileSync(mediaFile, "image", "utf8");
createdPaths.push(path.dirname(mediaFile));
const { mediaFile } = createQqbotMediaFile("allowed.png");
expect(resolveQQBotPayloadLocalFilePath(mediaFile)).toBe(mediaFile);
});
it("blocks structured payload files inside the QQ Bot data directory", () => {
const actualHome = getHomeDir();
const openclawDir = path.join(actualHome, ".openclaw");
fs.mkdirSync(openclawDir, { recursive: true });
const testRoot = fs.mkdtempSync(path.join(openclawDir, "qqbot-platform-test-"));
createdPaths.push(testRoot);
const { actualHome, testRootName } = createOpenClawTestRoot();
const dataFile = path.join(
actualHome,
".openclaw",
"qqbot",
"sessions",
path.basename(testRoot),
testRootName,
"session.json",
);
fs.mkdirSync(path.dirname(dataFile), { recursive: true });
@@ -144,24 +115,7 @@ describe("qqbot local media path remapping", () => {
});
it("allows legacy workspace paths when they remap into QQ Bot media storage", () => {
const actualHome = getHomeDir();
const openclawDir = path.join(actualHome, ".openclaw");
fs.mkdirSync(openclawDir, { recursive: true });
const testRoot = fs.mkdtempSync(path.join(openclawDir, "qqbot-platform-test-"));
createdPaths.push(testRoot);
const mediaFile = path.join(
actualHome,
".openclaw",
"media",
"qqbot",
"downloads",
path.basename(testRoot),
"legacy.png",
);
fs.mkdirSync(path.dirname(mediaFile), { recursive: true });
fs.writeFileSync(mediaFile, "image", "utf8");
createdPaths.push(path.dirname(mediaFile));
const { actualHome, testRootName, mediaFile } = createQqbotMediaFile("legacy.png");
const missingWorkspacePath = path.join(
actualHome,
@@ -169,7 +123,7 @@ describe("qqbot local media path remapping", () => {
"workspace",
"qqbot",
"downloads",
path.basename(testRoot),
testRootName,
"legacy.png",
);
+30 -109
View File
@@ -1,71 +1,27 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildQwenVideoGenerationProvider } from "./video-generation-provider.js";
import { beforeAll, describe, expect, it } from "vitest";
import {
expectDashscopeVideoTaskPoll,
expectSuccessfulDashscopeVideoResult,
mockSuccessfulDashscopeVideoTask,
} from "../../test/helpers/media-generation/dashscope-video-provider.js";
import {
getProviderHttpMocks,
installProviderHttpMockCleanup,
} from "../../test/helpers/media-generation/provider-http-mocks.js";
const {
resolveApiKeyForProviderMock,
postJsonRequestMock,
fetchWithTimeoutMock,
assertOkOrThrowHttpErrorMock,
resolveProviderHttpRequestConfigMock,
} = vi.hoisted(() => ({
resolveApiKeyForProviderMock: vi.fn(async () => ({ apiKey: "qwen-key" })),
postJsonRequestMock: vi.fn(),
fetchWithTimeoutMock: vi.fn(),
assertOkOrThrowHttpErrorMock: vi.fn(async () => {}),
resolveProviderHttpRequestConfigMock: vi.fn((params) => ({
baseUrl: params.baseUrl ?? params.defaultBaseUrl,
allowPrivateNetwork: false,
headers: new Headers(params.defaultHeaders),
dispatcherPolicy: undefined,
})),
}));
const { postJsonRequestMock, fetchWithTimeoutMock } = getProviderHttpMocks();
vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
}));
let buildQwenVideoGenerationProvider: typeof import("./video-generation-provider.js").buildQwenVideoGenerationProvider;
vi.mock("openclaw/plugin-sdk/provider-http", () => ({
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
fetchWithTimeout: fetchWithTimeoutMock,
postJsonRequest: postJsonRequestMock,
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
}));
beforeAll(async () => {
({ buildQwenVideoGenerationProvider } = await import("./video-generation-provider.js"));
});
installProviderHttpMockCleanup();
describe("qwen video generation provider", () => {
afterEach(() => {
resolveApiKeyForProviderMock.mockClear();
postJsonRequestMock.mockReset();
fetchWithTimeoutMock.mockReset();
assertOkOrThrowHttpErrorMock.mockClear();
resolveProviderHttpRequestConfigMock.mockClear();
});
it("submits async Wan generation, polls task status, and downloads the resulting video", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
request_id: "req-1",
output: {
task_id: "task-1",
},
}),
},
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock
.mockResolvedValueOnce({
json: async () => ({
output: {
task_status: "SUCCEEDED",
results: [{ video_url: "https://example.com/out.mp4" }],
},
}),
headers: new Headers(),
})
.mockResolvedValueOnce({
arrayBuffer: async () => Buffer.from("mp4-bytes"),
headers: new Headers({ "content-type": "video/mp4" }),
});
mockSuccessfulDashscopeVideoTask({ postJsonRequestMock, fetchWithTimeoutMock });
const provider = buildQwenVideoGenerationProvider();
const result = await provider.generateVideo({
@@ -90,22 +46,8 @@ describe("qwen video generation provider", () => {
}),
}),
);
expect(fetchWithTimeoutMock).toHaveBeenNthCalledWith(
1,
"https://dashscope-intl.aliyuncs.com/api/v1/tasks/task-1",
expect.objectContaining({ method: "GET" }),
120000,
fetch,
);
expect(result.videos).toHaveLength(1);
expect(result.videos[0]?.mimeType).toBe("video/mp4");
expect(result.metadata).toEqual(
expect.objectContaining({
requestId: "req-1",
taskId: "task-1",
taskStatus: "SUCCEEDED",
}),
);
expectDashscopeVideoTaskPoll(fetchWithTimeoutMock);
expectSuccessfulDashscopeVideoResult(result);
});
it("fails fast when reference inputs are local buffers instead of remote URLs", async () => {
@@ -126,31 +68,13 @@ describe("qwen video generation provider", () => {
});
it("preserves dedicated coding endpoints for dedicated API keys", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
request_id: "req-2",
output: {
task_id: "task-2",
},
}),
mockSuccessfulDashscopeVideoTask(
{
postJsonRequestMock,
fetchWithTimeoutMock,
},
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock
.mockResolvedValueOnce({
json: async () => ({
output: {
task_status: "SUCCEEDED",
results: [{ video_url: "https://example.com/out.mp4" }],
},
}),
headers: new Headers(),
})
.mockResolvedValueOnce({
arrayBuffer: async () => Buffer.from("mp4-bytes"),
headers: new Headers({ "content-type": "video/mp4" }),
});
{ requestId: "req-2", taskId: "task-2" },
);
const provider = buildQwenVideoGenerationProvider();
await provider.generateVideo({
@@ -174,12 +98,9 @@ describe("qwen video generation provider", () => {
url: "https://coding-intl.dashscope.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis",
}),
);
expect(fetchWithTimeoutMock).toHaveBeenNthCalledWith(
1,
"https://coding-intl.dashscope.aliyuncs.com/api/v1/tasks/task-2",
expect.objectContaining({ method: "GET" }),
120000,
fetch,
);
expectDashscopeVideoTaskPoll(fetchWithTimeoutMock, {
baseUrl: "https://coding-intl.dashscope.aliyuncs.com",
taskId: "task-2",
});
});
});
+14 -103
View File
@@ -1,22 +1,14 @@
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import { resolveProviderHttpRequestConfig } from "openclaw/plugin-sdk/provider-http";
import {
assertOkOrThrowHttpError,
postJsonRequest,
resolveProviderHttpRequestConfig,
} from "openclaw/plugin-sdk/provider-http";
import {
DEFAULT_VIDEO_GENERATION_DURATION_SECONDS,
DASHSCOPE_WAN_VIDEO_CAPABILITIES,
DASHSCOPE_WAN_VIDEO_MODELS,
DEFAULT_DASHSCOPE_WAN_VIDEO_MODEL,
DEFAULT_VIDEO_GENERATION_TIMEOUT_MS,
DEFAULT_VIDEO_RESOLUTION_TO_SIZE,
buildDashscopeVideoGenerationInput,
buildDashscopeVideoGenerationParameters,
downloadDashscopeGeneratedVideos,
extractDashscopeVideoUrls,
pollDashscopeVideoTaskUntilComplete,
runDashscopeVideoGenerationTask,
} from "openclaw/plugin-sdk/video-generation";
import type {
DashscopeVideoGenerationResponse,
VideoGenerationProvider,
VideoGenerationRequest,
VideoGenerationResult,
@@ -24,7 +16,7 @@ import type {
import { QWEN_STANDARD_CN_BASE_URL, QWEN_STANDARD_GLOBAL_BASE_URL } from "./models.js";
const DEFAULT_QWEN_VIDEO_BASE_URL = "https://dashscope-intl.aliyuncs.com";
const DEFAULT_QWEN_VIDEO_MODEL = "wan2.6-t2v";
const DEFAULT_QWEN_VIDEO_MODEL = DEFAULT_DASHSCOPE_WAN_VIDEO_MODEL;
function resolveQwenVideoBaseUrl(req: VideoGenerationRequest): string {
const direct = req.cfg?.models?.providers?.qwen?.baseUrl?.trim();
@@ -66,45 +58,13 @@ export function buildQwenVideoGenerationProvider(): VideoGenerationProvider {
id: "qwen",
label: "Qwen Cloud",
defaultModel: DEFAULT_QWEN_VIDEO_MODEL,
models: ["wan2.6-t2v", "wan2.6-i2v", "wan2.6-r2v", "wan2.6-r2v-flash", "wan2.7-r2v"],
models: [...DASHSCOPE_WAN_VIDEO_MODELS],
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({
provider: "qwen",
agentDir,
}),
capabilities: {
generate: {
maxVideos: 1,
maxDurationSeconds: 10,
supportsSize: true,
supportsAspectRatio: true,
supportsResolution: true,
supportsAudio: true,
supportsWatermark: true,
},
imageToVideo: {
enabled: true,
maxVideos: 1,
maxInputImages: 1,
maxDurationSeconds: 10,
supportsSize: true,
supportsAspectRatio: true,
supportsResolution: true,
supportsAudio: true,
supportsWatermark: true,
},
videoToVideo: {
enabled: true,
maxVideos: 1,
maxInputVideos: 4,
maxDurationSeconds: 10,
supportsSize: true,
supportsAspectRatio: true,
supportsResolution: true,
supportsAudio: true,
supportsWatermark: true,
},
},
capabilities: DASHSCOPE_WAN_VIDEO_CAPABILITIES,
async generateVideo(req): Promise<VideoGenerationResult> {
const fetchFn = fetch;
const auth = await resolveApiKeyForProvider({
@@ -133,68 +93,19 @@ export function buildQwenVideoGenerationProvider(): VideoGenerationProvider {
});
const model = req.model?.trim() || DEFAULT_QWEN_VIDEO_MODEL;
const { response, release } = await postJsonRequest({
return await runDashscopeVideoGenerationTask({
providerLabel: "Qwen",
model,
req,
url: `${resolveDashscopeAigcApiBaseUrl(baseUrl)}/api/v1/services/aigc/video-generation/video-synthesis`,
headers,
body: {
model,
input: buildDashscopeVideoGenerationInput({
providerLabel: "Qwen",
req,
}),
parameters: buildDashscopeVideoGenerationParameters(
{
...req,
durationSeconds: req.durationSeconds ?? DEFAULT_VIDEO_GENERATION_DURATION_SECONDS,
},
DEFAULT_VIDEO_RESOLUTION_TO_SIZE,
),
},
baseUrl: resolveDashscopeAigcApiBaseUrl(baseUrl),
timeoutMs: req.timeoutMs,
fetchFn,
allowPrivateNetwork,
dispatcherPolicy,
defaultTimeoutMs: DEFAULT_VIDEO_GENERATION_TIMEOUT_MS,
});
try {
await assertOkOrThrowHttpError(response, "Qwen video generation failed");
const submitted = (await response.json()) as DashscopeVideoGenerationResponse;
const taskId = submitted.output?.task_id?.trim();
if (!taskId) {
throw new Error("Qwen video generation response missing task_id");
}
const completed = await pollDashscopeVideoTaskUntilComplete({
providerLabel: "Qwen",
taskId,
headers,
timeoutMs: req.timeoutMs,
fetchFn,
baseUrl: resolveDashscopeAigcApiBaseUrl(baseUrl),
defaultTimeoutMs: DEFAULT_VIDEO_GENERATION_TIMEOUT_MS,
});
const urls = extractDashscopeVideoUrls(completed);
if (urls.length === 0) {
throw new Error("Qwen video generation completed without output video URLs");
}
const videos = await downloadDashscopeGeneratedVideos({
providerLabel: "Qwen",
urls,
timeoutMs: req.timeoutMs,
fetchFn,
defaultTimeoutMs: DEFAULT_VIDEO_GENERATION_TIMEOUT_MS,
});
return {
videos,
model,
metadata: {
requestId: submitted.request_id,
taskId,
taskStatus: completed.output?.task_status,
},
};
} finally {
await release();
}
},
};
}
@@ -1,45 +1,20 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildRunwayVideoGenerationProvider } from "./video-generation-provider.js";
import { beforeAll, describe, expect, it, vi } from "vitest";
import {
getProviderHttpMocks,
installProviderHttpMockCleanup,
} from "../../test/helpers/media-generation/provider-http-mocks.js";
const {
resolveApiKeyForProviderMock,
postJsonRequestMock,
fetchWithTimeoutMock,
assertOkOrThrowHttpErrorMock,
resolveProviderHttpRequestConfigMock,
} = vi.hoisted(() => ({
resolveApiKeyForProviderMock: vi.fn(async () => ({ apiKey: "runway-key" })),
postJsonRequestMock: vi.fn(),
fetchWithTimeoutMock: vi.fn(),
assertOkOrThrowHttpErrorMock: vi.fn(async () => {}),
resolveProviderHttpRequestConfigMock: vi.fn((params) => ({
baseUrl: params.baseUrl ?? params.defaultBaseUrl,
allowPrivateNetwork: false,
headers: new Headers(params.defaultHeaders),
dispatcherPolicy: undefined,
})),
}));
const { postJsonRequestMock, fetchWithTimeoutMock } = getProviderHttpMocks();
vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
}));
let buildRunwayVideoGenerationProvider: typeof import("./video-generation-provider.js").buildRunwayVideoGenerationProvider;
vi.mock("openclaw/plugin-sdk/provider-http", () => ({
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
fetchWithTimeout: fetchWithTimeoutMock,
postJsonRequest: postJsonRequestMock,
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
}));
beforeAll(async () => {
({ buildRunwayVideoGenerationProvider } = await import("./video-generation-provider.js"));
});
installProviderHttpMockCleanup();
describe("runway video generation provider", () => {
afterEach(() => {
resolveApiKeyForProviderMock.mockClear();
postJsonRequestMock.mockReset();
fetchWithTimeoutMock.mockReset();
assertOkOrThrowHttpErrorMock.mockClear();
resolveProviderHttpRequestConfigMock.mockClear();
});
it("submits a text-to-video task, polls it, and downloads the output", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
+61 -118
View File
@@ -222,6 +222,57 @@ describe("monitorSlackProvider tool results", () => {
);
}
function setMentionGatedAckConfig(statusReactionsEnabled: boolean) {
slackTestState.config = {
messages: {
responsePrefix: "PFX",
ackReaction: "👀",
ackReactionScope: "group-mentions",
removeAckAfterReply: true,
statusReactions: statusReactionsEnabled
? { enabled: true, timing: { debounceMs: 0, doneHoldMs: 0, errorHoldMs: 0 } }
: { enabled: false },
},
channels: {
slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
groupPolicy: "open",
},
},
};
}
function mockGeneralChannelInfo() {
const client = getSlackClient();
if (!client) {
throw new Error("Slack client not registered");
}
const conversations = client.conversations as {
info: ReturnType<typeof vi.fn>;
};
conversations.info.mockResolvedValueOnce({
channel: { name: "general", is_channel: true },
});
}
async function runMentionGatedChannelMessageAndFlush() {
await runSlackMessageOnce(monitorSlackProvider, {
event: makeSlackMessageEvent({
text: "<@bot-user> hello",
ts: "456",
channel_type: "channel",
}),
});
await new Promise((resolve) => setTimeout(resolve, 0));
await flush();
}
function expectReactionNames(names: string[]) {
expect(reactMock.mock.calls.map(([args]) => String((args as { name: string }).name))).toEqual(
names,
);
}
async function runDefaultMessageAndExpectSentText(expectedText: string) {
replyMock.mockResolvedValue({ text: expectedText.replace(/^PFX /, "") });
await runSlackMessageOnce(monitorSlackProvider, {
@@ -557,41 +608,9 @@ describe("monitorSlackProvider tool results", () => {
it("keeps ack reaction when no reply is delivered and status reactions are disabled", async () => {
replyMock.mockResolvedValue(undefined);
slackTestState.config = {
messages: {
responsePrefix: "PFX",
ackReaction: "👀",
ackReactionScope: "group-mentions",
removeAckAfterReply: true,
statusReactions: { enabled: false },
},
channels: {
slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
groupPolicy: "open",
},
},
};
const client = getSlackClient();
if (!client) {
throw new Error("Slack client not registered");
}
const conversations = client.conversations as {
info: ReturnType<typeof vi.fn>;
};
conversations.info.mockResolvedValueOnce({
channel: { name: "general", is_channel: true },
});
await runSlackMessageOnce(monitorSlackProvider, {
event: makeSlackMessageEvent({
text: "<@bot-user> hello",
ts: "456",
channel_type: "channel",
}),
});
await new Promise((resolve) => setTimeout(resolve, 0));
await flush();
setMentionGatedAckConfig(false);
mockGeneralChannelInfo();
await runMentionGatedChannelMessageAndFlush();
expect(sendMock).not.toHaveBeenCalled();
expect(reactMock).toHaveBeenCalledTimes(1);
@@ -604,44 +623,9 @@ describe("monitorSlackProvider tool results", () => {
it("keeps ack reaction when no reply is delivered and status reactions are enabled", async () => {
replyMock.mockResolvedValue(undefined);
slackTestState.config = {
messages: {
responsePrefix: "PFX",
ackReaction: "👀",
ackReactionScope: "group-mentions",
removeAckAfterReply: true,
statusReactions: {
enabled: true,
timing: { debounceMs: 0, doneHoldMs: 0, errorHoldMs: 0 },
},
},
channels: {
slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
groupPolicy: "open",
},
},
};
const client = getSlackClient();
if (!client) {
throw new Error("Slack client not registered");
}
const conversations = client.conversations as {
info: ReturnType<typeof vi.fn>;
};
conversations.info.mockResolvedValueOnce({
channel: { name: "general", is_channel: true },
});
await runSlackMessageOnce(monitorSlackProvider, {
event: makeSlackMessageEvent({
text: "<@bot-user> hello",
ts: "456",
channel_type: "channel",
}),
});
await new Promise((resolve) => setTimeout(resolve, 0));
await flush();
setMentionGatedAckConfig(true);
mockGeneralChannelInfo();
await runMentionGatedChannelMessageAndFlush();
expect(sendMock).not.toHaveBeenCalled();
expect(reactMock).toHaveBeenCalledTimes(1);
@@ -654,53 +638,12 @@ describe("monitorSlackProvider tool results", () => {
it("restores ack reaction when dispatch fails before any reply is delivered", async () => {
replyMock.mockRejectedValue(new Error("boom"));
slackTestState.config = {
messages: {
responsePrefix: "PFX",
ackReaction: "👀",
ackReactionScope: "group-mentions",
removeAckAfterReply: true,
statusReactions: {
enabled: true,
timing: { debounceMs: 0, doneHoldMs: 0, errorHoldMs: 0 },
},
},
channels: {
slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
groupPolicy: "open",
},
},
};
const client = getSlackClient();
if (!client) {
throw new Error("Slack client not registered");
}
const conversations = client.conversations as {
info: ReturnType<typeof vi.fn>;
};
conversations.info.mockResolvedValueOnce({
channel: { name: "general", is_channel: true },
});
await runSlackMessageOnce(monitorSlackProvider, {
event: makeSlackMessageEvent({
text: "<@bot-user> hello",
ts: "456",
channel_type: "channel",
}),
});
await new Promise((resolve) => setTimeout(resolve, 0));
await flush();
setMentionGatedAckConfig(true);
mockGeneralChannelInfo();
await runMentionGatedChannelMessageAndFlush();
expect(sendMock).not.toHaveBeenCalled();
expect(reactMock.mock.calls.map(([args]) => String((args as { name: string }).name))).toEqual([
"eyes",
"scream",
"eyes",
"eyes",
"scream",
]);
expectReactionNames(["eyes", "scream", "eyes", "eyes", "scream"]);
});
it("replies with pairing code when dmPolicy is pairing and no allowFrom is set", async () => {
@@ -23,6 +23,86 @@ function makeTmpStorePath() {
return path.join(dir, "sessions.json");
}
type ThreadContextCaseParams = {
channel: string;
channelType: SlackMessageEvent["channel_type"];
user: string;
userName: string;
starterText: string;
followUpText: string;
startTs: string;
replyTs: string;
followUpTs: string;
currentTs: string;
channelsConfig?: Parameters<typeof createInboundSlackTestContext>[0]["channelsConfig"];
resolveChannelName?: (channelId: string) => Promise<{
name?: string;
type?: SlackMessageEvent["channel_type"];
topic?: string;
purpose?: string;
}>;
};
async function prepareThreadContextCase(params: ThreadContextCaseParams) {
const replies = vi
.fn()
.mockResolvedValueOnce({
messages: [{ text: params.starterText, user: params.user, ts: params.startTs }],
})
.mockResolvedValueOnce({
messages: [
{ text: params.starterText, user: params.user, ts: params.startTs },
{ text: "assistant reply", bot_id: "B1", ts: params.replyTs },
{ text: params.followUpText, user: params.user, ts: params.followUpTs },
{ text: "current message", user: params.user, ts: params.currentTs },
],
response_metadata: { next_cursor: "" },
});
const ctx = createInboundSlackTestContext({
cfg: {
session: { store: makeTmpStorePath() },
channels: {
slack: {
enabled: true,
replyToMode: "all",
groupPolicy: "open",
contextVisibility: "allowlist",
},
},
} as OpenClawConfig,
appClient: { conversations: { replies } } as unknown as App["client"],
defaultRequireMention: false,
replyToMode: "all",
channelsConfig: params.channelsConfig,
});
ctx.allowFrom = ["u-owner"];
ctx.resolveUserName = async (id: string) => ({
name: id === params.user ? params.userName : "Owner",
});
if (params.resolveChannelName) {
ctx.resolveChannelName = params.resolveChannelName;
}
const prepared = await prepareSlackMessage({
ctx,
account: createSlackTestAccount({
replyToMode: "all",
thread: { initialHistoryLimit: 20 },
}),
message: {
channel: params.channel,
channel_type: params.channelType,
user: params.user,
text: "current message",
ts: params.currentTs,
thread_ts: params.startTs,
} as SlackMessageEvent,
opts: { source: "message" },
});
return { prepared, replies };
}
describe("prepareSlackMessage thread context allowlists", () => {
afterAll(() => {
if (fixtureRoot) {
@@ -32,64 +112,24 @@ describe("prepareSlackMessage thread context allowlists", () => {
});
it("uses room users allowlist for thread context filtering", async () => {
const replies = vi
.fn()
.mockResolvedValueOnce({
messages: [{ text: "starter from room user", user: "U1", ts: "100.000" }],
})
.mockResolvedValueOnce({
messages: [
{ text: "starter from room user", user: "U1", ts: "100.000" },
{ text: "assistant reply", bot_id: "B1", ts: "100.500" },
{ text: "allowed follow-up", user: "U1", ts: "100.800" },
{ text: "current message", user: "U1", ts: "101.000" },
],
response_metadata: { next_cursor: "" },
});
const storePath = makeTmpStorePath();
const ctx = createInboundSlackTestContext({
cfg: {
session: { store: storePath },
channels: {
slack: {
enabled: true,
replyToMode: "all",
groupPolicy: "open",
contextVisibility: "allowlist",
},
},
} as OpenClawConfig,
appClient: { conversations: { replies } } as unknown as App["client"],
defaultRequireMention: false,
replyToMode: "all",
const { prepared, replies } = await prepareThreadContextCase({
channel: "C123",
channelType: "channel",
user: "U1",
userName: "Alice",
starterText: "starter from room user",
followUpText: "allowed follow-up",
startTs: "100.000",
replyTs: "100.500",
followUpTs: "100.800",
currentTs: "101.000",
channelsConfig: {
C123: {
users: ["U1"],
requireMention: false,
},
},
});
ctx.allowFrom = ["u-owner"];
ctx.resolveUserName = async (id: string) => ({
name: id === "U1" ? "Alice" : "Owner",
});
ctx.resolveChannelName = async () => ({ name: "general", type: "channel" });
const prepared = await prepareSlackMessage({
ctx,
account: createSlackTestAccount({
replyToMode: "all",
thread: { initialHistoryLimit: 20 },
}),
message: {
channel: "C123",
channel_type: "channel",
user: "U1",
text: "current message",
ts: "101.000",
thread_ts: "100.000",
} as SlackMessageEvent,
opts: { source: "message" },
resolveChannelName: async () => ({ name: "general", type: "channel" }),
});
expect(prepared).toBeTruthy();
@@ -102,63 +142,23 @@ describe("prepareSlackMessage thread context allowlists", () => {
});
it("does not apply the owner allowlist to open-room thread context", async () => {
const replies = vi
.fn()
.mockResolvedValueOnce({
messages: [{ text: "starter from open room", user: "U2", ts: "200.000" }],
})
.mockResolvedValueOnce({
messages: [
{ text: "starter from open room", user: "U2", ts: "200.000" },
{ text: "assistant reply", bot_id: "B1", ts: "200.500" },
{ text: "open-room follow-up", user: "U2", ts: "200.800" },
{ text: "current message", user: "U2", ts: "201.000" },
],
response_metadata: { next_cursor: "" },
});
const storePath = makeTmpStorePath();
const ctx = createInboundSlackTestContext({
cfg: {
session: { store: storePath },
channels: {
slack: {
enabled: true,
replyToMode: "all",
groupPolicy: "open",
contextVisibility: "allowlist",
},
},
} as OpenClawConfig,
appClient: { conversations: { replies } } as unknown as App["client"],
defaultRequireMention: false,
replyToMode: "all",
const { prepared, replies } = await prepareThreadContextCase({
channel: "C124",
channelType: "channel",
user: "U2",
userName: "Bob",
starterText: "starter from open room",
followUpText: "open-room follow-up",
startTs: "200.000",
replyTs: "200.500",
followUpTs: "200.800",
currentTs: "201.000",
channelsConfig: {
C124: {
requireMention: false,
},
},
});
ctx.allowFrom = ["u-owner"];
ctx.resolveUserName = async (id: string) => ({
name: id === "U2" ? "Bob" : "Owner",
});
ctx.resolveChannelName = async () => ({ name: "general", type: "channel" });
const prepared = await prepareSlackMessage({
ctx,
account: createSlackTestAccount({
replyToMode: "all",
thread: { initialHistoryLimit: 20 },
}),
message: {
channel: "C124",
channel_type: "channel",
user: "U2",
text: "current message",
ts: "201.000",
thread_ts: "200.000",
} as SlackMessageEvent,
opts: { source: "message" },
resolveChannelName: async () => ({ name: "general", type: "channel" }),
});
expect(prepared).toBeTruthy();
@@ -171,57 +171,17 @@ describe("prepareSlackMessage thread context allowlists", () => {
});
it("does not apply the owner allowlist to open DMs when dmPolicy is open", async () => {
const replies = vi
.fn()
.mockResolvedValueOnce({
messages: [{ text: "starter from open dm", user: "U3", ts: "300.000" }],
})
.mockResolvedValueOnce({
messages: [
{ text: "starter from open dm", user: "U3", ts: "300.000" },
{ text: "assistant reply", bot_id: "B1", ts: "300.500" },
{ text: "dm follow-up", user: "U3", ts: "300.800" },
{ text: "current message", user: "U3", ts: "301.000" },
],
response_metadata: { next_cursor: "" },
});
const storePath = makeTmpStorePath();
const ctx = createInboundSlackTestContext({
cfg: {
session: { store: storePath },
channels: {
slack: {
enabled: true,
replyToMode: "all",
groupPolicy: "open",
contextVisibility: "allowlist",
},
},
} as OpenClawConfig,
appClient: { conversations: { replies } } as unknown as App["client"],
defaultRequireMention: false,
replyToMode: "all",
});
ctx.allowFrom = ["u-owner"];
ctx.resolveUserName = async (id: string) => ({
name: id === "U3" ? "Dana" : "Owner",
});
const prepared = await prepareSlackMessage({
ctx,
account: createSlackTestAccount({
replyToMode: "all",
thread: { initialHistoryLimit: 20 },
}),
message: {
channel: "D300",
channel_type: "im",
user: "U3",
text: "current message",
ts: "301.000",
thread_ts: "300.000",
} as SlackMessageEvent,
opts: { source: "message" },
const { prepared, replies } = await prepareThreadContextCase({
channel: "D300",
channelType: "im",
user: "U3",
userName: "Dana",
starterText: "starter from open dm",
followUpText: "dm follow-up",
startTs: "300.000",
replyTs: "300.500",
followUpTs: "300.800",
currentTs: "301.000",
});
expect(prepared).toBeTruthy();
@@ -234,57 +194,17 @@ describe("prepareSlackMessage thread context allowlists", () => {
});
it("does not apply the owner allowlist to MPIM thread context", async () => {
const replies = vi
.fn()
.mockResolvedValueOnce({
messages: [{ text: "starter from mpim", user: "U4", ts: "400.000" }],
})
.mockResolvedValueOnce({
messages: [
{ text: "starter from mpim", user: "U4", ts: "400.000" },
{ text: "assistant reply", bot_id: "B1", ts: "400.500" },
{ text: "mpim follow-up", user: "U4", ts: "400.800" },
{ text: "current message", user: "U4", ts: "401.000" },
],
response_metadata: { next_cursor: "" },
});
const storePath = makeTmpStorePath();
const ctx = createInboundSlackTestContext({
cfg: {
session: { store: storePath },
channels: {
slack: {
enabled: true,
replyToMode: "all",
groupPolicy: "open",
contextVisibility: "allowlist",
},
},
} as OpenClawConfig,
appClient: { conversations: { replies } } as unknown as App["client"],
defaultRequireMention: false,
replyToMode: "all",
});
ctx.allowFrom = ["u-owner"];
ctx.resolveUserName = async (id: string) => ({
name: id === "U4" ? "Evan" : "Owner",
});
const prepared = await prepareSlackMessage({
ctx,
account: createSlackTestAccount({
replyToMode: "all",
thread: { initialHistoryLimit: 20 },
}),
message: {
channel: "G400",
channel_type: "mpim",
user: "U4",
text: "current message",
ts: "401.000",
thread_ts: "400.000",
} as SlackMessageEvent,
opts: { source: "message" },
const { prepared, replies } = await prepareThreadContextCase({
channel: "G400",
channelType: "mpim",
user: "U4",
userName: "Evan",
starterText: "starter from mpim",
followUpText: "mpim follow-up",
startTs: "400.000",
replyTs: "400.500",
followUpTs: "400.800",
currentTs: "401.000",
});
expect(prepared).toBeTruthy();
@@ -21,6 +21,9 @@ type CreateCommandBotResult = {
deleteMessage: ReturnType<typeof vi.fn>;
setMyCommands: ReturnType<typeof vi.fn>;
};
type CreateCommandBotParams = {
api?: Record<string, unknown>;
};
const skillCommandMocks = vi.hoisted(() => ({
listSkillCommandsForAgents: vi.fn<TelegramNativeCommandDeps["listSkillCommandsForAgents"]>(
@@ -67,7 +70,7 @@ export function resetNativeCommandMenuMocks() {
emitTelegramMessageSentHooks.mockClear();
}
export function createCommandBot(): CreateCommandBotResult {
export function createCommandBot(params: CreateCommandBotParams = {}): CreateCommandBotResult {
const commandHandlers = new Map<string, (ctx: unknown) => Promise<void>>();
const sendMessage = vi.fn().mockResolvedValue({ message_id: 999 });
const deleteMessage = vi.fn().mockResolvedValue(true);
@@ -77,6 +80,7 @@ export function createCommandBot(): CreateCommandBotResult {
setMyCommands,
sendMessage,
deleteMessage,
...params.api,
},
command: vi.fn((name: string, cb: (ctx: unknown) => Promise<void>) => {
commandHandlers.set(name, cb);
@@ -19,6 +19,54 @@ let registerTelegramNativeCommands: typeof import("./bot-native-commands.js").re
let parseTelegramNativeCommandCallbackData: typeof import("./bot-native-commands.js").parseTelegramNativeCommandCallbackData;
let resolveTelegramNativeCommandDisableBlockStreaming: typeof import("./bot-native-commands.js").resolveTelegramNativeCommandDisableBlockStreaming;
type CommandBotHarness = ReturnType<typeof createCommandBot>;
type CommandHandler = (ctx: unknown) => Promise<void>;
type PlugCommandHarnessParams = {
botHarness?: CommandBotHarness;
cfg?: OpenClawConfig;
command?: Record<string, unknown>;
args?: string;
result?: Record<string, unknown>;
registerOverrides?: Partial<Parameters<typeof registerTelegramNativeCommands>[0]>;
};
function primePlugCommand(params: PlugCommandHarnessParams = {}) {
pluginCommandMocks.getPluginCommandSpecs.mockReturnValue([
{
name: "plug",
description: "Plugin command",
},
] as never);
pluginCommandMocks.matchPluginCommand.mockReturnValue({
command: {
key: "plug",
requireAuth: false,
...params.command,
},
args: params.args,
} as never);
pluginCommandMocks.executePluginCommand.mockResolvedValue(
(params.result ?? { text: "ok" }) as never,
);
}
function registerPlugCommand(params: PlugCommandHarnessParams = {}) {
const botHarness = params.botHarness ?? createCommandBot();
primePlugCommand(params);
registerTelegramNativeCommands({
...createNativeCommandTestParams(params.cfg ?? {}, {
bot: botHarness.bot,
...params.registerOverrides,
}),
});
const handler = botHarness.commandHandlers.get("plug");
expect(handler).toBeTruthy();
return {
...botHarness,
handler: handler as CommandHandler,
};
}
describe("registerTelegramNativeCommands", () => {
beforeAll(async () => {
({
@@ -230,8 +278,6 @@ describe("registerTelegramNativeCommands", () => {
});
it("passes agent-scoped media roots for plugin command replies with media", async () => {
const commandHandlers = new Map<string, (ctx: unknown) => Promise<void>>();
const sendMessage = vi.fn().mockResolvedValue(undefined);
const cfg: OpenClawConfig = {
agents: {
list: [{ id: "main", default: true }, { id: "work" }],
@@ -239,38 +285,15 @@ describe("registerTelegramNativeCommands", () => {
bindings: [{ agentId: "work", match: { channel: "telegram", accountId: "default" } }],
};
pluginCommandMocks.getPluginCommandSpecs.mockReturnValue([
{
name: "plug",
description: "Plugin command",
const { handler, sendMessage } = registerPlugCommand({
cfg,
result: {
text: "with media",
mediaUrl: "/tmp/workspace-work/render.png",
},
] as never);
pluginCommandMocks.matchPluginCommand.mockReturnValue({
command: { key: "plug", requireAuth: false },
args: undefined,
} as never);
pluginCommandMocks.executePluginCommand.mockResolvedValue({
text: "with media",
mediaUrl: "/tmp/workspace-work/render.png",
} as never);
registerTelegramNativeCommands({
...createNativeCommandTestParams(cfg, {
bot: {
api: {
setMyCommands: vi.fn().mockResolvedValue(undefined),
sendMessage,
},
command: vi.fn((name: string, cb: (ctx: unknown) => Promise<void>) => {
commandHandlers.set(name, cb);
}),
} as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
}),
});
const handler = commandHandlers.get("plug");
expect(handler).toBeTruthy();
await handler?.(createPrivateCommandContext());
await handler(createPrivateCommandContext());
const firstDeliverRepliesCall = deliverReplies.mock.calls.at(0) as [unknown] | undefined;
expect(firstDeliverRepliesCall?.[0]).toEqual(
@@ -305,36 +328,20 @@ describe("registerTelegramNativeCommands", () => {
});
it("uses plugin command metadata to send and edit a Telegram progress placeholder", async () => {
const { bot, commandHandlers, sendMessage, deleteMessage } = createCommandBot();
pluginCommandMocks.getPluginCommandSpecs.mockReturnValue([
{
name: "plug",
description: "Plugin command",
},
] as never);
pluginCommandMocks.matchPluginCommand.mockReturnValue({
const { handler, sendMessage, deleteMessage } = registerPlugCommand({
args: "now",
command: {
key: "plug",
requireAuth: false,
nativeProgressMessages: {
telegram:
"Running this command now...\n\nI'll edit this message with the final result when it's ready.",
},
},
args: "now",
} as never);
pluginCommandMocks.executePluginCommand.mockResolvedValue({
text: "Command completed successfully",
} as never);
registerTelegramNativeCommands({
...createNativeCommandTestParams({}, { bot }),
result: {
text: "Command completed successfully",
},
});
const handler = commandHandlers.get("plug");
expect(handler).toBeTruthy();
await handler?.(
await handler(
createPrivateCommandContext({
match: "now",
}),
@@ -366,38 +373,22 @@ describe("registerTelegramNativeCommands", () => {
});
it("preserves Telegram buttons when editing a metadata-driven progress placeholder", async () => {
const { bot, commandHandlers, sendMessage, deleteMessage } = createCommandBot();
pluginCommandMocks.getPluginCommandSpecs.mockReturnValue([
{
name: "plug",
description: "Plugin command",
},
] as never);
pluginCommandMocks.matchPluginCommand.mockReturnValue({
const { handler, sendMessage, deleteMessage } = registerPlugCommand({
args: "now",
command: {
key: "plug",
requireAuth: false,
nativeProgressMessages: { telegram: "Working on it..." },
},
args: "now",
} as never);
pluginCommandMocks.executePluginCommand.mockResolvedValue({
text: "Choose an option",
channelData: {
telegram: {
buttons: [[{ text: "Approve", callback_data: "approve" }]],
result: {
text: "Choose an option",
channelData: {
telegram: {
buttons: [[{ text: "Approve", callback_data: "approve" }]],
},
},
},
} as never);
registerTelegramNativeCommands({
...createNativeCommandTestParams({}, { bot }),
});
const handler = commandHandlers.get("plug");
expect(handler).toBeTruthy();
await handler?.(createPrivateCommandContext({ match: "now" }));
await handler(createPrivateCommandContext({ match: "now" }));
expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined);
expect(editMessageTelegram).toHaveBeenCalledWith(
@@ -413,34 +404,18 @@ describe("registerTelegramNativeCommands", () => {
});
it("falls back to a normal reply when a metadata-driven progress result is not editable", async () => {
const { bot, commandHandlers, sendMessage, deleteMessage } = createCommandBot();
pluginCommandMocks.getPluginCommandSpecs.mockReturnValue([
{
name: "plug",
description: "Plugin command",
},
] as never);
pluginCommandMocks.matchPluginCommand.mockReturnValue({
const { handler, sendMessage, deleteMessage } = registerPlugCommand({
args: "now",
command: {
key: "plug",
requireAuth: false,
nativeProgressMessages: { telegram: "Working on it..." },
},
args: "now",
} as never);
pluginCommandMocks.executePluginCommand.mockResolvedValue({
text: "rich output",
mediaUrl: "/tmp/render.png",
} as never);
registerTelegramNativeCommands({
...createNativeCommandTestParams({}, { bot }),
result: {
text: "rich output",
mediaUrl: "/tmp/render.png",
},
});
const handler = commandHandlers.get("plug");
expect(handler).toBeTruthy();
await handler?.(
await handler(
createPrivateCommandContext({
match: "now",
}),
@@ -457,34 +432,18 @@ describe("registerTelegramNativeCommands", () => {
});
it("cleans up the progress placeholder before falling back after an edit failure", async () => {
const { bot, commandHandlers, sendMessage, deleteMessage } = createCommandBot();
pluginCommandMocks.getPluginCommandSpecs.mockReturnValue([
{
name: "plug",
description: "Plugin command",
},
] as never);
pluginCommandMocks.matchPluginCommand.mockReturnValue({
const { handler, sendMessage, deleteMessage } = registerPlugCommand({
args: "now",
command: {
key: "plug",
requireAuth: false,
nativeProgressMessages: { telegram: "Working on it..." },
},
args: "now",
} as never);
pluginCommandMocks.executePluginCommand.mockResolvedValue({
text: "Command completed successfully",
} as never);
result: {
text: "Command completed successfully",
},
});
editMessageTelegram.mockRejectedValueOnce(new Error("message to edit not found"));
registerTelegramNativeCommands({
...createNativeCommandTestParams({}, { bot }),
});
const handler = commandHandlers.get("plug");
expect(handler).toBeTruthy();
await handler?.(createPrivateCommandContext({ match: "now" }));
await handler(createPrivateCommandContext({ match: "now" }));
expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined);
expect(editMessageTelegram).toHaveBeenCalledTimes(1);
@@ -497,53 +456,35 @@ describe("registerTelegramNativeCommands", () => {
});
it("cleans up the progress placeholder when Telegram suppresses a local exec approval reply", async () => {
const { bot, commandHandlers, sendMessage, deleteMessage } = createCommandBot();
pluginCommandMocks.getPluginCommandSpecs.mockReturnValue([
{
name: "plug",
description: "Plugin command",
},
] as never);
pluginCommandMocks.matchPluginCommand.mockReturnValue({
const { handler, sendMessage, deleteMessage } = registerPlugCommand({
args: "now",
command: {
key: "plug",
requireAuth: false,
nativeProgressMessages: { telegram: "Working on it..." },
},
args: "now",
} as never);
pluginCommandMocks.executePluginCommand.mockResolvedValue({
text: "Approval required.\n\n```txt\n/approve 7f423fdc allow-once\n```",
channelData: {
execApproval: {
approvalId: "7f423fdc-1111-2222-3333-444444444444",
approvalSlug: "7f423fdc",
allowedDecisions: ["allow-once", "allow-always", "deny"],
result: {
text: "Approval required.\n\n```txt\n/approve 7f423fdc allow-once\n```",
channelData: {
execApproval: {
approvalId: "7f423fdc-1111-2222-3333-444444444444",
approvalSlug: "7f423fdc",
allowedDecisions: ["allow-once", "allow-always", "deny"],
},
},
},
} as never);
registerTelegramNativeCommands({
...createNativeCommandTestParams(
{
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["12345"],
target: "dm",
},
cfg: {
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["12345"],
target: "dm",
},
},
},
{ bot },
),
},
});
const handler = commandHandlers.get("plug");
expect(handler).toBeTruthy();
await handler?.(createPrivateCommandContext({ match: "now" }));
await handler(createPrivateCommandContext({ match: "now" }));
expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined);
expect(deleteMessage).toHaveBeenCalledWith(100, 999);
@@ -552,48 +493,24 @@ describe("registerTelegramNativeCommands", () => {
});
it("sends plugin command error replies silently when silentErrorReplies is enabled", async () => {
const commandHandlers = new Map<string, (ctx: unknown) => Promise<void>>();
const cfg: OpenClawConfig = {
channels: {
telegram: {
silentErrorReplies: true,
const { handler } = registerPlugCommand({
cfg: {
channels: {
telegram: {
silentErrorReplies: true,
},
},
},
};
pluginCommandMocks.getPluginCommandSpecs.mockReturnValue([
{
name: "plug",
description: "Plugin command",
result: {
text: "plugin failed",
isError: true,
},
registerOverrides: {
telegramCfg: { silentErrorReplies: true } as TelegramAccountConfig,
},
] as never);
pluginCommandMocks.matchPluginCommand.mockReturnValue({
command: { key: "plug", requireAuth: false },
args: undefined,
} as never);
pluginCommandMocks.executePluginCommand.mockResolvedValue({
text: "plugin failed",
isError: true,
} as never);
registerTelegramNativeCommands({
...createNativeCommandTestParams(cfg, {
bot: {
api: {
setMyCommands: vi.fn().mockResolvedValue(undefined),
sendMessage: vi.fn().mockResolvedValue(undefined),
},
command: vi.fn((name: string, cb: (ctx: unknown) => Promise<void>) => {
commandHandlers.set(name, cb);
}),
} as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
}),
telegramCfg: { silentErrorReplies: true } as TelegramAccountConfig,
});
const handler = commandHandlers.get("plug");
expect(handler).toBeTruthy();
await handler?.(createPrivateCommandContext());
await handler(createPrivateCommandContext());
const firstDeliverRepliesCall = deliverReplies.mock.calls.at(0) as [unknown] | undefined;
expect(firstDeliverRepliesCall?.[0]).toEqual(
@@ -605,40 +522,9 @@ describe("registerTelegramNativeCommands", () => {
});
it("forwards topic-scoped binding context to Telegram plugin commands", async () => {
const commandHandlers = new Map<string, (ctx: unknown) => Promise<void>>();
const { handler } = registerPlugCommand();
pluginCommandMocks.getPluginCommandSpecs.mockReturnValue([
{
name: "plug",
description: "Plugin command",
},
] as never);
pluginCommandMocks.matchPluginCommand.mockReturnValue({
command: { key: "plug", requireAuth: false },
args: undefined,
} as never);
pluginCommandMocks.executePluginCommand.mockResolvedValue({ text: "ok" } as never);
registerTelegramNativeCommands({
...createNativeCommandTestParams(
{},
{
bot: {
api: {
setMyCommands: vi.fn().mockResolvedValue(undefined),
sendMessage: vi.fn().mockResolvedValue(undefined),
},
command: vi.fn((name: string, cb: (ctx: unknown) => Promise<void>) => {
commandHandlers.set(name, cb);
}),
} as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
},
),
});
const handler = commandHandlers.get("plug");
expect(handler).toBeTruthy();
await handler?.({
await handler({
match: "",
message: {
message_id: 2,
@@ -666,42 +552,12 @@ describe("registerTelegramNativeCommands", () => {
});
it("treats Telegram forum #General commands as topic 1 when Telegram omits topic metadata", async () => {
const commandHandlers = new Map<string, (ctx: unknown) => Promise<void>>();
const getChat = vi.fn(async () => ({ id: -1001234567890, type: "supergroup", is_forum: true }));
pluginCommandMocks.getPluginCommandSpecs.mockReturnValue([
{
name: "plug",
description: "Plugin command",
},
] as never);
pluginCommandMocks.matchPluginCommand.mockReturnValue({
command: { key: "plug", requireAuth: false },
args: undefined,
} as never);
pluginCommandMocks.executePluginCommand.mockResolvedValue({ text: "ok" } as never);
registerTelegramNativeCommands({
...createNativeCommandTestParams(
{},
{
bot: {
api: {
setMyCommands: vi.fn().mockResolvedValue(undefined),
sendMessage: vi.fn().mockResolvedValue(undefined),
getChat,
},
command: vi.fn((name: string, cb: (ctx: unknown) => Promise<void>) => {
commandHandlers.set(name, cb);
}),
} as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
},
),
const { handler } = registerPlugCommand({
botHarness: createCommandBot({ api: { getChat } }),
});
const handler = commandHandlers.get("plug");
expect(handler).toBeTruthy();
await handler?.({
await handler({
match: "",
message: {
message_id: 2,
@@ -727,40 +583,9 @@ describe("registerTelegramNativeCommands", () => {
});
it("forwards direct-message binding context to Telegram plugin commands", async () => {
const commandHandlers = new Map<string, (ctx: unknown) => Promise<void>>();
const { handler } = registerPlugCommand();
pluginCommandMocks.getPluginCommandSpecs.mockReturnValue([
{
name: "plug",
description: "Plugin command",
},
] as never);
pluginCommandMocks.matchPluginCommand.mockReturnValue({
command: { key: "plug", requireAuth: false },
args: undefined,
} as never);
pluginCommandMocks.executePluginCommand.mockResolvedValue({ text: "ok" } as never);
registerTelegramNativeCommands({
...createNativeCommandTestParams(
{},
{
bot: {
api: {
setMyCommands: vi.fn().mockResolvedValue(undefined),
sendMessage: vi.fn().mockResolvedValue(undefined),
},
command: vi.fn((name: string, cb: (ctx: unknown) => Promise<void>) => {
commandHandlers.set(name, cb);
}),
} as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
},
),
});
const handler = commandHandlers.get("plug");
expect(handler).toBeTruthy();
await handler?.(createPrivateCommandContext({ chatId: 100, userId: 200 }));
await handler(createPrivateCommandContext({ chatId: 100, userId: 200 }));
expect(pluginCommandMocks.executePluginCommand).toHaveBeenCalledWith(
expect.objectContaining({
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { buildTelegramInteractiveButtons, resolveTelegramInlineButtons } from "./button-types.js";
export function describeTelegramInteractiveButtonBehavior(): void {
describe("buildTelegramInteractiveButtons", () => {
it("maps shared buttons and selects into Telegram inline rows", () => {
expect(
buildTelegramInteractiveButtons({
blocks: [
{
type: "buttons",
buttons: [
{ label: "Approve", value: "approve", style: "success" },
{ label: "Reject", value: "reject", style: "danger" },
{ label: "Later", value: "later" },
{ label: "Archive", value: "archive" },
],
},
{
type: "select",
options: [{ label: "Alpha", value: "alpha" }],
},
],
}),
).toEqual([
[
{ text: "Approve", callback_data: "approve", style: "success" },
{ text: "Reject", callback_data: "reject", style: "danger" },
{ text: "Later", callback_data: "later", style: undefined },
],
[{ text: "Archive", callback_data: "archive", style: undefined }],
[{ text: "Alpha", callback_data: "alpha", style: undefined }],
]);
});
});
describe("resolveTelegramInlineButtons", () => {
it("prefers explicit buttons over shared interactive blocks", () => {
const explicit = [[{ text: "Keep", callback_data: "keep" }]] as const;
expect(
resolveTelegramInlineButtons({
buttons: explicit,
interactive: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Override", value: "override" }],
},
],
},
}),
).toBe(explicit);
});
it("derives buttons from raw interactive payloads", () => {
expect(
resolveTelegramInlineButtons({
interactive: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Retry", value: "retry", style: "primary" }],
},
],
},
}),
).toEqual([[{ text: "Retry", callback_data: "retry", style: "primary" }]]);
});
});
}
+4 -66
View File
@@ -1,37 +1,10 @@
import { describe, expect, it } from "vitest";
import { buildTelegramInteractiveButtons, resolveTelegramInlineButtons } from "./button-types.js";
import { buildTelegramInteractiveButtons } from "./button-types.js";
import { describeTelegramInteractiveButtonBehavior } from "./button-types.test-helpers.js";
describe("buildTelegramInteractiveButtons", () => {
it("maps shared buttons and selects into Telegram inline rows", () => {
expect(
buildTelegramInteractiveButtons({
blocks: [
{
type: "buttons",
buttons: [
{ label: "Approve", value: "approve", style: "success" },
{ label: "Reject", value: "reject", style: "danger" },
{ label: "Later", value: "later" },
{ label: "Archive", value: "archive" },
],
},
{
type: "select",
options: [{ label: "Alpha", value: "alpha" }],
},
],
}),
).toEqual([
[
{ text: "Approve", callback_data: "approve", style: "success" },
{ text: "Reject", callback_data: "reject", style: "danger" },
{ text: "Later", callback_data: "later", style: undefined },
],
[{ text: "Archive", callback_data: "archive", style: undefined }],
[{ text: "Alpha", callback_data: "alpha", style: undefined }],
]);
});
describeTelegramInteractiveButtonBehavior();
describe("buildTelegramInteractiveButtons callback limits", () => {
it("drops buttons whose callback payload exceeds Telegram limits", () => {
expect(
buildTelegramInteractiveButtons({
@@ -48,38 +21,3 @@ describe("buildTelegramInteractiveButtons", () => {
).toEqual([[{ text: "Keep", callback_data: "ok", style: undefined }]]);
});
});
describe("resolveTelegramInlineButtons", () => {
it("prefers explicit buttons over shared interactive blocks", () => {
const explicit = [[{ text: "Keep", callback_data: "keep" }]] as const;
expect(
resolveTelegramInlineButtons({
buttons: explicit,
interactive: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Override", value: "override" }],
},
],
},
}),
).toBe(explicit);
});
it("derives buttons from raw interactive payloads", () => {
expect(
resolveTelegramInlineButtons({
interactive: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Retry", value: "retry", style: "primary" }],
},
],
},
}),
).toEqual([[{ text: "Retry", callback_data: "retry", style: "primary" }]]);
});
});
@@ -97,6 +97,7 @@ export function normalizeCompatibilityConfig({
entry: updated,
pathPrefix: "channels.telegram",
changes,
includePreviewChunk: true,
resolvedMode: resolveTelegramPreviewStreamMode(updated),
includePreviewChunk: true,
});
@@ -116,6 +117,7 @@ export function normalizeCompatibilityConfig({
entry: account,
pathPrefix: `channels.telegram.accounts.${accountId}`,
changes,
includePreviewChunk: true,
resolvedMode: resolveTelegramPreviewStreamMode(account),
includePreviewChunk: true,
});
+4 -66
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { buildTelegramInteractiveButtons, resolveTelegramInlineButtons } from "./button-types.js";
import { buildTelegramInteractiveButtons } from "./button-types.js";
import { describeTelegramInteractiveButtonBehavior } from "./button-types.test-helpers.js";
import { resolveTelegramTargetChatType } from "./inline-buttons.js";
describe("resolveTelegramTargetChatType", () => {
@@ -37,37 +38,9 @@ describe("resolveTelegramTargetChatType", () => {
});
});
describe("buildTelegramInteractiveButtons", () => {
it("maps shared buttons and selects into Telegram inline rows", () => {
expect(
buildTelegramInteractiveButtons({
blocks: [
{
type: "buttons",
buttons: [
{ label: "Approve", value: "approve", style: "success" },
{ label: "Reject", value: "reject", style: "danger" },
{ label: "Later", value: "later" },
{ label: "Archive", value: "archive" },
],
},
{
type: "select",
options: [{ label: "Alpha", value: "alpha" }],
},
],
}),
).toEqual([
[
{ text: "Approve", callback_data: "approve", style: "success" },
{ text: "Reject", callback_data: "reject", style: "danger" },
{ text: "Later", callback_data: "later", style: undefined },
],
[{ text: "Archive", callback_data: "archive", style: undefined }],
[{ text: "Alpha", callback_data: "alpha", style: undefined }],
]);
});
describeTelegramInteractiveButtonBehavior();
describe("buildTelegramInteractiveButtons callback rewrites", () => {
it("drops shared buttons whose callback data exceeds Telegram's limit", () => {
expect(
buildTelegramInteractiveButtons({
@@ -112,38 +85,3 @@ describe("buildTelegramInteractiveButtons", () => {
]);
});
});
describe("resolveTelegramInlineButtons", () => {
it("prefers explicit buttons over shared interactive blocks", () => {
const explicit = [[{ text: "Keep", callback_data: "keep" }]] as const;
expect(
resolveTelegramInlineButtons({
buttons: explicit,
interactive: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Override", value: "override" }],
},
],
},
}),
).toBe(explicit);
});
it("derives buttons from raw interactive payloads", () => {
expect(
resolveTelegramInlineButtons({
interactive: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Retry", value: "retry", style: "primary" }],
},
],
},
}),
).toEqual([[{ text: "Retry", callback_data: "retry", style: "primary" }]]);
});
});
+95 -296
View File
@@ -30,6 +30,13 @@ vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
let TelegramPollingSession: typeof import("./polling-session.js").TelegramPollingSession;
type TelegramApiMiddleware = (
prev: (...args: unknown[]) => Promise<unknown>,
method: string,
payload: unknown,
) => Promise<unknown>;
type AsyncVoidFn = () => Promise<void>;
function makeBot() {
return {
api: {
@@ -41,7 +48,10 @@ function makeBot() {
};
}
function installPollingStallWatchdogHarness() {
function installPollingStallWatchdogHarness(
dateNowSequence: readonly number[] = [0, 0],
fallbackDateNow = 120_001,
) {
let watchdog: (() => void) | undefined;
const setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation((fn) => {
watchdog = fn as () => void;
@@ -53,11 +63,11 @@ function installPollingStallWatchdogHarness() {
return 1 as unknown as ReturnType<typeof setTimeout>;
});
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout").mockImplementation(() => {});
const dateNowSpy = vi
.spyOn(Date, "now")
.mockImplementationOnce(() => 0) // lastGetUpdatesAt init
.mockImplementationOnce(() => 0) // lastApiActivityAt init
.mockImplementation(() => 120_001);
const dateNowSpy = vi.spyOn(Date, "now");
for (const value of dateNowSequence) {
dateNowSpy.mockImplementationOnce(() => value);
}
dateNowSpy.mockImplementation(() => fallbackDateNow);
return {
async waitForWatchdog() {
@@ -114,6 +124,15 @@ function createPollingSessionWithTransportRestart(params: {
abortSignal: AbortSignal;
telegramTransport: ReturnType<typeof makeTelegramTransport>;
createTelegramTransport: () => ReturnType<typeof makeTelegramTransport>;
}) {
return createPollingSession(params);
}
function createPollingSession(params: {
abortSignal: AbortSignal;
log?: (message: string) => void;
telegramTransport?: ReturnType<typeof makeTelegramTransport>;
createTelegramTransport?: () => ReturnType<typeof makeTelegramTransport>;
}) {
return new TelegramPollingSession({
token: "tok",
@@ -125,12 +144,47 @@ function createPollingSessionWithTransportRestart(params: {
runnerOptions: {},
getLastUpdateId: () => null,
persistUpdateId: async () => undefined,
log: () => undefined,
log: params.log ?? (() => undefined),
telegramTransport: params.telegramTransport,
createTelegramTransport: params.createTelegramTransport,
...(params.createTelegramTransport
? { createTelegramTransport: params.createTelegramTransport }
: {}),
});
}
function mockBotCapturingApiMiddleware(botStop: AsyncVoidFn) {
let apiMiddleware: TelegramApiMiddleware | undefined;
createTelegramBotMock.mockReturnValueOnce({
api: {
deleteWebhook: vi.fn(async () => true),
getUpdates: vi.fn(async () => []),
config: {
use: vi.fn((fn: TelegramApiMiddleware) => {
apiMiddleware = fn;
}),
},
},
stop: botStop,
});
return () => apiMiddleware;
}
function mockLongRunningPollingCycle(runnerStop: AsyncVoidFn) {
let firstTaskResolve: (() => void) | undefined;
runMock.mockReturnValue({
task: () =>
new Promise<void>((resolve) => {
firstTaskResolve = resolve;
}),
stop: async () => {
await runnerStop();
firstTaskResolve?.();
},
isRunning: () => true,
});
return () => firstTaskResolve?.();
}
describe("TelegramPollingSession", () => {
beforeAll(async () => {
({ TelegramPollingSession } = await import("./polling-session.js"));
@@ -367,91 +421,28 @@ describe("TelegramPollingSession", () => {
const abort = new AbortController();
const botStop = vi.fn(async () => undefined);
const runnerStop = vi.fn(async () => undefined);
// Capture the API middleware so we can simulate sendMessage calls
let apiMiddleware:
| ((
prev: (...args: unknown[]) => Promise<unknown>,
method: string,
payload: unknown,
) => Promise<unknown>)
| undefined;
const bot = {
api: {
deleteWebhook: vi.fn(async () => true),
getUpdates: vi.fn(async () => []),
config: {
use: vi.fn((fn: typeof apiMiddleware) => {
apiMiddleware = fn;
}),
},
},
stop: botStop,
};
createTelegramBotMock.mockReturnValue(bot);
let firstTaskResolve: (() => void) | undefined;
const firstTask = new Promise<void>((resolve) => {
firstTaskResolve = resolve;
});
runMock.mockImplementation(() => ({
task: () => firstTask,
stop: async () => {
await runnerStop();
firstTaskResolve?.();
},
isRunning: () => true,
}));
const getApiMiddleware = mockBotCapturingApiMiddleware(botStop);
const resolveFirstTask = mockLongRunningPollingCycle(runnerStop);
// t=0: lastGetUpdatesAt and lastApiActivityAt initialized
// t=120_001: watchdog fires (getUpdates stale for 120s)
// But right before watchdog, a sendMessage succeeded at t=120_000
const setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation((fn) => {
watchdog = fn as () => void;
return 1 as unknown as ReturnType<typeof setInterval>;
});
const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval").mockImplementation(() => {});
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout").mockImplementation((fn) => {
void Promise.resolve().then(() => (fn as () => void)());
return 1 as unknown as ReturnType<typeof setTimeout>;
});
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout").mockImplementation(() => {});
const dateNowSpy = vi
.spyOn(Date, "now")
.mockImplementationOnce(() => 0) // lastGetUpdatesAt init
.mockImplementationOnce(() => 0) // lastApiActivityAt init
// All subsequent calls (sendMessage completion + watchdog check) return
// the same value, giving apiIdle = 0 — well below the stall threshold.
.mockImplementation(() => 120_001);
// All subsequent Date.now calls return the same value, giving apiIdle = 0.
const watchdogHarness = installPollingStallWatchdogHarness();
let watchdog: (() => void) | undefined;
const log = vi.fn();
const session = new TelegramPollingSession({
token: "tok",
config: {},
accountId: "default",
runtime: undefined,
proxyFetch: undefined,
const session = createPollingSession({
abortSignal: abort.signal,
runnerOptions: {},
getLastUpdateId: () => null,
persistUpdateId: async () => undefined,
log,
telegramTransport: undefined,
});
try {
const runPromise = session.runUntilAbort();
// Wait for watchdog to be captured
for (let attempt = 0; attempt < 20 && !watchdog; attempt += 1) {
await Promise.resolve();
}
expect(watchdog).toBeTypeOf("function");
const watchdog = await watchdogHarness.waitForWatchdog();
// Simulate a sendMessage call through the middleware before watchdog fires.
// This updates lastApiActivityAt, proving the network is alive.
const apiMiddleware = getApiMiddleware();
if (apiMiddleware) {
const fakePrev = vi.fn(async () => ({ ok: true }));
await apiMiddleware(fakePrev, "sendMessage", { chat_id: 123, text: "hello" });
@@ -467,14 +458,10 @@ describe("TelegramPollingSession", () => {
// Clean up: abort to end the session
abort.abort();
firstTaskResolve?.();
resolveFirstTask();
await runPromise;
} finally {
setIntervalSpy.mockRestore();
clearIntervalSpy.mockRestore();
setTimeoutSpy.mockRestore();
clearTimeoutSpy.mockRestore();
dateNowSpy.mockRestore();
watchdogHarness.restore();
}
});
@@ -482,85 +469,26 @@ describe("TelegramPollingSession", () => {
const abort = new AbortController();
const botStop = vi.fn(async () => undefined);
const runnerStop = vi.fn(async () => undefined);
const getApiMiddleware = mockBotCapturingApiMiddleware(botStop);
const resolveFirstTask = mockLongRunningPollingCycle(runnerStop);
let apiMiddleware:
| ((
prev: (...args: unknown[]) => Promise<unknown>,
method: string,
payload: unknown,
) => Promise<unknown>)
| undefined;
createTelegramBotMock.mockReturnValueOnce({
api: {
deleteWebhook: vi.fn(async () => true),
getUpdates: vi.fn(async () => []),
config: {
use: vi.fn((fn: typeof apiMiddleware) => {
apiMiddleware = fn;
}),
},
},
stop: botStop,
});
const watchdogHarness = installPollingStallWatchdogHarness([0, 0, 60_000]);
let firstTaskResolve: (() => void) | undefined;
runMock.mockReturnValue({
task: () =>
new Promise<void>((resolve) => {
firstTaskResolve = resolve;
}),
stop: async () => {
await runnerStop();
firstTaskResolve?.();
},
isRunning: () => true,
});
// t=0: lastGetUpdatesAt and lastApiActivityAt initialized
const setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation((fn) => {
watchdog = fn as () => void;
return 1 as unknown as ReturnType<typeof setInterval>;
});
const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval").mockImplementation(() => {});
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout").mockImplementation((fn) => {
void Promise.resolve().then(() => (fn as () => void)());
return 1 as unknown as ReturnType<typeof setTimeout>;
});
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout").mockImplementation(() => {});
const dateNowSpy = vi
.spyOn(Date, "now")
.mockImplementationOnce(() => 0) // lastGetUpdatesAt init
.mockImplementationOnce(() => 0) // lastApiActivityAt init
.mockImplementationOnce(() => 60_000) // sendMessage start
.mockImplementation(() => 120_001);
let watchdog: (() => void) | undefined;
const log = vi.fn();
const session = new TelegramPollingSession({
token: "tok",
config: {},
accountId: "default",
runtime: undefined,
proxyFetch: undefined,
const session = createPollingSession({
abortSignal: abort.signal,
runnerOptions: {},
getLastUpdateId: () => null,
persistUpdateId: async () => undefined,
log,
telegramTransport: undefined,
});
try {
const runPromise = session.runUntilAbort();
for (let attempt = 0; attempt < 20 && !watchdog; attempt += 1) {
await Promise.resolve();
}
expect(watchdog).toBeTypeOf("function");
const watchdog = await watchdogHarness.waitForWatchdog();
// Start an in-flight sendMessage that has NOT yet resolved.
// This simulates a slow delivery where the API call is still pending.
let resolveSendMessage: ((v: unknown) => void) | undefined;
const apiMiddleware = getApiMiddleware();
if (apiMiddleware) {
const slowPrev = vi.fn(
() =>
@@ -586,14 +514,10 @@ describe("TelegramPollingSession", () => {
}
abort.abort();
firstTaskResolve?.();
resolveFirstTask();
await runPromise;
} finally {
setIntervalSpy.mockRestore();
clearIntervalSpy.mockRestore();
setTimeoutSpy.mockRestore();
clearTimeoutSpy.mockRestore();
dateNowSpy.mockRestore();
watchdogHarness.restore();
}
});
@@ -601,82 +525,24 @@ describe("TelegramPollingSession", () => {
const abort = new AbortController();
const botStop = vi.fn(async () => undefined);
const runnerStop = vi.fn(async () => undefined);
const getApiMiddleware = mockBotCapturingApiMiddleware(botStop);
const resolveFirstTask = mockLongRunningPollingCycle(runnerStop);
let apiMiddleware:
| ((
prev: (...args: unknown[]) => Promise<unknown>,
method: string,
payload: unknown,
) => Promise<unknown>)
| undefined;
createTelegramBotMock.mockReturnValueOnce({
api: {
deleteWebhook: vi.fn(async () => true),
getUpdates: vi.fn(async () => []),
config: {
use: vi.fn((fn: typeof apiMiddleware) => {
apiMiddleware = fn;
}),
},
},
stop: botStop,
});
const watchdogHarness = installPollingStallWatchdogHarness([0, 0, 1]);
let firstTaskResolve: (() => void) | undefined;
runMock.mockReturnValue({
task: () =>
new Promise<void>((resolve) => {
firstTaskResolve = resolve;
}),
stop: async () => {
await runnerStop();
firstTaskResolve?.();
},
isRunning: () => true,
});
const setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation((fn) => {
watchdog = fn as () => void;
return 1 as unknown as ReturnType<typeof setInterval>;
});
const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval").mockImplementation(() => {});
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout").mockImplementation((fn) => {
void Promise.resolve().then(() => (fn as () => void)());
return 1 as unknown as ReturnType<typeof setTimeout>;
});
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout").mockImplementation(() => {});
const dateNowSpy = vi
.spyOn(Date, "now")
.mockImplementationOnce(() => 0) // lastGetUpdatesAt init
.mockImplementationOnce(() => 0) // lastApiActivityAt init
.mockImplementationOnce(() => 1) // sendMessage start
.mockImplementation(() => 120_001);
let watchdog: (() => void) | undefined;
const log = vi.fn();
const session = new TelegramPollingSession({
token: "tok",
config: {},
accountId: "default",
runtime: undefined,
proxyFetch: undefined,
const session = createPollingSession({
abortSignal: abort.signal,
runnerOptions: {},
getLastUpdateId: () => null,
persistUpdateId: async () => undefined,
log,
telegramTransport: undefined,
});
try {
const runPromise = session.runUntilAbort();
for (let attempt = 0; attempt < 20 && !watchdog; attempt += 1) {
await Promise.resolve();
}
expect(watchdog).toBeTypeOf("function");
const watchdog = await watchdogHarness.waitForWatchdog();
let resolveSendMessage: ((v: unknown) => void) | undefined;
const apiMiddleware = getApiMiddleware();
if (apiMiddleware) {
const slowPrev = vi.fn(
() =>
@@ -699,14 +565,10 @@ describe("TelegramPollingSession", () => {
}
abort.abort();
firstTaskResolve?.();
resolveFirstTask();
await runPromise;
} finally {
setIntervalSpy.mockRestore();
clearIntervalSpy.mockRestore();
setTimeoutSpy.mockRestore();
clearTimeoutSpy.mockRestore();
dateNowSpy.mockRestore();
watchdogHarness.restore();
}
});
@@ -714,84 +576,25 @@ describe("TelegramPollingSession", () => {
const abort = new AbortController();
const botStop = vi.fn(async () => undefined);
const runnerStop = vi.fn(async () => undefined);
const getApiMiddleware = mockBotCapturingApiMiddleware(botStop);
const resolveFirstTask = mockLongRunningPollingCycle(runnerStop);
let apiMiddleware:
| ((
prev: (...args: unknown[]) => Promise<unknown>,
method: string,
payload: unknown,
) => Promise<unknown>)
| undefined;
createTelegramBotMock.mockReturnValueOnce({
api: {
deleteWebhook: vi.fn(async () => true),
getUpdates: vi.fn(async () => []),
config: {
use: vi.fn((fn: typeof apiMiddleware) => {
apiMiddleware = fn;
}),
},
},
stop: botStop,
});
const watchdogHarness = installPollingStallWatchdogHarness([0, 0, 1, 120_000]);
let firstTaskResolve: (() => void) | undefined;
runMock.mockReturnValue({
task: () =>
new Promise<void>((resolve) => {
firstTaskResolve = resolve;
}),
stop: async () => {
await runnerStop();
firstTaskResolve?.();
},
isRunning: () => true,
});
const setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation((fn) => {
watchdog = fn as () => void;
return 1 as unknown as ReturnType<typeof setInterval>;
});
const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval").mockImplementation(() => {});
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout").mockImplementation((fn) => {
void Promise.resolve().then(() => (fn as () => void)());
return 1 as unknown as ReturnType<typeof setTimeout>;
});
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout").mockImplementation(() => {});
const dateNowSpy = vi
.spyOn(Date, "now")
.mockImplementationOnce(() => 0) // lastGetUpdatesAt init
.mockImplementationOnce(() => 0) // lastApiActivityAt init
.mockImplementationOnce(() => 1) // first sendMessage start
.mockImplementationOnce(() => 120_000) // second sendMessage start
.mockImplementation(() => 120_001);
let watchdog: (() => void) | undefined;
const log = vi.fn();
const session = new TelegramPollingSession({
token: "tok",
config: {},
accountId: "default",
runtime: undefined,
proxyFetch: undefined,
const session = createPollingSession({
abortSignal: abort.signal,
runnerOptions: {},
getLastUpdateId: () => null,
persistUpdateId: async () => undefined,
log,
telegramTransport: undefined,
});
try {
const runPromise = session.runUntilAbort();
for (let attempt = 0; attempt < 20 && !watchdog; attempt += 1) {
await Promise.resolve();
}
expect(watchdog).toBeTypeOf("function");
const watchdog = await watchdogHarness.waitForWatchdog();
let resolveFirstSend: ((v: unknown) => void) | undefined;
let resolveSecondSend: ((v: unknown) => void) | undefined;
const apiMiddleware = getApiMiddleware();
if (apiMiddleware) {
const firstSendPromise = apiMiddleware(
vi.fn(
@@ -829,14 +632,10 @@ describe("TelegramPollingSession", () => {
}
abort.abort();
firstTaskResolve?.();
resolveFirstTask();
await runPromise;
} finally {
setIntervalSpy.mockRestore();
clearIntervalSpy.mockRestore();
setTimeoutSpy.mockRestore();
clearTimeoutSpy.mockRestore();
dateNowSpy.mockRestore();
watchdogHarness.restore();
}
});
@@ -1,45 +1,20 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildTogetherVideoGenerationProvider } from "./video-generation-provider.js";
import { beforeAll, describe, expect, it, vi } from "vitest";
import {
getProviderHttpMocks,
installProviderHttpMockCleanup,
} from "../../test/helpers/media-generation/provider-http-mocks.js";
const {
resolveApiKeyForProviderMock,
postJsonRequestMock,
fetchWithTimeoutMock,
assertOkOrThrowHttpErrorMock,
resolveProviderHttpRequestConfigMock,
} = vi.hoisted(() => ({
resolveApiKeyForProviderMock: vi.fn(async () => ({ apiKey: "together-key" })),
postJsonRequestMock: vi.fn(),
fetchWithTimeoutMock: vi.fn(),
assertOkOrThrowHttpErrorMock: vi.fn(async () => {}),
resolveProviderHttpRequestConfigMock: vi.fn((params) => ({
baseUrl: params.baseUrl ?? params.defaultBaseUrl,
allowPrivateNetwork: false,
headers: new Headers(params.defaultHeaders),
dispatcherPolicy: undefined,
})),
}));
const { postJsonRequestMock, fetchWithTimeoutMock } = getProviderHttpMocks();
vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
}));
let buildTogetherVideoGenerationProvider: typeof import("./video-generation-provider.js").buildTogetherVideoGenerationProvider;
vi.mock("openclaw/plugin-sdk/provider-http", () => ({
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
fetchWithTimeout: fetchWithTimeoutMock,
postJsonRequest: postJsonRequestMock,
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
}));
beforeAll(async () => {
({ buildTogetherVideoGenerationProvider } = await import("./video-generation-provider.js"));
});
installProviderHttpMockCleanup();
describe("together video generation provider", () => {
afterEach(() => {
resolveApiKeyForProviderMock.mockClear();
postJsonRequestMock.mockReset();
fetchWithTimeoutMock.mockReset();
assertOkOrThrowHttpErrorMock.mockClear();
resolveProviderHttpRequestConfigMock.mockClear();
});
it("creates a video, polls completion, and downloads the output", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
@@ -99,6 +99,39 @@ function getCapturedDeliver() {
)?.dispatcherOptions?.deliver;
}
type BufferedReplyParams = Parameters<typeof dispatchWhatsAppBufferedReply>[0];
function makeReplyLogger(): BufferedReplyParams["replyLogger"] {
return {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
} as never;
}
async function dispatchBufferedReply(overrides: Partial<BufferedReplyParams> = {}) {
const params: BufferedReplyParams = {
cfg: { channels: { whatsapp: { blockStreaming: true } } } as never,
connectionId: "conn",
context: { Body: "hi" },
conversationId: "+1000",
deliverReply: async () => {},
groupHistories: new Map(),
groupHistoryKey: "+1000",
maxMediaBytes: 1,
msg: makeMsg(),
rememberSentText: () => {},
replyLogger: makeReplyLogger(),
replyPipeline: {} as never,
replyResolver: (async () => undefined) as never,
route: makeRoute(),
shouldClearGroupHistory: false,
};
return dispatchWhatsAppBufferedReply({ ...params, ...overrides });
}
describe("whatsapp inbound dispatch", () => {
beforeEach(() => {
capturedDispatchParams = undefined;
@@ -197,29 +230,16 @@ describe("whatsapp inbound dispatch", () => {
["whatsapp:default:group:123@g.us", [{ sender: "Alice (+111)", body: "first" }]],
]);
await dispatchWhatsAppBufferedReply({
cfg: { channels: { whatsapp: { blockStreaming: true } } } as never,
connectionId: "conn",
await dispatchBufferedReply({
context: { Body: "second" },
conversationId: "123@g.us",
deliverReply: async () => {},
groupHistories,
groupHistoryKey: "whatsapp:default:group:123@g.us",
maxMediaBytes: 1,
msg: makeMsg({
from: "123@g.us",
chatType: "group",
senderE164: "+222",
}),
rememberSentText: () => {},
replyLogger: {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
} as never,
replyPipeline: {},
replyResolver: (async () => undefined) as never,
route: makeRoute({ sessionKey: "agent:main:whatsapp:group:123@g.us" }),
shouldClearGroupHistory: true,
});
@@ -231,27 +251,9 @@ describe("whatsapp inbound dispatch", () => {
const deliverReply = vi.fn(async () => undefined);
const rememberSentText = vi.fn();
await dispatchWhatsAppBufferedReply({
cfg: { channels: { whatsapp: { blockStreaming: true } } } as never,
connectionId: "conn",
context: { Body: "hi" },
conversationId: "+1000",
await dispatchBufferedReply({
deliverReply,
groupHistories: new Map(),
groupHistoryKey: "+1000",
maxMediaBytes: 1,
msg: makeMsg(),
rememberSentText,
replyLogger: {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
} as never,
replyPipeline: {},
replyResolver: (async () => undefined) as never,
route: makeRoute(),
shouldClearGroupHistory: false,
});
const deliver = getCapturedDeliver();
@@ -271,27 +273,9 @@ describe("whatsapp inbound dispatch", () => {
const deliverReply = vi.fn(async () => undefined);
const rememberSentText = vi.fn();
await dispatchWhatsAppBufferedReply({
cfg: { channels: { whatsapp: { blockStreaming: true } } } as never,
connectionId: "conn",
context: { Body: "hi" },
conversationId: "+1000",
await dispatchBufferedReply({
deliverReply,
groupHistories: new Map(),
groupHistoryKey: "+1000",
maxMediaBytes: 1,
msg: makeMsg(),
rememberSentText,
replyLogger: {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
} as never,
replyPipeline: {},
replyResolver: (async () => undefined) as never,
route: makeRoute(),
shouldClearGroupHistory: false,
});
const deliver = getCapturedDeliver();
@@ -307,28 +291,7 @@ describe("whatsapp inbound dispatch", () => {
});
it("maps WhatsApp blockStreaming=true to disableBlockStreaming=false", async () => {
await dispatchWhatsAppBufferedReply({
cfg: { channels: { whatsapp: { blockStreaming: true } } } as never,
connectionId: "conn",
context: { Body: "hi" },
conversationId: "+1000",
deliverReply: async () => {},
groupHistories: new Map(),
groupHistoryKey: "+1000",
maxMediaBytes: 1,
msg: makeMsg(),
rememberSentText: () => {},
replyLogger: {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
} as never,
replyPipeline: {},
replyResolver: (async () => undefined) as never,
route: makeRoute(),
shouldClearGroupHistory: false,
});
await dispatchBufferedReply();
expect(
(
@@ -340,27 +303,8 @@ describe("whatsapp inbound dispatch", () => {
});
it("maps WhatsApp blockStreaming=false to disableBlockStreaming=true", async () => {
await dispatchWhatsAppBufferedReply({
await dispatchBufferedReply({
cfg: { channels: { whatsapp: { blockStreaming: false } } } as never,
connectionId: "conn",
context: { Body: "hi" },
conversationId: "+1000",
deliverReply: async () => {},
groupHistories: new Map(),
groupHistoryKey: "+1000",
maxMediaBytes: 1,
msg: makeMsg(),
rememberSentText: () => {},
replyLogger: {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
} as never,
replyPipeline: {},
replyResolver: (async () => undefined) as never,
route: makeRoute(),
shouldClearGroupHistory: false,
});
expect(
@@ -373,27 +317,8 @@ describe("whatsapp inbound dispatch", () => {
});
it("leaves disableBlockStreaming undefined when WhatsApp blockStreaming is unset", async () => {
await dispatchWhatsAppBufferedReply({
await dispatchBufferedReply({
cfg: { channels: { whatsapp: {} } } as never,
connectionId: "conn",
context: { Body: "hi" },
conversationId: "+1000",
deliverReply: async () => {},
groupHistories: new Map(),
groupHistoryKey: "+1000",
maxMediaBytes: 1,
msg: makeMsg(),
rememberSentText: () => {},
replyLogger: {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
} as never,
replyPipeline: {},
replyResolver: (async () => undefined) as never,
route: makeRoute(),
shouldClearGroupHistory: false,
});
expect(
@@ -425,27 +350,9 @@ describe("whatsapp inbound dispatch", () => {
);
await expect(
dispatchWhatsAppBufferedReply({
cfg: { channels: { whatsapp: { blockStreaming: true } } } as never,
connectionId: "conn",
context: { Body: "hi" },
conversationId: "+1000",
dispatchBufferedReply({
deliverReply,
groupHistories: new Map(),
groupHistoryKey: "+1000",
maxMediaBytes: 1,
msg: makeMsg(),
rememberSentText,
replyLogger: {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
} as never,
replyPipeline: {},
replyResolver: (async () => undefined) as never,
route: makeRoute(),
shouldClearGroupHistory: false,
}),
).resolves.toBe(true);
@@ -456,27 +363,8 @@ describe("whatsapp inbound dispatch", () => {
it("passes sendComposing through as the reply typing callback", async () => {
const sendComposing = vi.fn(async () => undefined);
await dispatchWhatsAppBufferedReply({
cfg: { channels: { whatsapp: { blockStreaming: true } } } as never,
connectionId: "conn",
context: { Body: "hi" },
conversationId: "+1000",
deliverReply: async () => {},
groupHistories: new Map(),
groupHistoryKey: "+1000",
maxMediaBytes: 1,
await dispatchBufferedReply({
msg: makeMsg({ sendComposing }),
rememberSentText: () => {},
replyLogger: {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
} as never,
replyPipeline: {},
replyResolver: (async () => undefined) as never,
route: makeRoute(),
shouldClearGroupHistory: false,
});
expect(
+27 -56
View File
@@ -2,11 +2,25 @@ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
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 { checkWhatsAppHeartbeatReady } from "./heartbeat.js";
import { whatsappApprovalAuth } from "./approval-auth.js";
import { whatsappPlugin } from "./channel.js";
import { checkWhatsAppHeartbeatReady } from "./heartbeat.js";
import type { OpenClawConfig } from "./runtime-api.js";
import { finalizeWhatsAppSetup } from "./setup-finalize.js";
import {
createWhatsAppAllowlistModeInput,
createWhatsAppLinkingHarness,
createWhatsAppOwnerAllowlistHarness,
createWhatsAppPersonalPhoneHarness,
createWhatsAppRootAllowFromConfig,
expectNoWhatsAppLoginFollowup,
expectWhatsAppAllowlistModeSetup,
expectWhatsAppLoginFollowup,
expectWhatsAppOpenPolicySetup,
expectWhatsAppOwnerAllowlistSetup,
expectWhatsAppPersonalPhoneSetup,
expectWhatsAppSeparatePhoneDisabledSetup,
} from "./setup-test-helpers.js";
const hoisted = vi.hoisted(() => ({
loginWeb: vi.fn(async () => {}),
@@ -129,10 +143,7 @@ describe("whatsapp setup wizard", () => {
});
it("applies owner allowlist when forceAllowFrom is enabled", async () => {
const harness = createQueuedWizardPrompter({
confirmValues: [false],
textValues: ["+1 (555) 555-0123"],
});
const harness = createWhatsAppOwnerAllowlistHarness(createQueuedWizardPrompter);
const result = await runConfigureWithHarness({
harness,
@@ -141,14 +152,7 @@ describe("whatsapp setup wizard", () => {
expect(result.accountId).toBe(DEFAULT_ACCOUNT_ID);
expect(hoisted.loginWeb).not.toHaveBeenCalled();
expect(result.cfg.channels?.whatsapp?.selfChatMode).toBe(true);
expect(result.cfg.channels?.whatsapp?.dmPolicy).toBe("allowlist");
expect(result.cfg.channels?.whatsapp?.allowFrom).toEqual(["+15555550123"]);
expect(harness.text).toHaveBeenCalledWith(
expect.objectContaining({
message: "Your personal WhatsApp number (the phone you will message from)",
}),
);
expectWhatsAppOwnerAllowlistSetup(result.cfg, harness);
});
it("supports disabled DM policy for separate-phone setup", async () => {
@@ -156,38 +160,24 @@ describe("whatsapp setup wizard", () => {
selectValues: ["separate", "disabled"],
});
expect(result.cfg.channels?.whatsapp?.selfChatMode).toBe(false);
expect(result.cfg.channels?.whatsapp?.dmPolicy).toBe("disabled");
expect(result.cfg.channels?.whatsapp?.allowFrom).toBeUndefined();
expect(harness.text).not.toHaveBeenCalled();
expectWhatsAppSeparatePhoneDisabledSetup(result.cfg, harness);
});
it("normalizes allowFrom entries when list mode is selected", async () => {
const { result } = await runSeparatePhoneFlow({
selectValues: ["separate", "allowlist", "list"],
textValues: ["+1 (555) 555-0123, +15555550123, *"],
});
const { result } = await runSeparatePhoneFlow(createWhatsAppAllowlistModeInput());
expect(result.cfg.channels?.whatsapp?.selfChatMode).toBe(false);
expect(result.cfg.channels?.whatsapp?.dmPolicy).toBe("allowlist");
expect(result.cfg.channels?.whatsapp?.allowFrom).toEqual(["+15555550123", "*"]);
expectWhatsAppAllowlistModeSetup(result.cfg);
});
it("enables allowlist self-chat mode for personal-phone setup", async () => {
hoisted.pathExists.mockResolvedValue(true);
const harness = createQueuedWizardPrompter({
confirmValues: [false],
selectValues: ["personal"],
textValues: ["+1 (555) 111-2222"],
});
const harness = createWhatsAppPersonalPhoneHarness(createQueuedWizardPrompter);
const result = await runConfigureWithHarness({
harness,
});
expect(result.cfg.channels?.whatsapp?.selfChatMode).toBe(true);
expect(result.cfg.channels?.whatsapp?.dmPolicy).toBe("allowlist");
expect(result.cfg.channels?.whatsapp?.allowFrom).toEqual(["+15551112222"]);
expectWhatsAppPersonalPhoneSetup(result.cfg);
});
it("forces wildcard allowFrom for open policy without allowFrom follow-up prompts", async () => {
@@ -198,28 +188,15 @@ describe("whatsapp setup wizard", () => {
const result = await runConfigureWithHarness({
harness,
cfg: {
channels: {
whatsapp: {
allowFrom: ["+15555550123"],
},
},
},
cfg: createWhatsAppRootAllowFromConfig() as OpenClawConfig,
});
expect(result.cfg.channels?.whatsapp?.selfChatMode).toBe(false);
expect(result.cfg.channels?.whatsapp?.dmPolicy).toBe("open");
expect(result.cfg.channels?.whatsapp?.allowFrom).toEqual(["*", "+15555550123"]);
expect(harness.select).toHaveBeenCalledTimes(2);
expect(harness.text).not.toHaveBeenCalled();
expectWhatsAppOpenPolicySetup(result.cfg, harness);
});
it("runs WhatsApp login when not linked and user confirms linking", async () => {
hoisted.pathExists.mockResolvedValue(false);
const harness = createQueuedWizardPrompter({
confirmValues: [true],
selectValues: ["separate", "disabled"],
});
const harness = createWhatsAppLinkingHarness(createQueuedWizardPrompter);
const runtime = createRuntime();
await runConfigureWithHarness({
@@ -241,10 +218,7 @@ describe("whatsapp setup wizard", () => {
});
expect(hoisted.loginWeb).not.toHaveBeenCalled();
expect(harness.note).not.toHaveBeenCalledWith(
expect.stringContaining("openclaw channels login"),
"WhatsApp",
);
expectNoWhatsAppLoginFollowup(harness);
});
it("shows follow-up login command note when not linked and linking is skipped", async () => {
@@ -257,10 +231,7 @@ describe("whatsapp setup wizard", () => {
harness,
});
expect(harness.note).toHaveBeenCalledWith(
expect.stringContaining("openclaw channels login"),
"WhatsApp",
);
expectWhatsAppLoginFollowup(harness);
});
it("heartbeat readiness uses configured defaultAccount for active listener checks", async () => {
+35 -102
View File
@@ -7,6 +7,23 @@ import {
runSetupWizardFinalize,
} from "../../../test/helpers/plugins/setup-wizard.js";
import { whatsappSetupWizard } from "./setup-surface.js";
import {
createWhatsAppAllowlistModeInput,
createWhatsAppLinkingHarness,
createWhatsAppOwnerAllowlistHarness,
createWhatsAppPersonalPhoneHarness,
createWhatsAppRootAllowFromConfig,
createWhatsAppWorkAccountConfig,
expectNoWhatsAppLoginFollowup,
expectWhatsAppAllowlistModeSetup,
expectWhatsAppLoginFollowup,
expectWhatsAppOpenPolicySetup,
expectWhatsAppOwnerAllowlistSetup,
expectWhatsAppPersonalPhoneSetup,
expectWhatsAppSeparatePhoneDisabledSetup,
expectWhatsAppWorkAccountAccessNote,
expectWhatsAppWorkAccountOpenAccess,
} from "./setup-test-helpers.js";
const hoisted = vi.hoisted(() => ({
detectWhatsAppLinked: vi.fn<(cfg: OpenClawConfig, accountId: string) => Promise<boolean>>(
@@ -124,10 +141,7 @@ describe("whatsapp setup wizard", () => {
});
it("applies owner allowlist when forceAllowFrom is enabled", async () => {
const harness = createQueuedWizardPrompter({
confirmValues: [false],
textValues: ["+1 (555) 555-0123"],
});
const harness = createWhatsAppOwnerAllowlistHarness(createQueuedWizardPrompter);
const result = expectFinalizeResult(
await runFinalizeWithHarness({
@@ -137,14 +151,7 @@ describe("whatsapp setup wizard", () => {
);
expect(hoisted.loginWeb).not.toHaveBeenCalled();
expect(result.cfg.channels?.whatsapp?.selfChatMode).toBe(true);
expect(result.cfg.channels?.whatsapp?.dmPolicy).toBe("allowlist");
expect(result.cfg.channels?.whatsapp?.allowFrom).toEqual(["+15555550123"]);
expect(harness.text).toHaveBeenCalledWith(
expect.objectContaining({
message: "Your personal WhatsApp number (the phone you will message from)",
}),
);
expectWhatsAppOwnerAllowlistSetup(result.cfg, harness);
});
it("supports disabled DM policy for separate-phone setup", async () => {
@@ -152,10 +159,7 @@ describe("whatsapp setup wizard", () => {
selectValues: ["separate", "disabled"],
});
expect(result.cfg.channels?.whatsapp?.selfChatMode).toBe(false);
expect(result.cfg.channels?.whatsapp?.dmPolicy).toBe("disabled");
expect(result.cfg.channels?.whatsapp?.allowFrom).toBeUndefined();
expect(harness.text).not.toHaveBeenCalled();
expectWhatsAppSeparatePhoneDisabledSetup(result.cfg, harness);
});
it("writes named-account DM policy and allowFrom instead of the channel root", async () => {
@@ -168,32 +172,12 @@ describe("whatsapp setup wizard", () => {
await runFinalizeWithHarness({
harness,
accountId: "work",
cfg: {
channels: {
whatsapp: {
dmPolicy: "disabled",
allowFrom: ["+15555550123"],
accounts: {
work: {
authDir: "/tmp/work",
},
},
},
},
},
cfg: createWhatsAppWorkAccountConfig() as OpenClawConfig,
}),
);
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(harness.note).toHaveBeenCalledWith(
expect.stringContaining(
"`channels.whatsapp.accounts.work.dmPolicy` + `channels.whatsapp.accounts.work.allowFrom`",
),
"WhatsApp DM access",
);
expectWhatsAppWorkAccountOpenAccess(named.cfg);
expectWhatsAppWorkAccountAccessNote(harness);
});
it("labels the selected named account in setup status even when not linked", async () => {
@@ -261,53 +245,23 @@ describe("whatsapp setup wizard", () => {
await runFinalizeWithHarness({
harness,
accountId: "",
cfg: {
channels: {
whatsapp: {
defaultAccount: "work",
dmPolicy: "disabled",
allowFrom: ["+15555550123"],
accounts: {
work: {
authDir: "/tmp/work",
},
},
},
},
},
cfg: createWhatsAppWorkAccountConfig({ defaultAccount: "work" }) as OpenClawConfig,
}),
);
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",
);
expectWhatsAppWorkAccountOpenAccess(result.cfg);
expectWhatsAppWorkAccountAccessNote(harness);
});
it("normalizes allowFrom entries when list mode is selected", async () => {
const { result } = await runSeparatePhoneFlow({
selectValues: ["separate", "allowlist", "list"],
textValues: ["+1 (555) 555-0123, +15555550123, *"],
});
const { result } = await runSeparatePhoneFlow(createWhatsAppAllowlistModeInput());
expect(result.cfg.channels?.whatsapp?.selfChatMode).toBe(false);
expect(result.cfg.channels?.whatsapp?.dmPolicy).toBe("allowlist");
expect(result.cfg.channels?.whatsapp?.allowFrom).toEqual(["+15555550123", "*"]);
expectWhatsAppAllowlistModeSetup(result.cfg);
});
it("enables allowlist self-chat mode for personal-phone setup", async () => {
hoisted.pathExists.mockResolvedValue(true);
const harness = createQueuedWizardPrompter({
confirmValues: [false],
selectValues: ["personal"],
textValues: ["+1 (555) 111-2222"],
});
const harness = createWhatsAppPersonalPhoneHarness(createQueuedWizardPrompter);
const result = expectFinalizeResult(
await runFinalizeWithHarness({
@@ -315,9 +269,7 @@ describe("whatsapp setup wizard", () => {
}),
);
expect(result.cfg.channels?.whatsapp?.selfChatMode).toBe(true);
expect(result.cfg.channels?.whatsapp?.dmPolicy).toBe("allowlist");
expect(result.cfg.channels?.whatsapp?.allowFrom).toEqual(["+15551112222"]);
expectWhatsAppPersonalPhoneSetup(result.cfg);
});
it("forces wildcard allowFrom for open policy without allowFrom follow-up prompts", async () => {
@@ -329,29 +281,16 @@ describe("whatsapp setup wizard", () => {
const result = expectFinalizeResult(
await runFinalizeWithHarness({
harness,
cfg: {
channels: {
whatsapp: {
allowFrom: ["+15555550123"],
},
},
},
cfg: createWhatsAppRootAllowFromConfig() as OpenClawConfig,
}),
);
expect(result.cfg.channels?.whatsapp?.selfChatMode).toBe(false);
expect(result.cfg.channels?.whatsapp?.dmPolicy).toBe("open");
expect(result.cfg.channels?.whatsapp?.allowFrom).toEqual(["*", "+15555550123"]);
expect(harness.select).toHaveBeenCalledTimes(2);
expect(harness.text).not.toHaveBeenCalled();
expectWhatsAppOpenPolicySetup(result.cfg, harness);
});
it("runs WhatsApp login when not linked and user confirms linking", async () => {
hoisted.pathExists.mockResolvedValue(false);
const harness = createQueuedWizardPrompter({
confirmValues: [true],
selectValues: ["separate", "disabled"],
});
const harness = createWhatsAppLinkingHarness(createQueuedWizardPrompter);
const runtime = createRuntime();
await runFinalizeWithHarness({
@@ -373,10 +312,7 @@ describe("whatsapp setup wizard", () => {
});
expect(hoisted.loginWeb).not.toHaveBeenCalled();
expect(harness.note).not.toHaveBeenCalledWith(
expect.stringContaining("openclaw channels login"),
"WhatsApp",
);
expectNoWhatsAppLoginFollowup(harness);
});
it("shows follow-up login command note when not linked and linking is skipped", async () => {
@@ -389,9 +325,6 @@ describe("whatsapp setup wizard", () => {
harness,
});
expect(harness.note).toHaveBeenCalledWith(
expect.stringContaining("openclaw channels login"),
"WhatsApp",
);
expectWhatsAppLoginFollowup(harness);
});
});
@@ -0,0 +1,207 @@
import { expect } from "vitest";
type WhatsAppSetupConfig = {
channels?: {
whatsapp?: {
selfChatMode?: boolean;
dmPolicy?: string;
allowFrom?: string[];
accounts?: Record<string, { dmPolicy?: string; allowFrom?: string[]; authDir?: string }>;
};
};
};
type WizardPromptHarness = {
text: { (...args: unknown[]): unknown };
select: { (...args: unknown[]): unknown };
note: { (...args: unknown[]): unknown };
};
type QueuedWizardPrompterFactory<T extends WizardPromptHarness> = (params: {
confirmValues?: boolean[];
selectValues?: string[];
textValues?: string[];
}) => T;
export const WHATSAPP_OWNER_NUMBER_INPUT = "+1 (555) 555-0123";
export const WHATSAPP_OWNER_NUMBER = "+15555550123";
export const WHATSAPP_PERSONAL_NUMBER_INPUT = "+1 (555) 111-2222";
export const WHATSAPP_PERSONAL_NUMBER = "+15551112222";
export const WHATSAPP_ACCESS_NOTE_TITLE = "WhatsApp DM access";
export const WHATSAPP_LOGIN_NOTE_TITLE = "WhatsApp";
export function createWhatsAppRootAllowFromConfig(): WhatsAppSetupConfig {
return {
channels: {
whatsapp: {
allowFrom: [WHATSAPP_OWNER_NUMBER],
},
},
};
}
export function createWhatsAppOwnerAllowlistHarness<T extends WizardPromptHarness>(
createPrompter: QueuedWizardPrompterFactory<T>,
): T {
return createPrompter({
confirmValues: [false],
textValues: [WHATSAPP_OWNER_NUMBER_INPUT],
});
}
export function createWhatsAppPersonalPhoneHarness<T extends WizardPromptHarness>(
createPrompter: QueuedWizardPrompterFactory<T>,
): T {
return createPrompter({
confirmValues: [false],
selectValues: ["personal"],
textValues: [WHATSAPP_PERSONAL_NUMBER_INPUT],
});
}
export function createWhatsAppLinkingHarness<T extends WizardPromptHarness>(
createPrompter: QueuedWizardPrompterFactory<T>,
): T {
return createPrompter({
confirmValues: [true],
selectValues: ["separate", "disabled"],
});
}
export function createWhatsAppWorkAccountConfig(
params: {
defaultAccount?: string;
} = {},
): WhatsAppSetupConfig {
return {
channels: {
whatsapp: {
...(params.defaultAccount ? { defaultAccount: params.defaultAccount } : {}),
dmPolicy: "disabled",
allowFrom: [WHATSAPP_OWNER_NUMBER],
accounts: {
work: {
authDir: "/tmp/work",
},
},
},
},
};
}
export function createWhatsAppAllowlistModeInput(): {
selectValues: string[];
textValues: string[];
} {
return {
selectValues: ["separate", "allowlist", "list"],
textValues: [`${WHATSAPP_OWNER_NUMBER_INPUT}, ${WHATSAPP_OWNER_NUMBER}, *`],
};
}
export function expectWhatsAppDmAccess(
cfg: WhatsAppSetupConfig,
expected: {
selfChatMode: boolean;
dmPolicy: string;
allowFrom?: string[];
},
): void {
expect(cfg.channels?.whatsapp?.selfChatMode).toBe(expected.selfChatMode);
expect(cfg.channels?.whatsapp?.dmPolicy).toBe(expected.dmPolicy);
if ("allowFrom" in expected) {
expect(cfg.channels?.whatsapp?.allowFrom).toEqual(expected.allowFrom);
} else {
expect(cfg.channels?.whatsapp?.allowFrom).toBeUndefined();
}
}
export function expectWhatsAppWorkAccountOpenAccess(cfg: WhatsAppSetupConfig): void {
expect(cfg.channels?.whatsapp?.dmPolicy).toBe("disabled");
expect(cfg.channels?.whatsapp?.allowFrom).toEqual([WHATSAPP_OWNER_NUMBER]);
expect(cfg.channels?.whatsapp?.accounts?.work?.dmPolicy).toBe("open");
expect(cfg.channels?.whatsapp?.accounts?.work?.allowFrom).toEqual(["*", WHATSAPP_OWNER_NUMBER]);
}
export function expectWhatsAppOwnerNumberPrompt(harness: WizardPromptHarness): void {
expect(harness.text).toHaveBeenCalledWith(
expect.objectContaining({
message: "Your personal WhatsApp number (the phone you will message from)",
}),
);
}
export function expectWhatsAppOwnerAllowlistSetup(
cfg: WhatsAppSetupConfig,
harness: WizardPromptHarness,
): void {
expectWhatsAppDmAccess(cfg, {
selfChatMode: true,
dmPolicy: "allowlist",
allowFrom: [WHATSAPP_OWNER_NUMBER],
});
expectWhatsAppOwnerNumberPrompt(harness);
}
export function expectWhatsAppSeparatePhoneDisabledSetup(
cfg: WhatsAppSetupConfig,
harness: WizardPromptHarness,
): void {
expectWhatsAppDmAccess(cfg, {
selfChatMode: false,
dmPolicy: "disabled",
});
expect(harness.text).not.toHaveBeenCalled();
}
export function expectWhatsAppAllowlistModeSetup(cfg: WhatsAppSetupConfig): void {
expectWhatsAppDmAccess(cfg, {
selfChatMode: false,
dmPolicy: "allowlist",
allowFrom: [WHATSAPP_OWNER_NUMBER, "*"],
});
}
export function expectWhatsAppPersonalPhoneSetup(cfg: WhatsAppSetupConfig): void {
expectWhatsAppDmAccess(cfg, {
selfChatMode: true,
dmPolicy: "allowlist",
allowFrom: [WHATSAPP_PERSONAL_NUMBER],
});
}
export function expectWhatsAppOpenPolicySetup(
cfg: WhatsAppSetupConfig,
harness: WizardPromptHarness,
): void {
expectWhatsAppDmAccess(cfg, {
selfChatMode: false,
dmPolicy: "open",
allowFrom: ["*", WHATSAPP_OWNER_NUMBER],
});
expect(harness.select).toHaveBeenCalledTimes(2);
expect(harness.text).not.toHaveBeenCalled();
}
export function expectNoWhatsAppLoginFollowup(harness: WizardPromptHarness): void {
expect(harness.note).not.toHaveBeenCalledWith(
expect.stringContaining("openclaw channels login"),
WHATSAPP_LOGIN_NOTE_TITLE,
);
}
export function expectWhatsAppLoginFollowup(harness: WizardPromptHarness): void {
expect(harness.note).toHaveBeenCalledWith(
expect.stringContaining("openclaw channels login"),
WHATSAPP_LOGIN_NOTE_TITLE,
);
}
export function expectWhatsAppWorkAccountAccessNote(harness: WizardPromptHarness): void {
expect(harness.note).toHaveBeenCalledWith(
expect.stringContaining(
"`channels.whatsapp.accounts.work.dmPolicy` + `channels.whatsapp.accounts.work.allowFrom`",
),
WHATSAPP_ACCESS_NOTE_TITLE,
);
}
+43 -76
View File
@@ -82,6 +82,47 @@ function loadSessionStoreMock(storePath: string) {
}
}
type BufferedDispatchReplyParams = {
ctx: Record<string, unknown>;
replyResolver: (ctx: Record<string, unknown>) => Promise<Record<string, unknown> | undefined>;
dispatcherOptions: {
deliver: (
payload: Record<string, unknown>,
info: { kind: "tool" | "block" | "final" },
) => Promise<void>;
onReplyStart?: (() => Promise<void>) | (() => void);
};
};
function createBufferedDispatchReplyMock() {
return vi.fn(async (params: BufferedDispatchReplyParams) => {
await params.dispatcherOptions.onReplyStart?.();
const payload = await params.replyResolver(params.ctx);
if (!payload || typeof payload !== "object") {
return {
queuedFinal: false,
counts: { tool: 0, block: 0, final: 0 },
};
}
const text = typeof payload.text === "string" ? payload.text.trim() : "";
const hasMedia =
typeof payload.mediaUrl === "string" ||
typeof payload.mediaPath === "string" ||
typeof payload.fileUrl === "string";
if (!text && !hasMedia) {
return {
queuedFinal: false,
counts: { tool: 0, block: 0, final: 0 },
};
}
await params.dispatcherOptions.deliver(payload, { kind: "final" });
return {
queuedFinal: true,
counts: { tool: 0, block: 0, final: 1 },
};
});
}
function resolveChannelContextVisibilityModeMock(params: {
cfg: {
channels?: Record<
@@ -225,44 +266,7 @@ vi.mock("./auto-reply/monitor/inbound-dispatch.runtime.js", () => ({
onModelSelected: undefined,
responsePrefix: undefined,
}),
dispatchReplyWithBufferedBlockDispatcher: vi.fn(
async (params: {
ctx: Record<string, unknown>;
replyResolver: (ctx: Record<string, unknown>) => Promise<Record<string, unknown> | undefined>;
dispatcherOptions: {
deliver: (
payload: Record<string, unknown>,
info: { kind: "tool" | "block" | "final" },
) => Promise<void>;
onReplyStart?: (() => Promise<void>) | (() => void);
};
}) => {
await params.dispatcherOptions.onReplyStart?.();
const payload = await params.replyResolver(params.ctx);
if (!payload || typeof payload !== "object") {
return {
queuedFinal: false,
counts: { tool: 0, block: 0, final: 0 },
};
}
const text = typeof payload.text === "string" ? payload.text.trim() : "";
const hasMedia =
typeof payload.mediaUrl === "string" ||
typeof payload.mediaPath === "string" ||
typeof payload.fileUrl === "string";
if (!text && !hasMedia) {
return {
queuedFinal: false,
counts: { tool: 0, block: 0, final: 0 },
};
}
await params.dispatcherOptions.deliver(payload, { kind: "final" });
return {
queuedFinal: true,
counts: { tool: 0, block: 0, final: 1 },
};
},
),
dispatchReplyWithBufferedBlockDispatcher: createBufferedDispatchReplyMock(),
finalizeInboundContext: <T>(ctx: T) => ctx,
getAgentScopedMediaLocalRoots: () => [] as string[],
jidToE164: (jid: string) => {
@@ -304,44 +308,7 @@ vi.mock("./auto-reply/monitor/runtime-api.js", () => ({
onModelSelected: undefined,
responsePrefix: undefined,
}),
dispatchReplyWithBufferedBlockDispatcher: vi.fn(
async (params: {
ctx: Record<string, unknown>;
replyResolver: (ctx: Record<string, unknown>) => Promise<Record<string, unknown> | undefined>;
dispatcherOptions: {
deliver: (
payload: Record<string, unknown>,
info: { kind: "tool" | "block" | "final" },
) => Promise<void>;
onReplyStart?: (() => Promise<void>) | (() => void);
};
}) => {
await params.dispatcherOptions.onReplyStart?.();
const payload = await params.replyResolver(params.ctx);
if (!payload || typeof payload !== "object") {
return {
queuedFinal: false,
counts: { tool: 0, block: 0, final: 0 },
};
}
const text = typeof payload.text === "string" ? payload.text.trim() : "";
const hasMedia =
typeof payload.mediaUrl === "string" ||
typeof payload.mediaPath === "string" ||
typeof payload.fileUrl === "string";
if (!text && !hasMedia) {
return {
queuedFinal: false,
counts: { tool: 0, block: 0, final: 0 },
};
}
await params.dispatcherOptions.deliver(payload, { kind: "final" });
return {
queuedFinal: true,
counts: { tool: 0, block: 0, final: 1 },
};
},
),
dispatchReplyWithBufferedBlockDispatcher: createBufferedDispatchReplyMock(),
finalizeInboundContext: <T>(ctx: T) => ctx,
formatInboundEnvelope: (params: { body: string; senderLabel?: string }) =>
`${params.senderLabel ? `${params.senderLabel}: ` : ""}${params.body}`,
+36 -95
View File
@@ -14,10 +14,12 @@ type XaiTestPayload = Record<string, unknown> & {
tools?: Array<{ type?: string; function?: Record<string, unknown> }>;
input?: unknown[];
};
type XaiStreamApi = Extract<Api, "openai-completions" | "openai-responses">;
function captureWrappedModelId(params: {
modelId: string;
fastMode: boolean;
api?: Extract<Api, "openai-completions" | "openai-responses">;
api?: XaiStreamApi;
}): string {
let capturedModelId = "";
const baseStreamFn: StreamFn = (model) => {
@@ -39,6 +41,33 @@ function captureWrappedModelId(params: {
return capturedModelId;
}
function runXaiToolPayloadWrapper(params: {
payload: Record<string, unknown>;
api?: XaiStreamApi;
modelId?: string;
input?: string[];
}) {
const baseStreamFn: StreamFn = (_model, _context, options) => {
options?.onPayload?.(params.payload, {} as Model<XaiStreamApi>);
return {} as ReturnType<StreamFn>;
};
const wrapped = createXaiToolPayloadCompatibilityWrapper(baseStreamFn);
const api = params.api ?? "openai-responses";
void wrapped(
{
api,
provider: "xai",
id:
params.modelId ??
(api === "openai-completions" ? "grok-4-1-fast-reasoning" : "grok-4-fast"),
...(params.input ? { input: params.input } : {}),
} as Model<XaiStreamApi>,
{ messages: [] } as Context,
{},
);
}
describe("xai stream wrappers", () => {
it("rewrites supported Grok models to fast variants when fast mode is enabled", () => {
expect(captureWrappedModelId({ modelId: "grok-3", fastMode: true })).toBe("grok-3-fast");
@@ -128,21 +157,7 @@ describe("xai stream wrappers", () => {
},
],
};
const baseStreamFn: StreamFn = (_model, _context, options) => {
options?.onPayload?.(payload, {} as Model<"openai-completions">);
return {} as ReturnType<StreamFn>;
};
const wrapped = createXaiToolPayloadCompatibilityWrapper(baseStreamFn);
void wrapped(
{
api: "openai-completions",
provider: "xai",
id: "grok-4-1-fast-reasoning",
} as Model<"openai-completions">,
{ messages: [] } as Context,
{},
);
runXaiToolPayloadWrapper({ payload, api: "openai-completions" });
expect(payload).not.toHaveProperty("reasoning");
expect(payload).not.toHaveProperty("reasoningEffort");
@@ -156,21 +171,7 @@ describe("xai stream wrappers", () => {
reasoningEffort: "high",
reasoning_effort: "high",
};
const baseStreamFn: StreamFn = (_model, _context, options) => {
options?.onPayload?.(payload, {} as Model<"openai-responses">);
return {} as ReturnType<StreamFn>;
};
const wrapped = createXaiToolPayloadCompatibilityWrapper(baseStreamFn);
void wrapped(
{
api: "openai-responses",
provider: "xai",
id: "grok-4-fast",
} as Model<"openai-responses">,
{ messages: [] } as Context,
{},
);
runXaiToolPayloadWrapper({ payload });
expect(payload).not.toHaveProperty("reasoning");
expect(payload).not.toHaveProperty("reasoningEffort");
@@ -194,22 +195,7 @@ describe("xai stream wrappers", () => {
},
],
};
const baseStreamFn: StreamFn = (_model, _context, options) => {
options?.onPayload?.(payload, {} as Model<"openai-responses">);
return {} as ReturnType<StreamFn>;
};
const wrapped = createXaiToolPayloadCompatibilityWrapper(baseStreamFn);
void wrapped(
{
api: "openai-responses",
provider: "xai",
id: "grok-4-fast",
input: ["text", "image"],
} as Model<"openai-responses">,
{ messages: [] } as Context,
{},
);
runXaiToolPayloadWrapper({ payload, input: ["text", "image"] });
expect(payload.input).toEqual([
{
@@ -252,22 +238,7 @@ describe("xai stream wrappers", () => {
},
],
};
const baseStreamFn: StreamFn = (_model, _context, options) => {
options?.onPayload?.(payload, {} as Model<"openai-responses">);
return {} as ReturnType<StreamFn>;
};
const wrapped = createXaiToolPayloadCompatibilityWrapper(baseStreamFn);
void wrapped(
{
api: "openai-responses",
provider: "xai",
id: "grok-4-fast",
input: ["text", "image"],
} as Model<"openai-responses">,
{ messages: [] } as Context,
{},
);
runXaiToolPayloadWrapper({ payload, input: ["text", "image"] });
expect(payload.input).toEqual([
{
@@ -322,22 +293,7 @@ describe("xai stream wrappers", () => {
},
],
};
const baseStreamFn: StreamFn = (_model, _context, options) => {
options?.onPayload?.(payload, {} as Model<"openai-responses">);
return {} as ReturnType<StreamFn>;
};
const wrapped = createXaiToolPayloadCompatibilityWrapper(baseStreamFn);
void wrapped(
{
api: "openai-responses",
provider: "xai",
id: "grok-4-fast",
input: ["text", "image"],
} as Model<"openai-responses">,
{ messages: [] } as Context,
{},
);
runXaiToolPayloadWrapper({ payload, input: ["text", "image"] });
expect(payload.input).toEqual([
{
@@ -386,22 +342,7 @@ describe("xai stream wrappers", () => {
},
],
};
const baseStreamFn: StreamFn = (_model, _context, options) => {
options?.onPayload?.(payload, {} as Model<"openai-responses">);
return {} as ReturnType<StreamFn>;
};
const wrapped = createXaiToolPayloadCompatibilityWrapper(baseStreamFn);
void wrapped(
{
api: "openai-responses",
provider: "xai",
id: "grok-4-fast",
input: ["text"],
} as Model<"openai-responses">,
{ messages: [] } as Context,
{},
);
runXaiToolPayloadWrapper({ payload, input: ["text"] });
expect(payload.input).toEqual([
{
@@ -1,45 +1,20 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildXaiVideoGenerationProvider } from "./video-generation-provider.js";
import { beforeAll, describe, expect, it, vi } from "vitest";
import {
getProviderHttpMocks,
installProviderHttpMockCleanup,
} from "../../test/helpers/media-generation/provider-http-mocks.js";
const {
resolveApiKeyForProviderMock,
postJsonRequestMock,
fetchWithTimeoutMock,
assertOkOrThrowHttpErrorMock,
resolveProviderHttpRequestConfigMock,
} = vi.hoisted(() => ({
resolveApiKeyForProviderMock: vi.fn(async () => ({ apiKey: "xai-key" })),
postJsonRequestMock: vi.fn(),
fetchWithTimeoutMock: vi.fn(),
assertOkOrThrowHttpErrorMock: vi.fn(async () => {}),
resolveProviderHttpRequestConfigMock: vi.fn((params) => ({
baseUrl: params.baseUrl ?? params.defaultBaseUrl,
allowPrivateNetwork: false,
headers: new Headers(params.defaultHeaders),
dispatcherPolicy: undefined,
})),
}));
const { postJsonRequestMock, fetchWithTimeoutMock } = getProviderHttpMocks();
vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
}));
let buildXaiVideoGenerationProvider: typeof import("./video-generation-provider.js").buildXaiVideoGenerationProvider;
vi.mock("openclaw/plugin-sdk/provider-http", () => ({
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
fetchWithTimeout: fetchWithTimeoutMock,
postJsonRequest: postJsonRequestMock,
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
}));
beforeAll(async () => {
({ buildXaiVideoGenerationProvider } = await import("./video-generation-provider.js"));
});
installProviderHttpMockCleanup();
describe("xai video generation provider", () => {
afterEach(() => {
resolveApiKeyForProviderMock.mockClear();
postJsonRequestMock.mockReset();
fetchWithTimeoutMock.mockReset();
assertOkOrThrowHttpErrorMock.mockClear();
resolveProviderHttpRequestConfigMock.mockClear();
});
it("creates, polls, and downloads a generated video", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
+1 -42
View File
@@ -1,52 +1,11 @@
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
import { withEnvAsync } from "openclaw/plugin-sdk/testing";
import { describe, expect, it } from "vitest";
import { createPluginSetupWizardStatus } from "../../../test/helpers/plugins/setup-wizard.js";
import type { OpenClawConfig } from "../runtime-api.js";
import "./zalo-js.test-mocks.js";
import {
listZalouserAccountIds,
resolveDefaultZalouserAccountId,
resolveZalouserAccountSync,
} from "./accounts.js";
import { zalouserSetupAdapter } from "./setup-core.js";
import { zalouserSetupWizard } from "./setup-surface.js";
const zalouserSetupPlugin = {
id: "zalouser",
meta: {
id: "zalouser",
label: "ZaloUser",
selectionLabel: "ZaloUser",
docsPath: "/channels/zalouser",
blurb: "Unofficial Zalo personal account connector.",
},
capabilities: {
chatTypes: ["direct", "group"] as Array<"direct" | "group">,
},
config: {
listAccountIds: (cfg: unknown) => listZalouserAccountIds(cfg as never),
defaultAccountId: (cfg: unknown) => resolveDefaultZalouserAccountId(cfg as never),
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) =>
resolveZalouserAccountSync({ cfg, accountId }),
},
security: {
resolveDmPolicy: createScopedDmSecurityResolver({
channelKey: "zalouser",
resolvePolicy: (account: ReturnType<typeof resolveZalouserAccountSync>) =>
account.config.dmPolicy,
resolveAllowFrom: (account: ReturnType<typeof resolveZalouserAccountSync>) =>
account.config.allowFrom,
policyPathSuffix: "dmPolicy",
normalizeEntry: (raw: string) => raw.trim().replace(/^(zalouser|zlu):/i, ""),
}),
},
setup: zalouserSetupAdapter,
setupWizard: zalouserSetupWizard,
} as const;
import { zalouserSetupPlugin } from "./setup-test-helpers.js";
const zalouserSetupGetStatus = createPluginSetupWizardStatus(zalouserSetupPlugin);
+1 -40
View File
@@ -1,4 +1,3 @@
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
import { describe, expect, it, vi } from "vitest";
import {
createPluginSetupWizardConfigure,
@@ -7,46 +6,8 @@ import {
} from "../../../test/helpers/plugins/setup-wizard.js";
import type { OpenClawConfig } from "../runtime-api.js";
import "./zalo-js.test-mocks.js";
import {
listZalouserAccountIds,
resolveDefaultZalouserAccountId,
resolveZalouserAccountSync,
} from "./accounts.js";
import { zalouserSetupAdapter } from "./setup-core.js";
import { zalouserSetupWizard } from "./setup-surface.js";
const zalouserSetupPlugin = {
id: "zalouser",
meta: {
id: "zalouser",
label: "ZaloUser",
selectionLabel: "ZaloUser",
docsPath: "/channels/zalouser",
blurb: "Unofficial Zalo personal account connector.",
},
capabilities: {
chatTypes: ["direct", "group"] as Array<"direct" | "group">,
},
config: {
listAccountIds: (cfg: unknown) => listZalouserAccountIds(cfg as never),
defaultAccountId: (cfg: unknown) => resolveDefaultZalouserAccountId(cfg as never),
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) =>
resolveZalouserAccountSync({ cfg, accountId }),
},
security: {
resolveDmPolicy: createScopedDmSecurityResolver({
channelKey: "zalouser",
resolvePolicy: (account: ReturnType<typeof resolveZalouserAccountSync>) =>
account.config.dmPolicy,
resolveAllowFrom: (account: ReturnType<typeof resolveZalouserAccountSync>) =>
account.config.allowFrom,
policyPathSuffix: "dmPolicy",
normalizeEntry: (raw: string) => raw.trim().replace(/^(zalouser|zlu):/i, ""),
}),
},
setup: zalouserSetupAdapter,
setupWizard: zalouserSetupWizard,
} as const;
import { zalouserSetupPlugin } from "./setup-test-helpers.js";
const zalouserConfigure = createPluginSetupWizardConfigure(zalouserSetupPlugin);
@@ -0,0 +1,42 @@
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
import type { OpenClawConfig } from "../runtime-api.js";
import {
listZalouserAccountIds,
resolveDefaultZalouserAccountId,
resolveZalouserAccountSync,
} from "./accounts.js";
import { zalouserSetupAdapter } from "./setup-core.js";
import { zalouserSetupWizard } from "./setup-surface.js";
export const zalouserSetupPlugin = {
id: "zalouser",
meta: {
id: "zalouser",
label: "ZaloUser",
selectionLabel: "ZaloUser",
docsPath: "/channels/zalouser",
blurb: "Unofficial Zalo personal account connector.",
},
capabilities: {
chatTypes: ["direct", "group"] as Array<"direct" | "group">,
},
config: {
listAccountIds: (cfg: unknown) => listZalouserAccountIds(cfg as never),
defaultAccountId: (cfg: unknown) => resolveDefaultZalouserAccountId(cfg as never),
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) =>
resolveZalouserAccountSync({ cfg, accountId }),
},
security: {
resolveDmPolicy: createScopedDmSecurityResolver({
channelKey: "zalouser",
resolvePolicy: (account: ReturnType<typeof resolveZalouserAccountSync>) =>
account.config.dmPolicy,
resolveAllowFrom: (account: ReturnType<typeof resolveZalouserAccountSync>) =>
account.config.allowFrom,
policyPathSuffix: "dmPolicy",
normalizeEntry: (raw: string) => raw.trim().replace(/^(zalouser|zlu):/i, ""),
}),
},
setup: zalouserSetupAdapter,
setupWizard: zalouserSetupWizard,
} as const;
+80 -223
View File
@@ -12,10 +12,56 @@ type PendingPromptHarness = {
runId: string;
};
async function createPendingPromptHarness(): Promise<PendingPromptHarness> {
const sessionId = "session-1";
const sessionKey = "agent:main:main";
const DEFAULT_SESSION_ID = "session-1";
const DEFAULT_SESSION_KEY = "agent:main:main";
const DEFAULT_PROMPT_TEXT = "hello";
function createSessionAgentHarness(
request: GatewayClient["request"],
options: { sessionId?: string; sessionKey?: string; cwd?: string } = {},
) {
const sessionId = options.sessionId ?? DEFAULT_SESSION_ID;
const sessionKey = options.sessionKey ?? DEFAULT_SESSION_KEY;
const sessionStore = createInMemorySessionStore();
sessionStore.createSession({
sessionId,
sessionKey,
cwd: options.cwd ?? "/tmp",
});
const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(request), {
sessionStore,
});
return {
agent,
sessionId,
sessionKey,
sessionStore,
};
}
function promptAgent(
agent: AcpGatewayAgent,
sessionId = DEFAULT_SESSION_ID,
text = DEFAULT_PROMPT_TEXT,
) {
return agent.prompt({
sessionId,
prompt: [{ type: "text", text }],
_meta: {},
} as unknown as PromptRequest);
}
function observeSettlement(promise: ReturnType<AcpGatewayAgent["prompt"]>) {
const settleSpy = vi.fn();
void promise.then(
(value) => settleSpy({ kind: "resolve", value }),
(error) => settleSpy({ kind: "reject", error }),
);
return settleSpy;
}
async function createPendingPromptHarness(): Promise<PendingPromptHarness> {
let runId: string | undefined;
const request = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === "chat.send") {
@@ -25,23 +71,8 @@ async function createPendingPromptHarness(): Promise<PendingPromptHarness> {
return {};
}) as GatewayClient["request"];
const sessionStore = createInMemorySessionStore();
sessionStore.createSession({
sessionId,
sessionKey,
cwd: "/tmp",
});
const agent = new AcpGatewayAgent(
createAcpConnection(),
createAcpGateway(request as unknown as GatewayClient["request"]),
{ sessionStore },
);
const promptPromise = agent.prompt({
sessionId,
prompt: [{ type: "text", text: "hello" }],
_meta: {},
} as unknown as PromptRequest);
const { agent, sessionId } = createSessionAgentHarness(request);
const promptPromise = promptAgent(agent, sessionId);
await vi.waitFor(() => {
expect(runId).toBeDefined();
@@ -111,11 +142,7 @@ describe("acp translator stop reason mapping", () => {
it("keeps in-flight prompts pending across transient gateway disconnects", async () => {
const { agent, promptPromise, runId } = await createPendingPromptHarness();
const settleSpy = vi.fn();
void promptPromise.then(
(value) => settleSpy({ kind: "resolve", value }),
(error) => settleSpy({ kind: "reject", error }),
);
const settleSpy = observeSettlement(promptPromise);
agent.handleGatewayDisconnect("1006: connection lost");
await Promise.resolve();
@@ -153,31 +180,15 @@ describe("acp translator stop reason mapping", () => {
it("keeps pre-ack send disconnects inside the reconnect grace window", async () => {
vi.useFakeTimers();
try {
const sessionStore = createInMemorySessionStore();
sessionStore.createSession({
sessionId: "session-1",
sessionKey: "agent:main:main",
cwd: "/tmp",
});
const request = vi.fn(async (method: string) => {
if (method === "chat.send") {
throw new Error("gateway closed (1006): connection lost");
}
return {};
}) as GatewayClient["request"];
const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(request), {
sessionStore,
});
const promptPromise = agent.prompt({
sessionId: "session-1",
prompt: [{ type: "text", text: "hello" }],
_meta: {},
} as unknown as PromptRequest);
const settleSpy = vi.fn();
void promptPromise.then(
(value) => settleSpy({ kind: "resolve", value }),
(error) => settleSpy({ kind: "reject", error }),
);
const { agent, sessionId } = createSessionAgentHarness(request);
const promptPromise = promptAgent(agent, sessionId);
const settleSpy = observeSettlement(promptPromise);
await Promise.resolve();
expect(settleSpy).not.toHaveBeenCalled();
@@ -194,8 +205,6 @@ describe("acp translator stop reason mapping", () => {
});
it("reconciles a missed final event on reconnect via agent.wait", async () => {
const sessionId = "session-1";
const sessionKey = "agent:main:main";
let runId: string | undefined;
const request = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === "chat.send") {
@@ -207,20 +216,8 @@ describe("acp translator stop reason mapping", () => {
}
return {};
}) as GatewayClient["request"];
const sessionStore = createInMemorySessionStore();
sessionStore.createSession({
sessionId,
sessionKey,
cwd: "/tmp",
});
const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(request), {
sessionStore,
});
const promptPromise = agent.prompt({
sessionId,
prompt: [{ type: "text", text: "hello" }],
_meta: {},
} as unknown as PromptRequest);
const { agent, sessionId } = createSessionAgentHarness(request);
const promptPromise = promptAgent(agent, sessionId);
await vi.waitFor(() => {
expect(runId).toBeDefined();
@@ -243,8 +240,6 @@ describe("acp translator stop reason mapping", () => {
it("rechecks accepted prompts at the disconnect deadline after reconnect timeout", async () => {
vi.useFakeTimers();
try {
const sessionId = "session-1";
const sessionKey = "agent:main:main";
let waitCount = 0;
const request = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === "chat.send") {
@@ -260,25 +255,9 @@ describe("acp translator stop reason mapping", () => {
}
return {};
}) as GatewayClient["request"];
const sessionStore = createInMemorySessionStore();
sessionStore.createSession({
sessionId,
sessionKey,
cwd: "/tmp",
});
const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(request), {
sessionStore,
});
const promptPromise = agent.prompt({
sessionId,
prompt: [{ type: "text", text: "hello" }],
_meta: {},
} as unknown as PromptRequest);
const settleSpy = vi.fn();
void promptPromise.then(
(value) => settleSpy({ kind: "resolve", value }),
(error) => settleSpy({ kind: "reject", error }),
);
const { agent, sessionId } = createSessionAgentHarness(request);
const promptPromise = promptAgent(agent, sessionId);
const settleSpy = observeSettlement(promptPromise);
await Promise.resolve();
agent.handleGatewayDisconnect("1006: connection lost");
@@ -298,8 +277,6 @@ describe("acp translator stop reason mapping", () => {
it("keeps accepted prompts pending when the deadline recheck still reports timeout", async () => {
vi.useFakeTimers();
try {
const sessionId = "session-1";
const sessionKey = "agent:main:main";
const request = vi.fn(async (method: string) => {
if (method === "chat.send") {
return {};
@@ -309,20 +286,8 @@ describe("acp translator stop reason mapping", () => {
}
return {};
}) as GatewayClient["request"];
const sessionStore = createInMemorySessionStore();
sessionStore.createSession({
sessionId,
sessionKey,
cwd: "/tmp",
});
const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(request), {
sessionStore,
});
const promptPromise = agent.prompt({
sessionId,
prompt: [{ type: "text", text: "hello" }],
_meta: {},
} as unknown as PromptRequest);
const { agent, sessionId } = createSessionAgentHarness(request);
const promptPromise = promptAgent(agent, sessionId);
await Promise.resolve();
agent.handleGatewayDisconnect("1006: connection lost");
@@ -341,8 +306,6 @@ describe("acp translator stop reason mapping", () => {
it("does not clear a newer disconnect deadline while reconnect reconciliation is still running", async () => {
vi.useFakeTimers();
try {
const sessionId = "session-1";
const sessionKey = "agent:main:main";
let resolveAgentWait: ((value: { status: "timeout" }) => void) | undefined;
let agentWaitCount = 0;
const request = vi.fn(async (method: string) => {
@@ -360,25 +323,9 @@ describe("acp translator stop reason mapping", () => {
}
return {};
}) as GatewayClient["request"];
const sessionStore = createInMemorySessionStore();
sessionStore.createSession({
sessionId,
sessionKey,
cwd: "/tmp",
});
const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(request), {
sessionStore,
});
const promptPromise = agent.prompt({
sessionId,
prompt: [{ type: "text", text: "hello" }],
_meta: {},
} as unknown as PromptRequest);
const settleSpy = vi.fn();
void promptPromise.then(
(value) => settleSpy({ kind: "resolve", value }),
(error) => settleSpy({ kind: "reject", error }),
);
const { agent, sessionId } = createSessionAgentHarness(request);
const promptPromise = promptAgent(agent, sessionId);
const settleSpy = observeSettlement(promptPromise);
await Promise.resolve();
agent.handleGatewayDisconnect("1006: first disconnect");
@@ -405,8 +352,6 @@ describe("acp translator stop reason mapping", () => {
it("rejects pre-ack prompts when reconnect timeout still finds no run", async () => {
vi.useFakeTimers();
try {
const sessionId = "session-1";
const sessionKey = "agent:main:main";
const request = vi.fn(async (method: string) => {
if (method === "chat.send") {
throw new Error("gateway closed (1006): connection lost");
@@ -416,20 +361,8 @@ describe("acp translator stop reason mapping", () => {
}
return {};
}) as GatewayClient["request"];
const sessionStore = createInMemorySessionStore();
sessionStore.createSession({
sessionId,
sessionKey,
cwd: "/tmp",
});
const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(request), {
sessionStore,
});
const promptPromise = agent.prompt({
sessionId,
prompt: [{ type: "text", text: "hello" }],
_meta: {},
} as unknown as PromptRequest);
const { agent, sessionId } = createSessionAgentHarness(request);
const promptPromise = promptAgent(agent, sessionId);
void promptPromise.catch(() => {});
await Promise.resolve();
@@ -449,8 +382,6 @@ describe("acp translator stop reason mapping", () => {
});
it("rejects a superseded pre-ack prompt when a newer prompt has replaced the session entry", async () => {
const sessionId = "session-1";
const sessionKey = "agent:main:main";
let promptCount = 0;
const request = vi.fn(async (method: string) => {
if (method !== "chat.send") {
@@ -462,28 +393,12 @@ describe("acp translator stop reason mapping", () => {
}
return {};
}) as GatewayClient["request"];
const sessionStore = createInMemorySessionStore();
sessionStore.createSession({
sessionId,
sessionKey,
cwd: "/tmp",
});
const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(request), {
sessionStore,
});
const { agent, sessionId } = createSessionAgentHarness(request);
const firstPrompt = agent.prompt({
sessionId,
prompt: [{ type: "text", text: "first" }],
_meta: {},
} as unknown as PromptRequest);
const firstPrompt = promptAgent(agent, sessionId, "first");
await Promise.resolve();
const secondPrompt = agent.prompt({
sessionId,
prompt: [{ type: "text", text: "second" }],
_meta: {},
} as unknown as PromptRequest);
const secondPrompt = promptAgent(agent, sessionId, "second");
await expect(firstPrompt).rejects.toThrow("gateway closed (1006): connection lost");
await expect(Promise.race([secondPrompt, Promise.resolve("pending")])).resolves.toBe("pending");
@@ -492,8 +407,6 @@ describe("acp translator stop reason mapping", () => {
it("rejects stale pre-ack prompts when a superseded send resolves late", async () => {
vi.useFakeTimers();
try {
const sessionId = "session-1";
const sessionKey = "agent:main:main";
let firstSendResolve: (() => void) | undefined;
let sendCount = 0;
const request = vi.fn(async (method: string) => {
@@ -511,30 +424,14 @@ describe("acp translator stop reason mapping", () => {
}
return {};
}) as GatewayClient["request"];
const sessionStore = createInMemorySessionStore();
sessionStore.createSession({
sessionId,
sessionKey,
cwd: "/tmp",
});
const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(request), {
sessionStore,
});
const { agent, sessionId } = createSessionAgentHarness(request);
const firstPrompt = agent.prompt({
sessionId,
prompt: [{ type: "text", text: "first" }],
_meta: {},
} as unknown as PromptRequest);
const firstPrompt = promptAgent(agent, sessionId, "first");
void firstPrompt.catch(() => {});
await Promise.resolve();
expect(firstSendResolve).toBeDefined();
const secondPrompt = agent.prompt({
sessionId,
prompt: [{ type: "text", text: "second" }],
_meta: {},
} as unknown as PromptRequest);
const secondPrompt = promptAgent(agent, sessionId, "second");
void secondPrompt.catch(() => {});
await Promise.resolve();
expect(sendCount).toBe(2);
@@ -598,11 +495,7 @@ describe("acp translator stop reason mapping", () => {
prompt: [{ type: "text", text: "pre-ack" }],
_meta: {},
} as unknown as PromptRequest);
const acceptedSettleSpy = vi.fn();
void acceptedPrompt.then(
(value) => acceptedSettleSpy({ kind: "resolve", value }),
(error) => acceptedSettleSpy({ kind: "reject", error }),
);
observeSettlement(acceptedPrompt);
void preAckPrompt.catch(() => {});
await Promise.resolve();
@@ -624,8 +517,6 @@ describe("acp translator stop reason mapping", () => {
});
it("reconciles prompts started while the gateway is disconnected", async () => {
const sessionId = "session-1";
const sessionKey = "agent:main:main";
const request = vi.fn(async (method: string) => {
if (method === "chat.send") {
throw new Error("gateway closed (1006): connection lost");
@@ -635,27 +526,11 @@ describe("acp translator stop reason mapping", () => {
}
return {};
}) as GatewayClient["request"];
const sessionStore = createInMemorySessionStore();
sessionStore.createSession({
sessionId,
sessionKey,
cwd: "/tmp",
});
const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(request), {
sessionStore,
});
const { agent, sessionId } = createSessionAgentHarness(request);
agent.handleGatewayDisconnect("1006: connection lost");
const promptPromise = agent.prompt({
sessionId,
prompt: [{ type: "text", text: "hello" }],
_meta: {},
} as unknown as PromptRequest);
const settleSpy = vi.fn();
void promptPromise.then(
(value) => settleSpy({ kind: "resolve", value }),
(error) => settleSpy({ kind: "reject", error }),
);
const promptPromise = promptAgent(agent, sessionId);
const settleSpy = observeSettlement(promptPromise);
await Promise.resolve();
agent.handleGatewayReconnect();
@@ -670,8 +545,6 @@ describe("acp translator stop reason mapping", () => {
it("does not let a stale disconnect deadline reject a newer prompt on the same session", async () => {
vi.useFakeTimers();
try {
const sessionId = "session-1";
const sessionKey = "agent:main:main";
let sendCount = 0;
const requestMock = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === "chat.send") {
@@ -687,21 +560,9 @@ describe("acp translator stop reason mapping", () => {
return {};
});
const request = requestMock as GatewayClient["request"];
const sessionStore = createInMemorySessionStore();
sessionStore.createSession({
sessionId,
sessionKey,
cwd: "/tmp",
});
const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(request), {
sessionStore,
});
const { agent, sessionId } = createSessionAgentHarness(request);
const firstPrompt = agent.prompt({
sessionId,
prompt: [{ type: "text", text: "first" }],
_meta: {},
} as unknown as PromptRequest);
const firstPrompt = promptAgent(agent, sessionId, "first");
void firstPrompt.catch(() => {});
await Promise.resolve();
const firstRunId = requestMock.mock.calls[0]?.[1]?.idempotencyKey as string;
@@ -710,11 +571,7 @@ describe("acp translator stop reason mapping", () => {
agent.handleGatewayReconnect();
await Promise.resolve();
const secondPrompt = agent.prompt({
sessionId,
prompt: [{ type: "text", text: "second" }],
_meta: {},
} as unknown as PromptRequest);
const secondPrompt = promptAgent(agent, sessionId, "second");
await vi.advanceTimersByTimeAsync(5_000);
await expect(Promise.race([secondPrompt, Promise.resolve("pending")])).resolves.toBe(
@@ -0,0 +1,98 @@
import fs from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
const require = createRequire(import.meta.url);
const SDK_SERVER_MCP_PATH = require.resolve("@modelcontextprotocol/sdk/server/mcp.js");
const SDK_SERVER_STDIO_PATH = require.resolve("@modelcontextprotocol/sdk/server/stdio.js");
export async function writeExecutable(filePath: string, content: string): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content, { encoding: "utf-8", mode: 0o755 });
}
export async function writeBundleProbeMcpServer(
filePath: string,
params: {
startupCounterPath?: string;
startupDelayMs?: number;
pidPath?: string;
exitMarkerPath?: string;
} = {},
): Promise<void> {
await writeExecutable(
filePath,
`#!/usr/bin/env node
import fs from "node:fs";
import fsp from "node:fs/promises";
import { setTimeout as delay } from "node:timers/promises";
import { McpServer } from ${JSON.stringify(SDK_SERVER_MCP_PATH)};
import { StdioServerTransport } from ${JSON.stringify(SDK_SERVER_STDIO_PATH)};
const startupCounterPath = ${JSON.stringify(params.startupCounterPath ?? "")};
if (startupCounterPath) {
let current = 0;
try {
current = Number.parseInt((await fsp.readFile(startupCounterPath, "utf8")).trim(), 10) || 0;
} catch {}
await fsp.writeFile(startupCounterPath, String(current + 1), "utf8");
}
const pidPath = ${JSON.stringify(params.pidPath ?? "")};
if (pidPath) {
await fsp.writeFile(pidPath, String(process.pid), "utf8");
}
const exitMarkerPath = ${JSON.stringify(params.exitMarkerPath ?? "")};
if (exitMarkerPath) {
process.once("exit", () => {
try {
fs.writeFileSync(exitMarkerPath, "exited", "utf8");
} catch {}
});
}
const startupDelayMs = ${JSON.stringify(params.startupDelayMs ?? 0)};
if (startupDelayMs > 0) {
await delay(startupDelayMs);
}
const server = new McpServer({ name: "bundle-probe", version: "1.0.0" });
server.tool("bundle_probe", "Bundle MCP probe", async () => {
return {
content: [{ type: "text", text: process.env.BUNDLE_PROBE_TEXT ?? "missing-probe-text" }],
};
});
await server.connect(new StdioServerTransport());
`,
);
}
export async function writeClaudeBundle(params: {
pluginRoot: string;
serverScriptPath: string;
}): Promise<void> {
await fs.mkdir(path.join(params.pluginRoot, ".claude-plugin"), { recursive: true });
await fs.writeFile(
path.join(params.pluginRoot, ".claude-plugin", "plugin.json"),
`${JSON.stringify({ name: "bundle-probe" }, null, 2)}\n`,
"utf-8",
);
await fs.writeFile(
path.join(params.pluginRoot, ".mcp.json"),
`${JSON.stringify(
{
mcpServers: {
bundleProbe: {
command: "node",
args: [path.relative(params.pluginRoot, params.serverScriptPath)],
env: {
BUNDLE_PROBE_TEXT: "FROM-BUNDLE",
},
},
},
},
null,
2,
)}\n`,
"utf-8",
);
}
+6 -58
View File
@@ -1,67 +1,15 @@
import fs from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
import {
writeBundleProbeMcpServer,
writeClaudeBundle,
writeExecutable,
} from "./bundle-mcp-shared.test-harness.js";
const require = createRequire(import.meta.url);
const SDK_SERVER_MCP_PATH = require.resolve("@modelcontextprotocol/sdk/server/mcp.js");
const SDK_SERVER_STDIO_PATH = require.resolve("@modelcontextprotocol/sdk/server/stdio.js");
const SDK_CLIENT_INDEX_PATH = require.resolve("@modelcontextprotocol/sdk/client/index.js");
const SDK_CLIENT_STDIO_PATH = require.resolve("@modelcontextprotocol/sdk/client/stdio.js");
export async function writeExecutable(filePath: string, content: string): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content, { encoding: "utf-8", mode: 0o755 });
}
export async function writeBundleProbeMcpServer(filePath: string): Promise<void> {
await writeExecutable(
filePath,
`#!/usr/bin/env node
import { McpServer } from ${JSON.stringify(SDK_SERVER_MCP_PATH)};
import { StdioServerTransport } from ${JSON.stringify(SDK_SERVER_STDIO_PATH)};
const server = new McpServer({ name: "bundle-probe", version: "1.0.0" });
server.tool("bundle_probe", "Bundle MCP probe", async () => {
return {
content: [{ type: "text", text: process.env.BUNDLE_PROBE_TEXT ?? "missing-probe-text" }],
};
});
await server.connect(new StdioServerTransport());
`,
);
}
export async function writeClaudeBundle(params: {
pluginRoot: string;
serverScriptPath: string;
}): Promise<void> {
await fs.mkdir(path.join(params.pluginRoot, ".claude-plugin"), { recursive: true });
await fs.writeFile(
path.join(params.pluginRoot, ".claude-plugin", "plugin.json"),
`${JSON.stringify({ name: "bundle-probe" }, null, 2)}\n`,
"utf-8",
);
await fs.writeFile(
path.join(params.pluginRoot, ".mcp.json"),
`${JSON.stringify(
{
mcpServers: {
bundleProbe: {
command: "node",
args: [path.relative(params.pluginRoot, params.serverScriptPath)],
env: {
BUNDLE_PROBE_TEXT: "FROM-BUNDLE",
},
},
},
},
null,
2,
)}\n`,
"utf-8",
);
}
export { writeBundleProbeMcpServer, writeClaudeBundle, writeExecutable };
export async function writeFakeClaudeCli(filePath: string): Promise<void> {
await writeExecutable(
+1 -24
View File
@@ -225,30 +225,7 @@ function mergeConfig(
tokens: overrides?.chunking?.tokens ?? defaults?.chunking?.tokens ?? DEFAULT_CHUNK_TOKENS,
overlap: overrides?.chunking?.overlap ?? defaults?.chunking?.overlap ?? DEFAULT_CHUNK_OVERLAP,
};
const sync = {
onSessionStart: overrides?.sync?.onSessionStart ?? defaults?.sync?.onSessionStart ?? true,
onSearch: overrides?.sync?.onSearch ?? defaults?.sync?.onSearch ?? true,
watch: overrides?.sync?.watch ?? defaults?.sync?.watch ?? true,
watchDebounceMs:
overrides?.sync?.watchDebounceMs ??
defaults?.sync?.watchDebounceMs ??
DEFAULT_WATCH_DEBOUNCE_MS,
intervalMinutes: overrides?.sync?.intervalMinutes ?? defaults?.sync?.intervalMinutes ?? 0,
sessions: {
deltaBytes:
overrides?.sync?.sessions?.deltaBytes ??
defaults?.sync?.sessions?.deltaBytes ??
DEFAULT_SESSION_DELTA_BYTES,
deltaMessages:
overrides?.sync?.sessions?.deltaMessages ??
defaults?.sync?.sessions?.deltaMessages ??
DEFAULT_SESSION_DELTA_MESSAGES,
postCompactionForce:
overrides?.sync?.sessions?.postCompactionForce ??
defaults?.sync?.sessions?.postCompactionForce ??
true,
},
};
const sync = resolveSyncConfig(defaults, overrides);
const query = {
maxResults: overrides?.query?.maxResults ?? defaults?.query?.maxResults ?? DEFAULT_MAX_RESULTS,
minScore: overrides?.query?.minScore ?? defaults?.query?.minScore ?? DEFAULT_MIN_SCORE,
+2 -9
View File
@@ -2,6 +2,7 @@ import { type Api, completeSimple, type Model } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import { describe, expect, it } from "vitest";
import { loadConfig } from "../config/config.js";
import { parseLiveCsvFilter } from "../media-generation/live-test-helpers.js";
import { resolveOpenClawAgentDir } from "./agent-paths.js";
import {
collectAnthropicApiKeys,
@@ -30,15 +31,7 @@ const LIVE_SETUP_TIMEOUT_MS = Math.max(
const describeLive = LIVE ? describe : describe.skip;
function parseCsvFilter(raw?: string): Set<string> | null {
const trimmed = raw?.trim();
if (!trimmed || trimmed === "all") {
return null;
}
const ids = trimmed
.split(",")
.map((s) => s.trim())
.filter(Boolean);
return ids.length ? new Set(ids) : null;
return parseLiveCsvFilter(raw, { lowercase: false });
}
function parseProviderFilter(raw?: string): Set<string> | null {
+6 -91
View File
@@ -3,12 +3,16 @@ import http from "node:http";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import {
writeBundleProbeMcpServer,
writeClaudeBundle,
writeExecutable,
} from "./bundle-mcp-shared.test-harness.js";
import { __testing } from "./pi-bundle-mcp-tools.js";
const require = createRequire(import.meta.url);
const SDK_SERVER_MCP_PATH = require.resolve("@modelcontextprotocol/sdk/server/mcp.js");
const SDK_SERVER_SSE_PATH = require.resolve("@modelcontextprotocol/sdk/server/sse.js");
const SDK_SERVER_STDIO_PATH = require.resolve("@modelcontextprotocol/sdk/server/stdio.js");
const tempDirs: string[] = [];
@@ -25,65 +29,7 @@ export async function makeTempDir(prefix: string): Promise<string> {
return dir;
}
export async function writeExecutable(filePath: string, content: string): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content, { encoding: "utf-8", mode: 0o755 });
}
export async function writeBundleProbeMcpServer(
filePath: string,
params: {
startupCounterPath?: string;
startupDelayMs?: number;
pidPath?: string;
exitMarkerPath?: string;
} = {},
): Promise<void> {
await writeExecutable(
filePath,
`#!/usr/bin/env node
import fs from "node:fs";
import fsp from "node:fs/promises";
import { setTimeout as delay } from "node:timers/promises";
import { McpServer } from ${JSON.stringify(SDK_SERVER_MCP_PATH)};
import { StdioServerTransport } from ${JSON.stringify(SDK_SERVER_STDIO_PATH)};
const startupCounterPath = ${JSON.stringify(params.startupCounterPath ?? "")};
if (startupCounterPath) {
let current = 0;
try {
current = Number.parseInt((await fsp.readFile(startupCounterPath, "utf8")).trim(), 10) || 0;
} catch {}
await fsp.writeFile(startupCounterPath, String(current + 1), "utf8");
}
const pidPath = ${JSON.stringify(params.pidPath ?? "")};
if (pidPath) {
await fsp.writeFile(pidPath, String(process.pid), "utf8");
}
const exitMarkerPath = ${JSON.stringify(params.exitMarkerPath ?? "")};
if (exitMarkerPath) {
process.once("exit", () => {
try {
fs.writeFileSync(exitMarkerPath, "exited", "utf8");
} catch {}
});
}
const startupDelayMs = ${JSON.stringify(params.startupDelayMs ?? 0)};
if (startupDelayMs > 0) {
await delay(startupDelayMs);
}
const server = new McpServer({ name: "bundle-probe", version: "1.0.0" });
server.tool("bundle_probe", "Bundle MCP probe", async () => {
return {
content: [{ type: "text", text: process.env.BUNDLE_PROBE_TEXT ?? "missing-probe-text" }],
};
});
await server.connect(new StdioServerTransport());
`,
);
}
export { writeBundleProbeMcpServer, writeClaudeBundle, writeExecutable };
export async function waitForFileText(filePath: string, timeoutMs = 5_000): Promise<string> {
const start = Date.now();
@@ -97,37 +43,6 @@ export async function waitForFileText(filePath: string, timeoutMs = 5_000): Prom
throw new Error(`Timed out waiting for ${filePath}`);
}
export async function writeClaudeBundle(params: {
pluginRoot: string;
serverScriptPath: string;
}): Promise<void> {
await fs.mkdir(path.join(params.pluginRoot, ".claude-plugin"), { recursive: true });
await fs.writeFile(
path.join(params.pluginRoot, ".claude-plugin", "plugin.json"),
`${JSON.stringify({ name: "bundle-probe" }, null, 2)}\n`,
"utf-8",
);
await fs.writeFile(
path.join(params.pluginRoot, ".mcp.json"),
`${JSON.stringify(
{
mcpServers: {
bundleProbe: {
command: "node",
args: [path.relative(params.pluginRoot, params.serverScriptPath)],
env: {
BUNDLE_PROBE_TEXT: "FROM-BUNDLE",
},
},
},
},
null,
2,
)}\n`,
"utf-8",
);
}
export async function startSseProbeServer(
probeText = "FROM-SSE",
): Promise<{ port: number; close: () => Promise<void> }> {
@@ -51,50 +51,76 @@ function makeGoogleModel(id = "gemini-3.1-pro-preview") {
} satisfies Model<"google-generative-ai">;
}
function createCacheFetchMock(params: { name: string; expireTime: string }) {
return vi.fn().mockResolvedValue(
new Response(JSON.stringify(params), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
}
function createCapturingStreamFn(result = "stream") {
let capturedPayload: Record<string, unknown> | undefined;
const streamFn = vi.fn(
(
model: Parameters<StreamFn>[0],
_context: Parameters<StreamFn>[1],
options: Parameters<StreamFn>[2],
) => {
const payload: Record<string, unknown> = {};
void options?.onPayload?.(payload, model);
capturedPayload = payload;
return result as never;
},
);
return {
streamFn,
getCapturedPayload: () => capturedPayload,
};
}
function preparePromptCacheStream(params: {
fetchMock: ReturnType<typeof vi.fn>;
now: number;
sessionManager: ReturnType<typeof makeSessionManager>;
streamFn: StreamFn;
}) {
return prepareGooglePromptCacheStreamFn(
{
apiKey: "gemini-api-key",
extraParams: { cacheRetention: "long" },
model: makeGoogleModel(),
modelId: "gemini-3.1-pro-preview",
provider: "google",
sessionManager: params.sessionManager,
streamFn: params.streamFn,
systemPrompt: "Follow policy.",
},
{
buildGuardedFetch: () => params.fetchMock as typeof fetch,
now: () => params.now,
},
);
}
describe("google prompt cache", () => {
it("creates cached content from the system prompt and strips that prompt from live requests", async () => {
const now = 1_000_000;
const entries: SessionCustomEntry[] = [];
const sessionManager = makeSessionManager(entries);
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
name: "cachedContents/system-cache-1",
expireTime: new Date(now + 3_600_000).toISOString(),
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
let capturedPayload: Record<string, unknown> | undefined;
const innerStreamFn = vi.fn(
(
model: Parameters<StreamFn>[0],
_context: Parameters<StreamFn>[1],
options: Parameters<StreamFn>[2],
) => {
const payload: Record<string, unknown> = {};
void options?.onPayload?.(payload, model);
capturedPayload = payload;
return "stream" as never;
},
);
const fetchMock = createCacheFetchMock({
name: "cachedContents/system-cache-1",
expireTime: new Date(now + 3_600_000).toISOString(),
});
const { streamFn: innerStreamFn, getCapturedPayload } = createCapturingStreamFn();
const wrapped = await prepareGooglePromptCacheStreamFn(
{
apiKey: "gemini-api-key",
extraParams: { cacheRetention: "long" },
model: makeGoogleModel(),
modelId: "gemini-3.1-pro-preview",
provider: "google",
sessionManager,
streamFn: innerStreamFn,
systemPrompt: "Follow policy.",
},
{
buildGuardedFetch: () => fetchMock as typeof fetch,
now: () => now,
},
);
const wrapped = await preparePromptCacheStream({
fetchMock,
now,
sessionManager,
streamFn: innerStreamFn,
});
expect(wrapped).toBeTypeOf("function");
void wrapped?.(
@@ -143,7 +169,7 @@ describe("google prompt cache", () => {
}),
expect.objectContaining({ temperature: 0.2 }),
);
expect(capturedPayload).toMatchObject({
expect(getCapturedPayload()).toMatchObject({
cachedContent: "cachedContents/system-cache-1",
});
expect(entries).toHaveLength(1);
@@ -155,63 +181,26 @@ describe("google prompt cache", () => {
const now = 2_000_000;
const entries: SessionCustomEntry[] = [];
const sessionManager = makeSessionManager(entries);
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
name: "cachedContents/system-cache-2",
expireTime: new Date(now + 3_600_000).toISOString(),
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const fetchMock = createCacheFetchMock({
name: "cachedContents/system-cache-2",
expireTime: new Date(now + 3_600_000).toISOString(),
});
await prepareGooglePromptCacheStreamFn(
{
apiKey: "gemini-api-key",
extraParams: { cacheRetention: "long" },
model: makeGoogleModel(),
modelId: "gemini-3.1-pro-preview",
provider: "google",
sessionManager,
streamFn: vi.fn(() => "first" as never),
systemPrompt: "Follow policy.",
},
{
buildGuardedFetch: () => fetchMock as typeof fetch,
now: () => now,
},
);
await preparePromptCacheStream({
fetchMock,
now,
sessionManager,
streamFn: vi.fn(() => "first" as never),
});
fetchMock.mockClear();
let capturedPayload: Record<string, unknown> | undefined;
const innerStreamFn = vi.fn(
(
model: Parameters<StreamFn>[0],
_context: Parameters<StreamFn>[1],
options: Parameters<StreamFn>[2],
) => {
const payload: Record<string, unknown> = {};
void options?.onPayload?.(payload, model);
capturedPayload = payload;
return "second" as never;
},
);
const wrapped = await prepareGooglePromptCacheStreamFn(
{
apiKey: "gemini-api-key",
extraParams: { cacheRetention: "long" },
model: makeGoogleModel(),
modelId: "gemini-3.1-pro-preview",
provider: "google",
sessionManager,
streamFn: innerStreamFn,
systemPrompt: "Follow policy.",
},
{
buildGuardedFetch: () => fetchMock as typeof fetch,
now: () => now + 30_000,
},
);
const { streamFn: innerStreamFn, getCapturedPayload } = createCapturingStreamFn("second");
const wrapped = await preparePromptCacheStream({
fetchMock,
now: now + 30_000,
sessionManager,
streamFn: innerStreamFn,
});
void wrapped?.(
makeGoogleModel(),
@@ -225,7 +214,7 @@ describe("google prompt cache", () => {
expect.objectContaining({ systemPrompt: undefined }),
expect.any(Object),
);
expect(capturedPayload).toMatchObject({
expect(getCapturedPayload()).toMatchObject({
cachedContent: "cachedContents/system-cache-2",
});
});
@@ -255,45 +244,18 @@ describe("google prompt cache", () => {
},
},
]);
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
name: "cachedContents/system-cache-3",
expireTime: new Date(now + 3_600_000).toISOString(),
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
let capturedPayload: Record<string, unknown> | undefined;
const innerStreamFn = vi.fn(
(
model: Parameters<StreamFn>[0],
_context: Parameters<StreamFn>[1],
options: Parameters<StreamFn>[2],
) => {
const payload: Record<string, unknown> = {};
void options?.onPayload?.(payload, model);
capturedPayload = payload;
return "stream" as never;
},
);
const fetchMock = createCacheFetchMock({
name: "cachedContents/system-cache-3",
expireTime: new Date(now + 3_600_000).toISOString(),
});
const { streamFn: innerStreamFn, getCapturedPayload } = createCapturingStreamFn();
const wrapped = await prepareGooglePromptCacheStreamFn(
{
apiKey: "gemini-api-key",
extraParams: { cacheRetention: "long" },
model: makeGoogleModel(),
modelId: "gemini-3.1-pro-preview",
provider: "google",
sessionManager,
streamFn: innerStreamFn,
systemPrompt: "Follow policy.",
},
{
buildGuardedFetch: () => fetchMock as typeof fetch,
now: () => now,
},
);
const wrapped = await preparePromptCacheStream({
fetchMock,
now,
sessionManager,
streamFn: innerStreamFn,
});
void wrapped?.(
makeGoogleModel(),
@@ -311,7 +273,7 @@ describe("google prompt cache", () => {
expect.objectContaining({ systemPrompt: undefined }),
expect.any(Object),
);
expect(capturedPayload).toMatchObject({
expect(getCapturedPayload()).toMatchObject({
cachedContent: "cachedContents/system-cache-3",
});
});
@@ -10,6 +10,109 @@ import {
resolveSilentReplyFallbackText,
} from "./pi-embedded-subscribe.handlers.messages.js";
import type { EmbeddedPiSubscribeContext } from "./pi-embedded-subscribe.handlers.types.js";
import {
createOpenAiResponsesPartial,
createOpenAiResponsesTextBlock,
createOpenAiResponsesTextEvent as createTextUpdateEvent,
} from "./pi-embedded-subscribe.openai-responses.test-helpers.js";
function createMessageUpdateContext(
params: {
onAgentEvent?: ReturnType<typeof vi.fn>;
onPartialReply?: ReturnType<typeof vi.fn>;
flushBlockReplyBuffer?: ReturnType<typeof vi.fn>;
debug?: ReturnType<typeof vi.fn>;
shouldEmitPartialReplies?: boolean;
} = {},
) {
return {
params: {
runId: "run-1",
session: { id: "session-1" },
...(params.onAgentEvent ? { onAgentEvent: params.onAgentEvent } : {}),
...(params.onPartialReply ? { onPartialReply: params.onPartialReply } : {}),
},
state: {
deterministicApprovalPromptPending: false,
deterministicApprovalPromptSent: false,
reasoningStreamOpen: false,
streamReasoning: false,
deltaBuffer: "",
blockBuffer: "",
partialBlockState: {
thinking: false,
final: false,
inlineCode: createInlineCodeState(),
},
lastStreamedAssistant: undefined,
lastStreamedAssistantCleaned: undefined,
emittedAssistantUpdate: false,
shouldEmitPartialReplies: params.shouldEmitPartialReplies ?? true,
blockReplyBreak: "text_end",
assistantMessageIndex: 0,
},
log: { debug: params.debug ?? vi.fn() },
noteLastAssistant: vi.fn(),
stripBlockTags: (text: string) => text,
consumePartialReplyDirectives: vi.fn(() => null),
emitReasoningStream: vi.fn(),
flushBlockReplyBuffer: params.flushBlockReplyBuffer ?? vi.fn(),
} as unknown as EmbeddedPiSubscribeContext;
}
function createMessageEndContext(
params: {
onAgentEvent?: ReturnType<typeof vi.fn>;
onBlockReply?: ReturnType<typeof vi.fn>;
emitBlockReply?: ReturnType<typeof vi.fn>;
finalizeAssistantTexts?: ReturnType<typeof vi.fn>;
consumeReplyDirectives?: ReturnType<typeof vi.fn>;
state?: Record<string, unknown>;
} = {},
) {
return {
params: {
runId: "run-1",
session: { id: "session-1" },
...(params.onAgentEvent ? { onAgentEvent: params.onAgentEvent } : {}),
...(params.onBlockReply ? { onBlockReply: params.onBlockReply } : { onBlockReply: vi.fn() }),
},
state: {
assistantTexts: [],
assistantTextBaseline: 0,
emittedAssistantUpdate: false,
deterministicApprovalPromptPending: false,
deterministicApprovalPromptSent: false,
messagingToolSentTexts: [],
messagingToolSentTextsNormalized: [],
includeReasoning: false,
streamReasoning: false,
blockReplyBreak: "message_end",
deltaBuffer: "Need send.",
blockBuffer: "Need send.",
blockState: {
thinking: false,
final: false,
inlineCode: createInlineCodeState(),
},
lastStreamedAssistant: undefined,
lastStreamedAssistantCleaned: undefined,
lastReasoningSent: undefined,
reasoningStreamOpen: false,
...params.state,
},
noteLastAssistant: vi.fn(),
recordAssistantUsage: vi.fn(),
log: { debug: vi.fn(), warn: vi.fn() },
stripBlockTags: (text: string) => text,
finalizeAssistantTexts: params.finalizeAssistantTexts ?? vi.fn(),
emitBlockReply: params.emitBlockReply ?? vi.fn(),
consumeReplyDirectives: params.consumeReplyDirectives ?? vi.fn(() => ({ text: "Need send." })),
emitReasoningStream: vi.fn(),
flushBlockReplyBuffer: vi.fn(),
blockChunker: null,
} as unknown as EmbeddedPiSubscribeContext;
}
describe("resolveSilentReplyFallbackText", () => {
it("replaces NO_REPLY with latest messaging tool text when available", () => {
@@ -145,48 +248,20 @@ describe("handleMessageUpdate", () => {
const onAgentEvent = vi.fn();
const onPartialReply = vi.fn();
const flushBlockReplyBuffer = vi.fn();
const ctx = {
params: {
runId: "run-1",
session: { id: "session-1" },
onAgentEvent,
onPartialReply,
},
state: {
deterministicApprovalPromptPending: false,
deterministicApprovalPromptSent: false,
reasoningStreamOpen: false,
streamReasoning: false,
deltaBuffer: "",
blockBuffer: "",
partialBlockState: {
thinking: false,
final: false,
inlineCode: createInlineCodeState(),
},
lastStreamedAssistantCleaned: undefined,
emittedAssistantUpdate: false,
shouldEmitPartialReplies: true,
blockReplyBreak: "text_end",
assistantMessageIndex: 0,
},
log: { debug: vi.fn() },
noteLastAssistant: vi.fn(),
stripBlockTags: (text: string) => text,
consumePartialReplyDirectives: vi.fn(() => null),
const ctx = createMessageUpdateContext({
onAgentEvent,
onPartialReply,
flushBlockReplyBuffer,
} as unknown as EmbeddedPiSubscribeContext;
});
handleMessageUpdate(ctx, {
type: "message_update",
message: { role: "assistant", phase: "commentary", content: [] },
assistantMessageEvent: { type: "text_delta", delta: "Need send." },
} as never);
handleMessageUpdate(ctx, {
type: "message_update",
message: { role: "assistant", phase: "commentary", content: [] },
assistantMessageEvent: { type: "text_end", content: "Need send." },
} as never);
handleMessageUpdate(
ctx,
createTextUpdateEvent({ type: "text_delta", text: "Need send.", messagePhase: "commentary" }),
);
handleMessageUpdate(
ctx,
createTextUpdateEvent({ type: "text_end", text: "Need send.", messagePhase: "commentary" }),
);
await Promise.resolve();
@@ -199,53 +274,33 @@ describe("handleMessageUpdate", () => {
const onAgentEvent = vi.fn();
const onPartialReply = vi.fn();
const flushBlockReplyBuffer = vi.fn();
const commentaryBlock = {
type: "text",
const commentaryBlock = createOpenAiResponsesTextBlock({
text: "Need send.",
textSignature: JSON.stringify({ v: 1, id: "msg_sig", phase: "commentary" }),
};
const ctx = {
params: {
runId: "run-1",
session: { id: "session-1" },
onAgentEvent,
onPartialReply,
},
state: {
deterministicApprovalPromptPending: false,
deterministicApprovalPromptSent: false,
reasoningStreamOpen: false,
streamReasoning: false,
deltaBuffer: "",
blockBuffer: "",
partialBlockState: {
thinking: false,
final: false,
inlineCode: createInlineCodeState(),
},
lastStreamedAssistantCleaned: undefined,
emittedAssistantUpdate: false,
shouldEmitPartialReplies: true,
blockReplyBreak: "text_end",
assistantMessageIndex: 0,
},
log: { debug: vi.fn() },
noteLastAssistant: vi.fn(),
stripBlockTags: (text: string) => text,
consumePartialReplyDirectives: vi.fn(() => null),
id: "msg_sig",
phase: "commentary",
});
const ctx = createMessageUpdateContext({
onAgentEvent,
onPartialReply,
flushBlockReplyBuffer,
} as unknown as EmbeddedPiSubscribeContext;
});
handleMessageUpdate(ctx, {
type: "message_update",
message: { role: "assistant", content: [commentaryBlock] },
assistantMessageEvent: { type: "text_delta", delta: "Need send." },
} as never);
handleMessageUpdate(ctx, {
type: "message_update",
message: { role: "assistant", content: [commentaryBlock] },
assistantMessageEvent: { type: "text_end", content: "Need send." },
} as never);
handleMessageUpdate(
ctx,
createTextUpdateEvent({
type: "text_delta",
text: "Need send.",
content: [commentaryBlock],
}),
);
handleMessageUpdate(
ctx,
createTextUpdateEvent({
type: "text_end",
text: "Need send.",
content: [commentaryBlock],
}),
);
await Promise.resolve();
@@ -258,93 +313,42 @@ describe("handleMessageUpdate", () => {
it("suppresses commentary partials even when they contain visible text", () => {
const onAgentEvent = vi.fn();
const ctx = {
params: {
runId: "run-1",
session: { id: "session-1" },
onAgentEvent,
},
state: {
deterministicApprovalPromptPending: false,
deterministicApprovalPromptSent: false,
reasoningStreamOpen: false,
streamReasoning: false,
deltaBuffer: "",
blockBuffer: "",
partialBlockState: {
thinking: false,
final: false,
inlineCode: createInlineCodeState(),
},
lastStreamedAssistant: undefined,
lastStreamedAssistantCleaned: undefined,
emittedAssistantUpdate: false,
shouldEmitPartialReplies: false,
blockReplyBreak: "text_end",
},
log: { debug: vi.fn() },
noteLastAssistant: vi.fn(),
stripBlockTags: (text: string) => text,
consumePartialReplyDirectives: vi.fn(() => null),
emitReasoningStream: vi.fn(),
flushBlockReplyBuffer: vi.fn(),
} as unknown as EmbeddedPiSubscribeContext;
const ctx = createMessageUpdateContext({
onAgentEvent,
shouldEmitPartialReplies: false,
});
handleMessageUpdate(ctx, {
type: "message_update",
message: { role: "assistant", content: [] },
assistantMessageEvent: {
handleMessageUpdate(
ctx,
createTextUpdateEvent({
type: "text_delta",
delta: "Working...",
partial: {
role: "assistant",
content: [
{
type: "text",
text: "Working...",
textSignature: JSON.stringify({ v: 1, id: "item_commentary", phase: "commentary" }),
},
],
phase: "commentary",
stopReason: "stop",
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {},
timestamp: 0,
},
},
} as never);
text: "Working...",
partial: createOpenAiResponsesPartial({
text: "Working...",
id: "item_commentary",
signaturePhase: "commentary",
partialPhase: "commentary",
}),
}),
);
expect(onAgentEvent).not.toHaveBeenCalled();
expect(ctx.state.deltaBuffer).toBe("");
expect(ctx.state.blockBuffer).toBe("");
handleMessageUpdate(ctx, {
type: "message_update",
message: { role: "assistant", content: [] },
assistantMessageEvent: {
handleMessageUpdate(
ctx,
createTextUpdateEvent({
type: "text_delta",
delta: "Done.",
partial: {
role: "assistant",
content: [
{
type: "text",
text: "Done.",
textSignature: JSON.stringify({ v: 1, id: "item_final", phase: "final_answer" }),
},
],
phase: "final_answer",
stopReason: "stop",
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {},
timestamp: 0,
},
},
} as never);
text: "Done.",
partial: createOpenAiResponsesPartial({
text: "Done.",
id: "item_final",
signaturePhase: "final_answer",
partialPhase: "final_answer",
}),
}),
);
expect(onAgentEvent).toHaveBeenCalledTimes(1);
expect(onAgentEvent.mock.calls[0]?.[0]).toMatchObject({
@@ -358,42 +362,15 @@ describe("handleMessageUpdate", () => {
it("contains synchronous text_end flush failures", async () => {
const debug = vi.fn();
const ctx = {
params: {
runId: "run-1",
session: { id: "session-1" },
},
state: {
deterministicApprovalPromptPending: false,
deterministicApprovalPromptSent: false,
reasoningStreamOpen: false,
streamReasoning: false,
deltaBuffer: "",
blockBuffer: "",
partialBlockState: {
thinking: false,
final: false,
inlineCode: createInlineCodeState(),
},
lastStreamedAssistantCleaned: undefined,
emittedAssistantUpdate: false,
shouldEmitPartialReplies: false,
blockReplyBreak: "text_end",
},
log: { debug },
noteLastAssistant: vi.fn(),
stripBlockTags: (text: string) => text,
consumePartialReplyDirectives: vi.fn(() => null),
const ctx = createMessageUpdateContext({
debug,
shouldEmitPartialReplies: false,
flushBlockReplyBuffer: vi.fn(() => {
throw new Error("boom");
}),
} as unknown as EmbeddedPiSubscribeContext;
});
handleMessageUpdate(ctx, {
type: "message_update",
message: { role: "assistant", content: [] },
assistantMessageEvent: { type: "text_end" },
} as never);
handleMessageUpdate(ctx, createTextUpdateEvent({ type: "text_end", text: "" }));
await vi.waitFor(() => {
expect(debug).toHaveBeenCalledWith("text_end block reply flush failed: Error: boom");
@@ -406,44 +383,11 @@ describe("handleMessageEnd", () => {
const onAgentEvent = vi.fn();
const emitBlockReply = vi.fn();
const finalizeAssistantTexts = vi.fn();
const ctx = {
params: {
runId: "run-1",
session: { id: "session-1" },
onAgentEvent,
onBlockReply: vi.fn(),
},
state: {
assistantTexts: [],
assistantTextBaseline: 0,
emittedAssistantUpdate: false,
deterministicApprovalPromptPending: false,
deterministicApprovalPromptSent: false,
reasoningStreamOpen: false,
includeReasoning: false,
streamReasoning: false,
blockReplyBreak: "message_end",
deltaBuffer: "Need send.",
blockBuffer: "Need send.",
blockState: {
thinking: false,
final: false,
inlineCode: createInlineCodeState(),
},
lastStreamedAssistant: undefined,
lastStreamedAssistantCleaned: undefined,
},
noteLastAssistant: vi.fn(),
recordAssistantUsage: vi.fn(),
log: { debug: vi.fn(), warn: vi.fn() },
stripBlockTags: (text: string) => text,
const ctx = createMessageEndContext({
onAgentEvent,
finalizeAssistantTexts,
emitBlockReply,
consumeReplyDirectives: vi.fn(() => ({ text: "Need send." })),
emitReasoningStream: vi.fn(),
flushBlockReplyBuffer: vi.fn(),
blockChunker: null,
} as unknown as EmbeddedPiSubscribeContext;
});
void handleMessageEnd(ctx, {
type: "message_end",
@@ -464,55 +408,22 @@ describe("handleMessageEnd", () => {
const onAgentEvent = vi.fn();
const emitBlockReply = vi.fn();
const finalizeAssistantTexts = vi.fn();
const ctx = {
params: {
runId: "run-1",
session: { id: "session-1" },
onAgentEvent,
onBlockReply: vi.fn(),
},
state: {
assistantTexts: [],
assistantTextBaseline: 0,
emittedAssistantUpdate: false,
deterministicApprovalPromptPending: false,
deterministicApprovalPromptSent: false,
reasoningStreamOpen: false,
includeReasoning: false,
streamReasoning: false,
blockReplyBreak: "message_end",
deltaBuffer: "Need send.",
blockBuffer: "Need send.",
blockState: {
thinking: false,
final: false,
inlineCode: createInlineCodeState(),
},
lastStreamedAssistant: undefined,
lastStreamedAssistantCleaned: undefined,
},
noteLastAssistant: vi.fn(),
recordAssistantUsage: vi.fn(),
log: { debug: vi.fn(), warn: vi.fn() },
stripBlockTags: (text: string) => text,
const ctx = createMessageEndContext({
onAgentEvent,
finalizeAssistantTexts,
emitBlockReply,
consumeReplyDirectives: vi.fn(() => ({ text: "Need send." })),
emitReasoningStream: vi.fn(),
flushBlockReplyBuffer: vi.fn(),
blockChunker: null,
} as unknown as EmbeddedPiSubscribeContext;
});
void handleMessageEnd(ctx, {
type: "message_end",
message: {
role: "assistant",
content: [
{
type: "text",
createOpenAiResponsesTextBlock({
text: "Need send.",
textSignature: JSON.stringify({ v: 1, id: "msg_sig", phase: "commentary" }),
},
id: "msg_sig",
phase: "commentary",
}),
],
usage: { input: 1, output: 1, total: 2 },
},
@@ -530,47 +441,20 @@ describe("handleMessageEnd", () => {
// input. The non-empty call shouldn't happen for text_end channels (that's
// the safety send we're guarding against).
const consumeReplyDirectives = vi.fn((text: string) => (text ? { text } : null));
const ctx = {
params: {
runId: "run-1",
session: { id: "session-1" },
onBlockReply,
},
const ctx = createMessageEndContext({
onBlockReply,
emitBlockReply,
consumeReplyDirectives,
state: {
deterministicApprovalPromptPending: false,
deterministicApprovalPromptSent: false,
messagingToolSentTexts: [],
messagingToolSentTextsNormalized: [],
includeReasoning: false,
streamReasoning: false,
emittedAssistantUpdate: true,
lastStreamedAssistantCleaned: "Hello world",
assistantTexts: [],
assistantTextBaseline: 0,
blockReplyBreak: "text_end",
// Simulate text_end already delivered this text through emitBlockChunk
lastBlockReplyText: "Hello world",
lastReasoningSent: undefined,
reasoningStreamOpen: false,
deltaBuffer: "",
blockBuffer: "",
blockState: {
thinking: false,
final: false,
inlineCode: createInlineCodeState(),
},
},
log: { debug: vi.fn() },
noteLastAssistant: vi.fn(),
recordAssistantUsage: vi.fn(),
stripBlockTags: (text: string) => text,
finalizeAssistantTexts: vi.fn(),
emitBlockReply,
consumeReplyDirectives,
emitReasoningStream: vi.fn(),
flushBlockReplyBuffer: vi.fn(),
blockChunker: null,
} as unknown as EmbeddedPiSubscribeContext;
});
void handleMessageEnd(ctx, {
type: "message_end",
@@ -592,47 +476,20 @@ describe("handleMessageEnd", () => {
const emitBlockReply = vi.fn();
// Same pattern: directive accumulator returns null for empty final flush
const consumeReplyDirectives = vi.fn((text: string) => (text ? { text } : null));
const ctx = {
params: {
runId: "run-1",
session: { id: "session-1" },
onBlockReply,
},
const ctx = createMessageEndContext({
onBlockReply,
emitBlockReply,
consumeReplyDirectives,
state: {
deterministicApprovalPromptPending: false,
deterministicApprovalPromptSent: false,
messagingToolSentTexts: [],
messagingToolSentTextsNormalized: [],
includeReasoning: false,
streamReasoning: false,
emittedAssistantUpdate: true,
lastStreamedAssistantCleaned: "Hello world",
assistantTexts: [],
assistantTextBaseline: 0,
blockReplyBreak: "text_end",
// text_end delivered via emitBlockChunk which uses different stripping
lastBlockReplyText: "Hello world.",
lastReasoningSent: undefined,
reasoningStreamOpen: false,
deltaBuffer: "",
blockBuffer: "",
blockState: {
thinking: false,
final: false,
inlineCode: createInlineCodeState(),
},
},
log: { debug: vi.fn() },
noteLastAssistant: vi.fn(),
recordAssistantUsage: vi.fn(),
stripBlockTags: (text: string) => text,
finalizeAssistantTexts: vi.fn(),
emitBlockReply,
consumeReplyDirectives,
emitReasoningStream: vi.fn(),
flushBlockReplyBuffer: vi.fn(),
blockChunker: null,
} as unknown as EmbeddedPiSubscribeContext;
});
void handleMessageEnd(ctx, {
type: "message_end",
@@ -652,58 +509,32 @@ describe("handleMessageEnd", () => {
it("emits a replacement final assistant event when final_answer appears only at message_end", () => {
const onAgentEvent = vi.fn();
const ctx = {
params: {
runId: "run-1",
session: { id: "session-1" },
onAgentEvent,
},
const ctx = createMessageEndContext({
onAgentEvent,
state: {
deterministicApprovalPromptPending: false,
deterministicApprovalPromptSent: false,
messagingToolSentTexts: [],
messagingToolSentTextsNormalized: [],
includeReasoning: false,
streamReasoning: false,
emittedAssistantUpdate: true,
lastStreamedAssistantCleaned: "Working...",
assistantTexts: [],
assistantTextBaseline: 0,
blockReplyBreak: "text_end",
lastReasoningSent: undefined,
reasoningStreamOpen: false,
deltaBuffer: "",
blockBuffer: "",
blockState: {
thinking: false,
final: false,
inlineCode: createInlineCodeState(),
},
},
log: { debug: vi.fn() },
noteLastAssistant: vi.fn(),
recordAssistantUsage: vi.fn(),
stripBlockTags: (text: string) => text,
finalizeAssistantTexts: vi.fn(),
emitReasoningStream: vi.fn(),
blockChunker: null,
} as unknown as EmbeddedPiSubscribeContext;
});
void handleMessageEnd(ctx, {
type: "message_end",
message: {
role: "assistant",
content: [
{
type: "text",
createOpenAiResponsesTextBlock({
text: "Working...",
textSignature: JSON.stringify({ v: 1, id: "item_commentary", phase: "commentary" }),
},
{
type: "text",
id: "item_commentary",
phase: "commentary",
}),
createOpenAiResponsesTextBlock({
text: "Done.",
textSignature: JSON.stringify({ v: 1, id: "item_final", phase: "final_answer" }),
},
id: "item_final",
phase: "final_answer",
}),
],
stopReason: "stop",
api: "openai-responses",
@@ -0,0 +1,81 @@
export type OpenAiResponsesTextEventPhase = "commentary" | "final_answer";
export function createOpenAiResponsesTextBlock(params: {
text: string;
id: string;
phase?: OpenAiResponsesTextEventPhase;
}) {
return {
type: "text",
text: params.text,
textSignature: JSON.stringify({
v: 1,
id: params.id,
...(params.phase ? { phase: params.phase } : {}),
}),
};
}
export function createOpenAiResponsesPartial(params: {
text: string;
id: string;
signaturePhase?: OpenAiResponsesTextEventPhase;
partialPhase?: OpenAiResponsesTextEventPhase;
}) {
return {
role: "assistant",
content: [
createOpenAiResponsesTextBlock({
text: params.text,
id: params.id,
phase: params.signaturePhase,
}),
],
...(params.partialPhase ? { phase: params.partialPhase } : {}),
stopReason: "stop",
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {},
timestamp: 0,
};
}
export function createOpenAiResponsesTextEvent(params: {
type: "text_delta" | "text_end";
text: string;
delta?: string;
id?: string;
signaturePhase?: OpenAiResponsesTextEventPhase;
partialPhase?: OpenAiResponsesTextEventPhase;
messagePhase?: OpenAiResponsesTextEventPhase;
content?: unknown[];
partial?: ReturnType<typeof createOpenAiResponsesPartial>;
}) {
const partial =
params.partial ??
(params.id
? createOpenAiResponsesPartial({
text: params.text,
id: params.id,
signaturePhase: params.signaturePhase,
partialPhase: params.partialPhase,
})
: undefined);
return {
type: "message_update",
message: {
role: "assistant",
...(params.messagePhase ? { phase: params.messagePhase } : {}),
content: params.content ?? [],
},
assistantMessageEvent: {
type: params.type,
...(params.type === "text_delta"
? { delta: params.delta ?? params.text }
: { content: params.text }),
...(partial ? { partial } : {}),
},
} as never;
}
@@ -5,6 +5,74 @@ import {
emitAssistantTextDelta,
emitAssistantTextEnd,
} from "./pi-embedded-subscribe.e2e-harness.js";
import {
createOpenAiResponsesTextBlock,
createOpenAiResponsesTextEvent,
type OpenAiResponsesTextEventPhase,
} from "./pi-embedded-subscribe.openai-responses.test-helpers.js";
type TextEndBlockReplyHarness = ReturnType<typeof createTextEndBlockReplyHarness>;
function emitOpenAiResponsesTextEvent(params: {
emit: TextEndBlockReplyHarness["emit"];
type: "text_delta" | "text_end";
text: string;
delta?: string;
id: string;
signaturePhase?: OpenAiResponsesTextEventPhase;
partialPhase?: OpenAiResponsesTextEventPhase;
}) {
const { emit, ...eventParams } = params;
emit(createOpenAiResponsesTextEvent(eventParams));
}
function emitOpenAiResponsesTextDeltaAndEnd(params: {
emit: TextEndBlockReplyHarness["emit"];
text: string;
delta?: string;
id: string;
phase?: OpenAiResponsesTextEventPhase;
}) {
const { phase, ...eventParams } = params;
emitOpenAiResponsesTextEvent({
...eventParams,
type: "text_delta",
signaturePhase: phase,
partialPhase: phase,
});
emitOpenAiResponsesTextEvent({
...eventParams,
type: "text_end",
delta: undefined,
signaturePhase: phase,
partialPhase: phase,
});
}
function emitOpenAiResponsesFinalMessageEnd(params: {
emit: TextEndBlockReplyHarness["emit"];
commentaryText: string;
finalText: string;
}) {
params.emit({
type: "message_end",
message: {
role: "assistant",
content: [
createOpenAiResponsesTextBlock({
text: params.commentaryText,
id: "item_commentary",
phase: "commentary",
}),
createOpenAiResponsesTextBlock({
text: params.finalText,
id: "item_final",
phase: "final_answer",
}),
],
} as AssistantMessage,
});
}
describe("subscribeEmbeddedPiSession", () => {
it("emits block replies on text_end and does not duplicate on message_end", async () => {
@@ -65,53 +133,17 @@ describe("subscribeEmbeddedPiSession", () => {
const { emit, subscription } = createTextEndBlockReplyHarness({ onBlockReply });
emit({ type: "message_start", message: { role: "assistant" } });
emit({
type: "message_update",
message: { role: "assistant", content: [] },
assistantMessageEvent: {
type: "text_delta",
delta: "Legacy answer",
partial: {
role: "assistant",
content: [
{
type: "text",
text: "Legacy answer",
textSignature: JSON.stringify({ v: 1, id: "item_legacy" }),
},
],
stopReason: "stop",
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {},
timestamp: 0,
},
},
emitOpenAiResponsesTextEvent({
emit,
type: "text_delta",
text: "Legacy answer",
id: "item_legacy",
});
emit({
type: "message_update",
message: { role: "assistant", content: [] },
assistantMessageEvent: {
type: "text_end",
content: "Legacy answer",
partial: {
role: "assistant",
content: [
{
type: "text",
text: "Legacy answer",
textSignature: JSON.stringify({ v: 1, id: "item_legacy" }),
},
],
stopReason: "stop",
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {},
timestamp: 0,
},
},
emitOpenAiResponsesTextEvent({
emit,
type: "text_end",
text: "Legacy answer",
id: "item_legacy",
});
await Promise.resolve();
@@ -136,131 +168,26 @@ describe("subscribeEmbeddedPiSession", () => {
const { emit, subscription } = createTextEndBlockReplyHarness({ onBlockReply });
emit({ type: "message_start", message: { role: "assistant" } });
emit({
type: "message_update",
message: { role: "assistant", content: [] },
assistantMessageEvent: {
type: "text_delta",
delta: "Working...",
partial: {
role: "assistant",
content: [
{
type: "text",
text: "Working...",
textSignature: JSON.stringify({ v: 1, id: "item_commentary", phase: "commentary" }),
},
],
phase: "commentary",
stopReason: "stop",
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {},
timestamp: 0,
},
},
});
emit({
type: "message_update",
message: { role: "assistant", content: [] },
assistantMessageEvent: {
type: "text_end",
content: "Working...",
partial: {
role: "assistant",
content: [
{
type: "text",
text: "Working...",
textSignature: JSON.stringify({ v: 1, id: "item_commentary", phase: "commentary" }),
},
],
phase: "commentary",
stopReason: "stop",
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {},
timestamp: 0,
},
},
emitOpenAiResponsesTextDeltaAndEnd({
emit,
text: "Working...",
id: "item_commentary",
phase: "commentary",
});
await Promise.resolve();
expect(onBlockReply).not.toHaveBeenCalled();
expect(subscription.assistantTexts).toEqual([]);
emit({
type: "message_update",
message: { role: "assistant", content: [] },
assistantMessageEvent: {
type: "text_delta",
delta: "Done.",
partial: {
role: "assistant",
content: [
{
type: "text",
text: "Done.",
textSignature: JSON.stringify({ v: 1, id: "item_final", phase: "final_answer" }),
},
],
phase: "final_answer",
stopReason: "stop",
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {},
timestamp: 0,
},
},
});
emit({
type: "message_update",
message: { role: "assistant", content: [] },
assistantMessageEvent: {
type: "text_end",
content: "Done.",
partial: {
role: "assistant",
content: [
{
type: "text",
text: "Done.",
textSignature: JSON.stringify({ v: 1, id: "item_final", phase: "final_answer" }),
},
],
phase: "final_answer",
stopReason: "stop",
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {},
timestamp: 0,
},
},
emitOpenAiResponsesTextDeltaAndEnd({
emit,
text: "Done.",
id: "item_final",
phase: "final_answer",
});
await Promise.resolve();
emit({
type: "message_end",
message: {
role: "assistant",
content: [
{
type: "text",
text: "Working...",
textSignature: JSON.stringify({ v: 1, id: "item_commentary", phase: "commentary" }),
},
{
type: "text",
text: "Done.",
textSignature: JSON.stringify({ v: 1, id: "item_final", phase: "final_answer" }),
},
],
} as AssistantMessage,
});
emitOpenAiResponsesFinalMessageEnd({ emit, commentaryText: "Working...", finalText: "Done." });
expect(onBlockReply).toHaveBeenCalledTimes(1);
expect(onBlockReply.mock.calls[0]?.[0]?.text).toBe("Done.");
@@ -272,109 +199,22 @@ describe("subscribeEmbeddedPiSession", () => {
const { emit, subscription } = createTextEndBlockReplyHarness({ onBlockReply });
emit({ type: "message_start", message: { role: "assistant" } });
emit({
type: "message_update",
message: { role: "assistant", content: [] },
assistantMessageEvent: {
type: "text_delta",
delta: "Hello",
partial: {
role: "assistant",
content: [
{
type: "text",
text: "Hello",
textSignature: JSON.stringify({ v: 1, id: "item_commentary", phase: "commentary" }),
},
],
phase: "commentary",
stopReason: "stop",
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {},
timestamp: 0,
},
},
});
emit({
type: "message_update",
message: { role: "assistant", content: [] },
assistantMessageEvent: {
type: "text_end",
content: "Hello",
partial: {
role: "assistant",
content: [
{
type: "text",
text: "Hello",
textSignature: JSON.stringify({ v: 1, id: "item_commentary", phase: "commentary" }),
},
],
phase: "commentary",
stopReason: "stop",
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {},
timestamp: 0,
},
},
emitOpenAiResponsesTextDeltaAndEnd({
emit,
text: "Hello",
id: "item_commentary",
phase: "commentary",
});
await Promise.resolve();
expect(onBlockReply).not.toHaveBeenCalled();
emit({
type: "message_update",
message: { role: "assistant", content: [] },
assistantMessageEvent: {
type: "text_delta",
delta: " world",
partial: {
role: "assistant",
content: [
{
type: "text",
text: "Hello world",
textSignature: JSON.stringify({ v: 1, id: "item_final", phase: "final_answer" }),
},
],
phase: "final_answer",
stopReason: "stop",
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {},
timestamp: 0,
},
},
});
emit({
type: "message_update",
message: { role: "assistant", content: [] },
assistantMessageEvent: {
type: "text_end",
content: "Hello world",
partial: {
role: "assistant",
content: [
{
type: "text",
text: "Hello world",
textSignature: JSON.stringify({ v: 1, id: "item_final", phase: "final_answer" }),
},
],
phase: "final_answer",
stopReason: "stop",
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {},
timestamp: 0,
},
},
emitOpenAiResponsesTextDeltaAndEnd({
emit,
text: "Hello world",
delta: " world",
id: "item_final",
phase: "final_answer",
});
await Promise.resolve();
@@ -388,53 +228,19 @@ describe("subscribeEmbeddedPiSession", () => {
const { emit, subscription } = createTextEndBlockReplyHarness({ onBlockReply });
emit({ type: "message_start", message: { role: "assistant" } });
emit({
type: "message_update",
message: { role: "assistant", content: [] },
assistantMessageEvent: {
type: "text_delta",
delta: "Done.",
partial: {
role: "assistant",
content: [
{
type: "text",
text: "Done.",
textSignature: JSON.stringify({ v: 1, id: "item_final", phase: "final_answer" }),
},
],
stopReason: "stop",
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {},
timestamp: 0,
},
},
emitOpenAiResponsesTextEvent({
emit,
type: "text_delta",
text: "Done.",
id: "item_final",
signaturePhase: "final_answer",
});
emit({
type: "message_update",
message: { role: "assistant", content: [] },
assistantMessageEvent: {
type: "text_end",
content: "Done.",
partial: {
role: "assistant",
content: [
{
type: "text",
text: "Done.",
textSignature: JSON.stringify({ v: 1, id: "item_final", phase: "final_answer" }),
},
],
stopReason: "stop",
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {},
timestamp: 0,
},
},
emitOpenAiResponsesTextEvent({
emit,
type: "text_end",
text: "Done.",
id: "item_final",
signaturePhase: "final_answer",
});
await Promise.resolve();
@@ -448,76 +254,15 @@ describe("subscribeEmbeddedPiSession", () => {
const { emit, subscription } = createTextEndBlockReplyHarness({ onBlockReply });
emit({ type: "message_start", message: { role: "assistant" } });
emit({
type: "message_update",
message: { role: "assistant", content: [] },
assistantMessageEvent: {
type: "text_delta",
delta: "Working...",
partial: {
role: "assistant",
content: [
{
type: "text",
text: "Working...",
textSignature: JSON.stringify({ v: 1, id: "item_commentary", phase: "commentary" }),
},
],
phase: "commentary",
stopReason: "stop",
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {},
timestamp: 0,
},
},
});
emit({
type: "message_update",
message: { role: "assistant", content: [] },
assistantMessageEvent: {
type: "text_end",
content: "Working...",
partial: {
role: "assistant",
content: [
{
type: "text",
text: "Working...",
textSignature: JSON.stringify({ v: 1, id: "item_commentary", phase: "commentary" }),
},
],
phase: "commentary",
stopReason: "stop",
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {},
timestamp: 0,
},
},
emitOpenAiResponsesTextDeltaAndEnd({
emit,
text: "Working...",
id: "item_commentary",
phase: "commentary",
});
await Promise.resolve();
emit({
type: "message_end",
message: {
role: "assistant",
content: [
{
type: "text",
text: "Working...",
textSignature: JSON.stringify({ v: 1, id: "item_commentary", phase: "commentary" }),
},
{
type: "text",
text: "Done.",
textSignature: JSON.stringify({ v: 1, id: "item_final", phase: "final_answer" }),
},
],
} as AssistantMessage,
});
emitOpenAiResponsesFinalMessageEnd({ emit, commentaryText: "Working...", finalText: "Done." });
expect(onBlockReply).toHaveBeenCalledTimes(1);
expect(onBlockReply.mock.calls[0]?.[0]?.text).toBe("Done.");
+2 -43
View File
@@ -182,26 +182,7 @@ const CronPatchObjectSchema = Type.Optional(
Type.Object(
{
name: Type.Optional(Type.String({ description: "Job name" })),
schedule: Type.Optional(
Type.Object(
{
kind: optionalStringEnum(CRON_SCHEDULE_KINDS, { description: "Schedule type" }),
at: Type.Optional(Type.String({ description: "ISO-8601 timestamp (kind=at)" })),
everyMs: Type.Optional(
Type.Number({ description: "Interval in milliseconds (kind=every)" }),
),
anchorMs: Type.Optional(
Type.Number({ description: "Optional start anchor in milliseconds (kind=every)" }),
),
expr: Type.Optional(Type.String({ description: "Cron expression (kind=cron)" })),
tz: Type.Optional(Type.String({ description: "IANA timezone (kind=cron)" })),
staggerMs: Type.Optional(
Type.Number({ description: "Random jitter in ms (kind=cron)" }),
),
},
{ additionalProperties: true },
),
),
schedule: CronScheduleSchema,
sessionTarget: Type.Optional(Type.String({ description: "Session target" })),
wakeMode: optionalStringEnum(CRON_WAKE_MODES),
payload: Type.Optional(
@@ -209,29 +190,7 @@ const CronPatchObjectSchema = Type.Optional(
toolsAllow: nullableStringArraySchema("Allowed tool ids, or null to clear"),
}),
),
delivery: Type.Optional(
Type.Object(
{
mode: optionalStringEnum(CRON_DELIVERY_MODES, { description: "Delivery mode" }),
channel: Type.Optional(Type.String({ description: "Delivery channel" })),
to: Type.Optional(Type.String({ description: "Delivery target" })),
bestEffort: Type.Optional(Type.Boolean()),
accountId: Type.Optional(Type.String({ description: "Account target for delivery" })),
failureDestination: Type.Optional(
Type.Object(
{
channel: Type.Optional(Type.String()),
to: Type.Optional(Type.String()),
accountId: Type.Optional(Type.String()),
mode: optionalStringEnum(["announce", "webhook"] as const),
},
{ additionalProperties: true },
),
),
},
{ additionalProperties: true },
),
),
delivery: CronDeliverySchema,
description: Type.Optional(Type.String()),
enabled: Type.Optional(Type.Boolean()),
deleteAfterRun: Type.Optional(Type.Boolean()),
+51 -116
View File
@@ -64,6 +64,45 @@ vi.mock("../../cli/nodes-screen.js", () => ({
let createNodesTool: typeof import("./nodes-tool.js").createNodesTool;
function mockNodePairApproveFlow(pendingRequest: {
requiredApproveScopes?: string[];
commands?: string[];
}): void {
gatewayMocks.callGatewayTool.mockImplementation(async (method, _opts, params, extra) => {
if (method === "node.pair.list") {
return {
pending: [
{
requestId: "req-1",
...pendingRequest,
},
],
};
}
if (method === "node.pair.approve") {
return { ok: true, method, params, extra };
}
throw new Error(`unexpected method: ${String(method)}`);
});
}
function expectNodePairApproveScopes(scopes: string[]): void {
expect(gatewayMocks.callGatewayTool).toHaveBeenNthCalledWith(
1,
"node.pair.list",
{},
{},
{ scopes: ["operator.pairing"] },
);
expect(gatewayMocks.callGatewayTool).toHaveBeenNthCalledWith(
2,
"node.pair.approve",
{},
{ requestId: "req-1" },
{ scopes },
);
}
describe("createNodesTool screen_record duration guardrails", () => {
beforeAll(async () => {
({ createNodesTool } = await import("./nodes-tool.js"));
@@ -212,21 +251,8 @@ describe("createNodesTool screen_record duration guardrails", () => {
});
it("uses operator.pairing plus operator.admin to approve exec-capable node pair requests", async () => {
gatewayMocks.callGatewayTool.mockImplementation(async (method, _opts, params, extra) => {
if (method === "node.pair.list") {
return {
pending: [
{
requestId: "req-1",
requiredApproveScopes: ["operator.pairing", "operator.admin"],
},
],
};
}
if (method === "node.pair.approve") {
return { ok: true, method, params, extra };
}
throw new Error(`unexpected method: ${String(method)}`);
mockNodePairApproveFlow({
requiredApproveScopes: ["operator.pairing", "operator.admin"],
});
const tool = createNodesTool();
@@ -235,38 +261,12 @@ describe("createNodesTool screen_record duration guardrails", () => {
requestId: "req-1",
});
expect(gatewayMocks.callGatewayTool).toHaveBeenNthCalledWith(
1,
"node.pair.list",
{},
{},
{ scopes: ["operator.pairing"] },
);
expect(gatewayMocks.callGatewayTool).toHaveBeenNthCalledWith(
2,
"node.pair.approve",
{},
{ requestId: "req-1" },
{ scopes: ["operator.pairing", "operator.admin"] },
);
expectNodePairApproveScopes(["operator.pairing", "operator.admin"]);
});
it("uses operator.pairing plus operator.write to approve non-exec node pair requests", async () => {
gatewayMocks.callGatewayTool.mockImplementation(async (method, _opts, params, extra) => {
if (method === "node.pair.list") {
return {
pending: [
{
requestId: "req-1",
requiredApproveScopes: ["operator.pairing", "operator.write"],
},
],
};
}
if (method === "node.pair.approve") {
return { ok: true, method, params, extra };
}
throw new Error(`unexpected method: ${String(method)}`);
mockNodePairApproveFlow({
requiredApproveScopes: ["operator.pairing", "operator.write"],
});
const tool = createNodesTool();
@@ -275,38 +275,12 @@ describe("createNodesTool screen_record duration guardrails", () => {
requestId: "req-1",
});
expect(gatewayMocks.callGatewayTool).toHaveBeenNthCalledWith(
1,
"node.pair.list",
{},
{},
{ scopes: ["operator.pairing"] },
);
expect(gatewayMocks.callGatewayTool).toHaveBeenNthCalledWith(
2,
"node.pair.approve",
{},
{ requestId: "req-1" },
{ scopes: ["operator.pairing", "operator.write"] },
);
expectNodePairApproveScopes(["operator.pairing", "operator.write"]);
});
it("uses operator.pairing for commandless node pair requests", async () => {
gatewayMocks.callGatewayTool.mockImplementation(async (method, _opts, params, extra) => {
if (method === "node.pair.list") {
return {
pending: [
{
requestId: "req-1",
requiredApproveScopes: ["operator.pairing"],
},
],
};
}
if (method === "node.pair.approve") {
return { ok: true, method, params, extra };
}
throw new Error(`unexpected method: ${String(method)}`);
mockNodePairApproveFlow({
requiredApproveScopes: ["operator.pairing"],
});
const tool = createNodesTool();
@@ -315,38 +289,12 @@ describe("createNodesTool screen_record duration guardrails", () => {
requestId: "req-1",
});
expect(gatewayMocks.callGatewayTool).toHaveBeenNthCalledWith(
1,
"node.pair.list",
{},
{},
{ scopes: ["operator.pairing"] },
);
expect(gatewayMocks.callGatewayTool).toHaveBeenNthCalledWith(
2,
"node.pair.approve",
{},
{ requestId: "req-1" },
{ scopes: ["operator.pairing"] },
);
expectNodePairApproveScopes(["operator.pairing"]);
});
it("falls back to command inspection when the gateway does not advertise required scopes", async () => {
gatewayMocks.callGatewayTool.mockImplementation(async (method, _opts, params, extra) => {
if (method === "node.pair.list") {
return {
pending: [
{
requestId: "req-1",
commands: ["canvas.snapshot"],
},
],
};
}
if (method === "node.pair.approve") {
return { ok: true, method, params, extra };
}
throw new Error(`unexpected method: ${String(method)}`);
mockNodePairApproveFlow({
commands: ["canvas.snapshot"],
});
const tool = createNodesTool();
@@ -355,20 +303,7 @@ describe("createNodesTool screen_record duration guardrails", () => {
requestId: "req-1",
});
expect(gatewayMocks.callGatewayTool).toHaveBeenNthCalledWith(
1,
"node.pair.list",
{},
{},
{ scopes: ["operator.pairing"] },
);
expect(gatewayMocks.callGatewayTool).toHaveBeenNthCalledWith(
2,
"node.pair.approve",
{},
{ requestId: "req-1" },
{ scopes: ["operator.pairing", "operator.write"] },
);
expectNodePairApproveScopes(["operator.pairing", "operator.write"]);
});
it("blocks invokeCommand system.run so exec stays the only shell path", async () => {
@@ -1,6 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SubagentRunRecord } from "../../agents/subagent-registry.types.js";
import type { OpenClawConfig } from "../../config/config.js";
import { buildSubagentsSendContext } from "./commands-subagents.test-helpers.js";
import { handleSubagentsSendAction } from "./commands-subagents/action-send.js";
const sendControlledSubagentMessageMock = vi.hoisted(() => vi.fn());
@@ -11,45 +10,11 @@ vi.mock("./commands-subagents-control.runtime.js", () => ({
steerControlledSubagentRun: steerControlledSubagentRunMock,
}));
function buildRun(): SubagentRunRecord {
return {
runId: "run-1",
childSessionKey: "agent:main:subagent:abc",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "do thing",
cleanup: "keep",
createdAt: 1000,
startedAt: 1000,
};
}
function buildContext(params?: {
cfg?: OpenClawConfig;
requesterKey?: string;
runs?: SubagentRunRecord[];
restTokens?: string[];
}) {
return {
params: {
cfg:
params?.cfg ??
({
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as OpenClawConfig),
ctx: {},
command: {
channel: "whatsapp",
to: "test-bot",
},
},
const buildContext = () =>
buildSubagentsSendContext({
handledPrefix: "/subagents",
requesterKey: params?.requesterKey ?? "agent:main:main",
runs: params?.runs ?? [buildRun()],
restTokens: params?.restTokens ?? ["1", "continue", "with", "follow-up", "details"],
} as Parameters<typeof handleSubagentsSendAction>[0];
}
restTokens: ["1", "continue", "with", "follow-up", "details"],
});
describe("subagents send action", () => {
beforeEach(() => {
@@ -1,6 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SubagentRunRecord } from "../../agents/subagent-registry.types.js";
import type { OpenClawConfig } from "../../config/config.js";
import { buildSubagentsSendContext } from "./commands-subagents.test-helpers.js";
import { handleSubagentsSendAction } from "./commands-subagents/action-send.js";
const sendControlledSubagentMessageMock = vi.hoisted(() => vi.fn());
@@ -11,45 +10,11 @@ vi.mock("./commands-subagents-control.runtime.js", () => ({
steerControlledSubagentRun: steerControlledSubagentRunMock,
}));
function buildRun(): SubagentRunRecord {
return {
runId: "run-1",
childSessionKey: "agent:main:subagent:abc",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "do thing",
cleanup: "keep",
createdAt: 1000,
startedAt: 1000,
};
}
function buildContext(params?: {
cfg?: OpenClawConfig;
requesterKey?: string;
runs?: SubagentRunRecord[];
restTokens?: string[];
}) {
return {
params: {
cfg:
params?.cfg ??
({
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as OpenClawConfig),
ctx: {},
command: {
channel: "whatsapp",
to: "test-bot",
},
},
const buildContext = () =>
buildSubagentsSendContext({
handledPrefix: "/steer",
requesterKey: params?.requesterKey ?? "agent:main:main",
runs: params?.runs ?? [buildRun()],
restTokens: params?.restTokens ?? ["1", "check", "timer.ts", "instead"],
} as Parameters<typeof handleSubagentsSendAction>[0];
}
restTokens: ["1", "check", "timer.ts", "instead"],
});
describe("subagents steer action", () => {
beforeEach(() => {
@@ -0,0 +1,44 @@
import type { SubagentRunRecord } from "../../agents/subagent-registry.types.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { handleSubagentsSendAction } from "./commands-subagents/action-send.js";
export function buildSubagentRun(): SubagentRunRecord {
return {
runId: "run-1",
childSessionKey: "agent:main:subagent:abc",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "do thing",
cleanup: "keep",
createdAt: 1000,
startedAt: 1000,
};
}
export function buildSubagentsSendContext(params?: {
cfg?: OpenClawConfig;
handledPrefix?: string;
requesterKey?: string;
runs?: SubagentRunRecord[];
restTokens?: string[];
}) {
return {
params: {
cfg:
params?.cfg ??
({
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as OpenClawConfig),
ctx: {},
command: {
channel: "whatsapp",
to: "test-bot",
},
},
handledPrefix: params?.handledPrefix ?? "/subagents",
requesterKey: params?.requesterKey ?? "agent:main:main",
runs: params?.runs ?? [buildSubagentRun()],
restTokens: params?.restTokens ?? [],
} as Parameters<typeof handleSubagentsSendAction>[0];
}
@@ -1,6 +1,5 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js";
import type {
AcpRuntime,
AcpRuntimeEnsureInput,
@@ -8,288 +7,27 @@ import type {
AcpRuntimeHandle,
AcpRuntimeTurnInput,
} from "../../plugin-sdk/acp-runtime.js";
import type {
PluginHookBeforeDispatchResult,
PluginHookReplyDispatchResult,
PluginTargetedInboundClaimOutcome,
} from "../../plugins/hooks.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import {
createChannelTestPluginBase,
createTestRegistry,
} from "../../test-utils/channel-plugins.js";
import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js";
import type { ReplyPayload } from "../types.js";
import type { ReplyDispatcher } from "./reply-dispatcher.js";
import {
acpManagerRuntimeMocks,
acpMocks,
agentEventMocks,
createDispatcher,
diagnosticMocks,
hookMocks,
internalHookMocks,
mocks,
noAbortResult,
resetPluginTtsAndThreadMocks,
sessionBindingMocks,
sessionStoreMocks,
setDiscordTestRegistry,
} from "./dispatch-from-config.shared.test-harness.js";
import { buildTestCtx } from "./test-ctx.js";
type AbortResult = { handled: boolean; aborted: boolean; stoppedSubagents?: number };
const mocks = vi.hoisted(() => ({
routeReply: vi.fn(async (_params: unknown) => ({ ok: true, messageId: "mock" })),
tryFastAbortFromMessage: vi.fn<() => Promise<AbortResult>>(async () => ({
handled: false,
aborted: false,
})),
}));
const diagnosticMocks = vi.hoisted(() => ({
logMessageQueued: vi.fn(),
logMessageProcessed: vi.fn(),
logSessionStateChange: vi.fn(),
}));
const hookMocks = vi.hoisted(() => ({
registry: {
plugins: [] as Array<{ id: string; status: "loaded" | "disabled" | "error" }>,
},
runner: {
hasHooks: vi.fn<(hookName?: string) => boolean>(() => false),
runInboundClaim: vi.fn(async () => undefined),
runInboundClaimForPlugin: vi.fn(async () => undefined),
runInboundClaimForPluginOutcome: vi.fn<() => Promise<PluginTargetedInboundClaimOutcome>>(
async () => ({ status: "no_handler" as const }),
),
runMessageReceived: vi.fn(async () => {}),
runBeforeDispatch: vi.fn<
(_event: unknown, _ctx: unknown) => Promise<PluginHookBeforeDispatchResult | undefined>
>(async () => undefined),
runReplyDispatch: vi.fn<
(_event: unknown, _ctx: unknown) => Promise<PluginHookReplyDispatchResult | undefined>
>(async () => undefined),
},
}));
const internalHookMocks = vi.hoisted(() => ({
createInternalHookEvent: vi.fn(),
triggerInternalHook: vi.fn(async () => {}),
}));
const acpMocks = vi.hoisted(() => ({
listAcpSessionEntries: vi.fn(async () => []),
readAcpSessionEntry: vi.fn<(params: { sessionKey: string; cfg?: OpenClawConfig }) => unknown>(
() => null,
),
getAcpRuntimeBackend: vi.fn<() => unknown>(() => null),
upsertAcpSessionMeta: vi.fn<
(params: {
sessionKey: string;
cfg?: OpenClawConfig;
mutate: (
current: Record<string, unknown> | undefined,
entry: { acp?: Record<string, unknown> } | undefined,
) => Record<string, unknown> | null | undefined;
}) => Promise<unknown>
>(async () => null),
requireAcpRuntimeBackend: vi.fn<() => unknown>(),
}));
const sessionBindingMocks = vi.hoisted(() => ({
listBySession: vi.fn<(targetSessionKey: string) => SessionBindingRecord[]>(() => []),
resolveByConversation: vi.fn<
(ref: {
channel: string;
accountId: string;
conversationId: string;
parentConversationId?: string;
}) => SessionBindingRecord | null
>(() => null),
touch: vi.fn(),
}));
const pluginConversationBindingMocks = vi.hoisted(() => ({
shownFallbackNoticeBindingIds: new Set<string>(),
}));
const sessionStoreMocks = vi.hoisted(() => ({
currentEntry: undefined as Record<string, unknown> | undefined,
loadSessionStore: vi.fn(() => ({})),
resolveStorePath: vi.fn(() => "/tmp/mock-sessions.json"),
resolveSessionStoreEntry: vi.fn(() => ({ existing: sessionStoreMocks.currentEntry })),
}));
const acpManagerRuntimeMocks = vi.hoisted(() => ({
getAcpSessionManager: vi.fn(),
}));
const agentEventMocks = vi.hoisted(() => ({
emitAgentEvent: vi.fn(),
onAgentEvent: vi.fn<(listener: unknown) => () => void>(() => () => {}),
}));
const ttsMocks = vi.hoisted(() => ({
maybeApplyTtsToPayload: vi.fn(async (paramsUnknown: unknown) => {
const params = paramsUnknown as { payload: ReplyPayload };
return params.payload;
}),
normalizeTtsAutoMode: vi.fn((value: unknown) => (typeof value === "string" ? value : undefined)),
resolveTtsConfig: vi.fn((_cfg: OpenClawConfig) => ({ mode: "final" })),
}));
const threadInfoMocks = vi.hoisted(() => ({
parseSessionThreadInfo: vi.fn<
(sessionKey: string | undefined) => {
baseSessionKey: string | undefined;
threadId: string | undefined;
}
>(),
}));
function parseGenericThreadSessionInfo(sessionKey: string | undefined) {
const trimmed = sessionKey?.trim();
if (!trimmed) {
return { baseSessionKey: undefined, threadId: undefined };
}
const threadMarker = ":thread:";
const topicMarker = ":topic:";
const marker = trimmed.includes(threadMarker)
? threadMarker
: trimmed.includes(topicMarker)
? topicMarker
: undefined;
if (!marker) {
return { baseSessionKey: trimmed, threadId: undefined };
}
const index = trimmed.lastIndexOf(marker);
if (index < 0) {
return { baseSessionKey: trimmed, threadId: undefined };
}
const baseSessionKey = trimmed.slice(0, index).trim() || undefined;
const threadId = trimmed.slice(index + marker.length).trim() || undefined;
return { baseSessionKey, threadId };
}
vi.mock("./route-reply.runtime.js", () => ({
isRoutableChannel: () => true,
routeReply: mocks.routeReply,
}));
vi.mock("./route-reply.js", () => ({
isRoutableChannel: () => true,
routeReply: mocks.routeReply,
}));
vi.mock("./abort.runtime.js", () => ({
tryFastAbortFromMessage: mocks.tryFastAbortFromMessage,
formatAbortReplyText: () => "⚙️ Agent was aborted.",
}));
vi.mock("../../logging/diagnostic.js", () => ({
logMessageQueued: diagnosticMocks.logMessageQueued,
logMessageProcessed: diagnosticMocks.logMessageProcessed,
logSessionStateChange: diagnosticMocks.logSessionStateChange,
}));
vi.mock("../../config/sessions/thread-info.js", () => ({
parseSessionThreadInfo: (sessionKey: string | undefined) =>
threadInfoMocks.parseSessionThreadInfo(sessionKey),
}));
vi.mock("./dispatch-from-config.runtime.js", () => ({
createInternalHookEvent: internalHookMocks.createInternalHookEvent,
loadSessionStore: sessionStoreMocks.loadSessionStore,
resolveSessionStoreEntry: sessionStoreMocks.resolveSessionStoreEntry,
resolveStorePath: sessionStoreMocks.resolveStorePath,
triggerInternalHook: internalHookMocks.triggerInternalHook,
}));
vi.mock("../../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: () => hookMocks.runner,
getGlobalPluginRegistry: () => hookMocks.registry,
}));
vi.mock("../../acp/runtime/session-meta.js", () => ({
listAcpSessionEntries: acpMocks.listAcpSessionEntries,
readAcpSessionEntry: acpMocks.readAcpSessionEntry,
upsertAcpSessionMeta: acpMocks.upsertAcpSessionMeta,
}));
vi.mock("../../acp/runtime/registry.js", () => ({
getAcpRuntimeBackend: acpMocks.getAcpRuntimeBackend,
requireAcpRuntimeBackend: acpMocks.requireAcpRuntimeBackend,
}));
vi.mock("../../infra/outbound/session-binding-service.js", () => ({
getSessionBindingService: () => ({
bind: vi.fn(async () => {
throw new Error("bind not mocked");
}),
getCapabilities: vi.fn(() => ({
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current", "child"] as const,
})),
listBySession: (targetSessionKey: string) =>
sessionBindingMocks.listBySession(targetSessionKey),
resolveByConversation: sessionBindingMocks.resolveByConversation,
touch: sessionBindingMocks.touch,
unbind: vi.fn(async () => []),
}),
}));
vi.mock("../../infra/agent-events.js", () => ({
emitAgentEvent: (params: unknown) => agentEventMocks.emitAgentEvent(params),
onAgentEvent: (listener: unknown) => agentEventMocks.onAgentEvent(listener),
}));
vi.mock("../../plugins/conversation-binding.js", () => ({
buildPluginBindingDeclinedText: () => "Plugin binding request was declined.",
buildPluginBindingErrorText: () => "Plugin binding request failed.",
buildPluginBindingUnavailableText: (binding: { pluginName?: string; pluginId: string }) =>
`${binding.pluginName ?? binding.pluginId} is not currently loaded.`,
hasShownPluginBindingFallbackNotice: (bindingId: string) =>
pluginConversationBindingMocks.shownFallbackNoticeBindingIds.has(bindingId),
isPluginOwnedSessionBindingRecord: (
record: SessionBindingRecord | null | undefined,
): record is SessionBindingRecord =>
record?.metadata != null &&
typeof record.metadata === "object" &&
(record.metadata as { pluginBindingOwner?: string }).pluginBindingOwner === "plugin",
markPluginBindingFallbackNoticeShown: (bindingId: string) => {
pluginConversationBindingMocks.shownFallbackNoticeBindingIds.add(bindingId);
},
toPluginConversationBinding: (record: SessionBindingRecord) => ({
bindingId: record.bindingId,
pluginId: "unknown-plugin",
pluginName: undefined,
pluginRoot: "",
channel: record.conversation.channel,
accountId: record.conversation.accountId,
conversationId: record.conversation.conversationId,
parentConversationId: record.conversation.parentConversationId,
}),
}));
vi.mock("./dispatch-acp-manager.runtime.js", () => ({
getAcpSessionManager: () => acpManagerRuntimeMocks.getAcpSessionManager(),
getSessionBindingService: () => ({
listBySession: (targetSessionKey: string) =>
sessionBindingMocks.listBySession(targetSessionKey),
unbind: vi.fn(async () => []),
}),
}));
vi.mock("../../tts/tts.js", () => ({
maybeApplyTtsToPayload: (params: unknown) => ttsMocks.maybeApplyTtsToPayload(params),
normalizeTtsAutoMode: (value: unknown) => ttsMocks.normalizeTtsAutoMode(value),
resolveTtsConfig: (cfg: OpenClawConfig) => ttsMocks.resolveTtsConfig(cfg),
}));
vi.mock("../../tts/tts.runtime.js", () => ({
maybeApplyTtsToPayload: (params: unknown) => ttsMocks.maybeApplyTtsToPayload(params),
}));
vi.mock("../../tts/status-config.js", () => ({
resolveStatusTtsSnapshot: () => ({
autoMode: "always",
provider: "auto",
maxLength: 1500,
summarize: true,
}),
}));
vi.mock("./dispatch-acp-tts.runtime.js", () => ({
maybeApplyTtsToPayload: (params: unknown) => ttsMocks.maybeApplyTtsToPayload(params),
}));
vi.mock("./dispatch-acp-session.runtime.js", () => ({
readAcpSessionEntry: (params: { sessionKey: string; cfg?: OpenClawConfig }) =>
acpMocks.readAcpSessionEntry(params),
}));
vi.mock("../../tts/tts-config.js", () => ({
normalizeTtsAutoMode: (value: unknown) => ttsMocks.normalizeTtsAutoMode(value),
resolveConfiguredTtsMode: (cfg: OpenClawConfig) => ttsMocks.resolveTtsConfig(cfg).mode,
}));
const noAbortResult = { handled: false, aborted: false } as const;
let dispatchReplyFromConfig: typeof import("./dispatch-from-config.js").dispatchReplyFromConfig;
let tryDispatchAcpReplyHook: typeof import("../../plugin-sdk/acp-runtime.js").tryDispatchAcpReplyHook;
function createDispatcher(): ReplyDispatcher {
return {
sendToolResult: vi.fn(() => true),
sendBlockReply: vi.fn(() => true),
sendFinalReply: vi.fn(() => true),
waitForIdle: vi.fn(async () => {}),
getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
getFailedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
markComplete: vi.fn(),
};
}
function shouldUseAcpReplyDispatchHook(eventUnknown: unknown): boolean {
const event = eventUnknown as {
sessionKey?: string;
@@ -393,19 +131,7 @@ describe("dispatchReplyFromConfig ACP abort", () => {
});
beforeEach(() => {
const discordTestPlugin = {
...createChannelTestPluginBase({
id: "discord",
capabilities: { chatTypes: ["direct"], nativeCommands: true },
}),
outbound: {
deliveryMode: "direct",
shouldSuppressLocalPayloadPrompt: () => false,
},
};
setActivePluginRegistry(
createTestRegistry([{ pluginId: "discord", source: "test", plugin: discordTestPlugin }]),
);
setDiscordTestRegistry();
acpManagerRuntimeMocks.getAcpSessionManager.mockReset();
acpManagerRuntimeMocks.getAcpSessionManager.mockReturnValue(createMockAcpSessionManager());
hookMocks.runner.hasHooks.mockReset();
@@ -445,20 +171,7 @@ describe("dispatchReplyFromConfig ACP abort", () => {
sessionBindingMocks.listBySession.mockReset().mockReturnValue([]);
sessionBindingMocks.resolveByConversation.mockReset().mockReturnValue(null);
sessionBindingMocks.touch.mockReset();
pluginConversationBindingMocks.shownFallbackNoticeBindingIds.clear();
ttsMocks.maybeApplyTtsToPayload
.mockReset()
.mockImplementation(async (paramsUnknown: unknown) => {
const params = paramsUnknown as { payload: ReplyPayload };
return params.payload;
});
ttsMocks.normalizeTtsAutoMode
.mockReset()
.mockImplementation((value: unknown) => (typeof value === "string" ? value : undefined));
ttsMocks.resolveTtsConfig.mockReset().mockReturnValue({ mode: "final" });
threadInfoMocks.parseSessionThreadInfo
.mockReset()
.mockImplementation(parseGenericThreadSessionInfo);
resetPluginTtsAndThreadMocks();
diagnosticMocks.logMessageQueued.mockReset();
diagnosticMocks.logMessageProcessed.mockReset();
diagnosticMocks.logSessionStateChange.mockReset();
@@ -1,304 +1,26 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js";
import type {
PluginHookBeforeDispatchResult,
PluginHookReplyDispatchResult,
PluginTargetedInboundClaimOutcome,
} from "../../plugins/hooks.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import {
createChannelTestPluginBase,
createTestRegistry,
} from "../../test-utils/channel-plugins.js";
import type { PluginHookReplyDispatchResult } from "../../plugins/hooks.js";
import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js";
import type { ReplyPayload } from "../types.js";
import type { ReplyDispatcher } from "./reply-dispatcher.js";
import { buildTestCtx } from "./test-ctx.js";
import {
acpManagerRuntimeMocks,
acpMocks,
agentEventMocks,
createDispatcher,
createHookCtx,
diagnosticMocks,
emptyConfig,
hookMocks,
internalHookMocks,
mocks,
resetPluginTtsAndThreadMocks,
sessionBindingMocks,
sessionStoreMocks,
setDiscordTestRegistry,
} from "./dispatch-from-config.shared.test-harness.js";
type AbortResult = { handled: boolean; aborted: boolean; stoppedSubagents?: number };
const mocks = vi.hoisted(() => ({
routeReply: vi.fn(async () => ({ ok: true, messageId: "mock" })),
tryFastAbortFromMessage: vi.fn<() => Promise<AbortResult>>(async () => ({
handled: false,
aborted: false,
})),
}));
const diagnosticMocks = vi.hoisted(() => ({
logMessageQueued: vi.fn(),
logMessageProcessed: vi.fn(),
logSessionStateChange: vi.fn(),
}));
const hookMocks = vi.hoisted(() => ({
registry: {
plugins: [] as Array<{ id: string; status: "loaded" | "disabled" | "error" }>,
},
runner: {
hasHooks: vi.fn<(hookName?: string) => boolean>(() => false),
runInboundClaim: vi.fn(async () => undefined),
runInboundClaimForPlugin: vi.fn(async () => undefined),
runInboundClaimForPluginOutcome: vi.fn<() => Promise<PluginTargetedInboundClaimOutcome>>(
async () => ({ status: "no_handler" as const }),
),
runMessageReceived: vi.fn(async () => {}),
runBeforeDispatch: vi.fn<
(_event: unknown, _ctx: unknown) => Promise<PluginHookBeforeDispatchResult | undefined>
>(async () => undefined),
runReplyDispatch: vi.fn<
(_event: unknown, _ctx: unknown) => Promise<PluginHookReplyDispatchResult | undefined>
>(async () => undefined),
},
}));
const internalHookMocks = vi.hoisted(() => ({
createInternalHookEvent: vi.fn(),
triggerInternalHook: vi.fn(async () => {}),
}));
const acpMocks = vi.hoisted(() => ({
listAcpSessionEntries: vi.fn(async () => []),
readAcpSessionEntry: vi.fn<(params: { sessionKey: string; cfg?: OpenClawConfig }) => unknown>(
() => null,
),
upsertAcpSessionMeta: vi.fn(async () => null),
requireAcpRuntimeBackend: vi.fn<() => unknown>(),
}));
const sessionBindingMocks = vi.hoisted(() => ({
listBySession: vi.fn<(targetSessionKey: string) => SessionBindingRecord[]>(() => []),
resolveByConversation: vi.fn<
(ref: {
channel: string;
accountId: string;
conversationId: string;
parentConversationId?: string;
}) => SessionBindingRecord | null
>(() => null),
touch: vi.fn(),
}));
const pluginConversationBindingMocks = vi.hoisted(() => ({
shownFallbackNoticeBindingIds: new Set<string>(),
}));
const sessionStoreMocks = vi.hoisted(() => ({
currentEntry: undefined as Record<string, unknown> | undefined,
loadSessionStore: vi.fn(() => ({})),
resolveStorePath: vi.fn(() => "/tmp/mock-sessions.json"),
resolveSessionStoreEntry: vi.fn(() => ({ existing: sessionStoreMocks.currentEntry })),
}));
const acpManagerRuntimeMocks = vi.hoisted(() => ({
getAcpSessionManager: vi.fn(() => ({
resolveSession: () => ({ kind: "none" as const }),
getObservabilitySnapshot: () => ({
runtimeCache: { activeSessions: 0, idleTtlMs: 0, evictedTotal: 0 },
turns: {
active: 0,
queueDepth: 0,
completed: 0,
failed: 0,
averageLatencyMs: 0,
maxLatencyMs: 0,
},
errorsByCode: {},
}),
runTurn: vi.fn(),
})),
}));
const agentEventMocks = vi.hoisted(() => ({
emitAgentEvent: vi.fn(),
onAgentEvent: vi.fn<(listener: unknown) => () => void>(() => () => {}),
}));
const ttsMocks = vi.hoisted(() => ({
maybeApplyTtsToPayload: vi.fn(async (paramsUnknown: unknown) => {
const params = paramsUnknown as { payload: ReplyPayload };
return params.payload;
}),
normalizeTtsAutoMode: vi.fn((value: unknown) => (typeof value === "string" ? value : undefined)),
resolveTtsConfig: vi.fn((_cfg: OpenClawConfig) => ({ mode: "final" })),
}));
const threadInfoMocks = vi.hoisted(() => ({
parseSessionThreadInfo: vi.fn<
(sessionKey: string | undefined) => {
baseSessionKey: string | undefined;
threadId: string | undefined;
}
>(),
}));
function parseGenericThreadSessionInfo(sessionKey: string | undefined) {
const trimmed = sessionKey?.trim();
if (!trimmed) {
return { baseSessionKey: undefined, threadId: undefined };
}
const threadMarker = ":thread:";
const topicMarker = ":topic:";
const marker = trimmed.includes(threadMarker)
? threadMarker
: trimmed.includes(topicMarker)
? topicMarker
: undefined;
if (!marker) {
return { baseSessionKey: trimmed, threadId: undefined };
}
const index = trimmed.lastIndexOf(marker);
if (index < 0) {
return { baseSessionKey: trimmed, threadId: undefined };
}
const baseSessionKey = trimmed.slice(0, index).trim() || undefined;
const threadId = trimmed.slice(index + marker.length).trim() || undefined;
return { baseSessionKey, threadId };
}
vi.mock("./route-reply.runtime.js", () => ({
isRoutableChannel: () => true,
routeReply: mocks.routeReply,
}));
vi.mock("./route-reply.js", () => ({
isRoutableChannel: () => true,
routeReply: mocks.routeReply,
}));
vi.mock("./abort.runtime.js", () => ({
tryFastAbortFromMessage: mocks.tryFastAbortFromMessage,
formatAbortReplyText: () => "⚙️ Agent was aborted.",
}));
vi.mock("../../logging/diagnostic.js", () => ({
logMessageQueued: diagnosticMocks.logMessageQueued,
logMessageProcessed: diagnosticMocks.logMessageProcessed,
logSessionStateChange: diagnosticMocks.logSessionStateChange,
}));
vi.mock("../../config/sessions/thread-info.js", () => ({
parseSessionThreadInfo: (sessionKey: string | undefined) =>
threadInfoMocks.parseSessionThreadInfo(sessionKey),
}));
vi.mock("./dispatch-from-config.runtime.js", () => ({
createInternalHookEvent: internalHookMocks.createInternalHookEvent,
loadSessionStore: sessionStoreMocks.loadSessionStore,
resolveSessionStoreEntry: sessionStoreMocks.resolveSessionStoreEntry,
resolveStorePath: sessionStoreMocks.resolveStorePath,
triggerInternalHook: internalHookMocks.triggerInternalHook,
}));
vi.mock("../../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: () => hookMocks.runner,
getGlobalPluginRegistry: () => hookMocks.registry,
}));
vi.mock("../../acp/runtime/session-meta.js", () => ({
listAcpSessionEntries: acpMocks.listAcpSessionEntries,
readAcpSessionEntry: acpMocks.readAcpSessionEntry,
upsertAcpSessionMeta: acpMocks.upsertAcpSessionMeta,
}));
vi.mock("../../acp/runtime/registry.js", () => ({
requireAcpRuntimeBackend: acpMocks.requireAcpRuntimeBackend,
}));
vi.mock("../../infra/outbound/session-binding-service.js", () => ({
getSessionBindingService: () => ({
bind: vi.fn(async () => {
throw new Error("bind not mocked");
}),
getCapabilities: vi.fn(() => ({
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current", "child"] as const,
})),
listBySession: (targetSessionKey: string) =>
sessionBindingMocks.listBySession(targetSessionKey),
resolveByConversation: sessionBindingMocks.resolveByConversation,
touch: sessionBindingMocks.touch,
unbind: vi.fn(async () => []),
}),
}));
vi.mock("../../infra/agent-events.js", () => ({
emitAgentEvent: (params: unknown) => agentEventMocks.emitAgentEvent(params),
onAgentEvent: (listener: unknown) => agentEventMocks.onAgentEvent(listener),
}));
vi.mock("../../plugins/conversation-binding.js", () => ({
buildPluginBindingDeclinedText: () => "Plugin binding request was declined.",
buildPluginBindingErrorText: () => "Plugin binding request failed.",
buildPluginBindingUnavailableText: (binding: { pluginName?: string; pluginId: string }) =>
`${binding.pluginName ?? binding.pluginId} is not currently loaded.`,
hasShownPluginBindingFallbackNotice: (bindingId: string) =>
pluginConversationBindingMocks.shownFallbackNoticeBindingIds.has(bindingId),
isPluginOwnedSessionBindingRecord: (
record: SessionBindingRecord | null | undefined,
): record is SessionBindingRecord =>
record?.metadata != null &&
typeof record.metadata === "object" &&
(record.metadata as { pluginBindingOwner?: string }).pluginBindingOwner === "plugin",
markPluginBindingFallbackNoticeShown: (bindingId: string) => {
pluginConversationBindingMocks.shownFallbackNoticeBindingIds.add(bindingId);
},
toPluginConversationBinding: (record: SessionBindingRecord) => ({
bindingId: record.bindingId,
pluginId: "unknown-plugin",
pluginName: undefined,
pluginRoot: "",
channel: record.conversation.channel,
accountId: record.conversation.accountId,
conversationId: record.conversation.conversationId,
parentConversationId: record.conversation.parentConversationId,
}),
}));
vi.mock("./dispatch-acp-manager.runtime.js", () => ({
getAcpSessionManager: () => acpManagerRuntimeMocks.getAcpSessionManager(),
getSessionBindingService: () => ({
listBySession: (targetSessionKey: string) =>
sessionBindingMocks.listBySession(targetSessionKey),
unbind: vi.fn(async () => []),
}),
}));
vi.mock("../../tts/tts.js", () => ({
maybeApplyTtsToPayload: (params: unknown) => ttsMocks.maybeApplyTtsToPayload(params),
normalizeTtsAutoMode: (value: unknown) => ttsMocks.normalizeTtsAutoMode(value),
resolveTtsConfig: (cfg: OpenClawConfig) => ttsMocks.resolveTtsConfig(cfg),
}));
vi.mock("../../tts/tts.runtime.js", () => ({
maybeApplyTtsToPayload: (params: unknown) => ttsMocks.maybeApplyTtsToPayload(params),
}));
vi.mock("../../tts/status-config.js", () => ({
resolveStatusTtsSnapshot: () => ({
autoMode: "always",
provider: "auto",
maxLength: 1500,
summarize: true,
}),
}));
vi.mock("./dispatch-acp-tts.runtime.js", () => ({
maybeApplyTtsToPayload: (params: unknown) => ttsMocks.maybeApplyTtsToPayload(params),
}));
vi.mock("./dispatch-acp-session.runtime.js", () => ({
readAcpSessionEntry: (params: { sessionKey: string; cfg?: OpenClawConfig }) =>
acpMocks.readAcpSessionEntry(params),
}));
vi.mock("../../tts/tts-config.js", () => ({
normalizeTtsAutoMode: (value: unknown) => ttsMocks.normalizeTtsAutoMode(value),
resolveConfiguredTtsMode: (cfg: OpenClawConfig) => ttsMocks.resolveTtsConfig(cfg).mode,
}));
const emptyConfig = {} as OpenClawConfig;
let dispatchReplyFromConfig: typeof import("./dispatch-from-config.js").dispatchReplyFromConfig;
let resetInboundDedupe: typeof import("./inbound-dedupe.js").resetInboundDedupe;
function createDispatcher(): ReplyDispatcher {
return {
sendToolResult: vi.fn(() => true),
sendBlockReply: vi.fn(() => true),
sendFinalReply: vi.fn(() => true),
waitForIdle: vi.fn(async () => {}),
getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
getFailedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
markComplete: vi.fn(),
};
}
function createHookCtx() {
return buildTestCtx({
Body: "hello",
BodyForAgent: "hello",
BodyForCommands: "hello",
From: "user1",
Surface: "telegram",
ChatType: "private",
SessionKey: "agent:test:session",
});
}
describe("dispatchReplyFromConfig reply_dispatch hook", () => {
beforeAll(async () => {
({ dispatchReplyFromConfig } = await import("./dispatch-from-config.js"));
@@ -306,19 +28,7 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => {
});
beforeEach(() => {
const discordTestPlugin = {
...createChannelTestPluginBase({
id: "discord",
capabilities: { chatTypes: ["direct"], nativeCommands: true },
}),
outbound: {
deliveryMode: "direct",
shouldSuppressLocalPayloadPrompt: () => false,
},
};
setActivePluginRegistry(
createTestRegistry([{ pluginId: "discord", source: "test", plugin: discordTestPlugin }]),
);
setDiscordTestRegistry();
resetInboundDedupe();
mocks.routeReply.mockReset().mockResolvedValue({ ok: true, messageId: "mock" });
mocks.tryFastAbortFromMessage.mockReset().mockResolvedValue({
@@ -347,7 +57,6 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => {
sessionBindingMocks.listBySession.mockReset().mockReturnValue([]);
sessionBindingMocks.resolveByConversation.mockReset().mockReturnValue(null);
sessionBindingMocks.touch.mockReset();
pluginConversationBindingMocks.shownFallbackNoticeBindingIds.clear();
sessionStoreMocks.currentEntry = undefined;
sessionStoreMocks.loadSessionStore.mockReset().mockReturnValue({});
sessionStoreMocks.resolveStorePath.mockReset().mockReturnValue("/tmp/mock-sessions.json");
@@ -374,19 +83,7 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => {
diagnosticMocks.logMessageQueued.mockReset();
diagnosticMocks.logMessageProcessed.mockReset();
diagnosticMocks.logSessionStateChange.mockReset();
ttsMocks.maybeApplyTtsToPayload
.mockReset()
.mockImplementation(async (paramsUnknown: unknown) => {
const params = paramsUnknown as { payload: ReplyPayload };
return params.payload;
});
ttsMocks.normalizeTtsAutoMode
.mockReset()
.mockImplementation((value: unknown) => (typeof value === "string" ? value : undefined));
ttsMocks.resolveTtsConfig.mockReset().mockReturnValue({ mode: "final" });
threadInfoMocks.parseSessionThreadInfo
.mockReset()
.mockImplementation(parseGenericThreadSessionInfo);
resetPluginTtsAndThreadMocks();
});
it("returns handled dispatch results from plugins", async () => {
@@ -0,0 +1,342 @@
import { vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js";
import type {
PluginHookBeforeDispatchResult,
PluginHookReplyDispatchResult,
PluginTargetedInboundClaimOutcome,
} from "../../plugins/hooks.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import {
createChannelTestPluginBase,
createTestRegistry,
} from "../../test-utils/channel-plugins.js";
import type { ReplyPayload } from "../types.js";
import type { ReplyDispatcher } from "./reply-dispatcher.js";
import { buildTestCtx } from "./test-ctx.js";
type AbortResult = { handled: boolean; aborted: boolean; stoppedSubagents?: number };
const mocks = vi.hoisted(() => ({
routeReply: vi.fn(async (_params: unknown) => ({ ok: true, messageId: "mock" })),
tryFastAbortFromMessage: vi.fn<() => Promise<AbortResult>>(async () => ({
handled: false,
aborted: false,
})),
}));
const diagnosticMocks = vi.hoisted(() => ({
logMessageQueued: vi.fn(),
logMessageProcessed: vi.fn(),
logSessionStateChange: vi.fn(),
}));
const hookMocks = vi.hoisted(() => ({
registry: {
plugins: [] as Array<{ id: string; status: "loaded" | "disabled" | "error" }>,
},
runner: {
hasHooks: vi.fn<(hookName?: string) => boolean>(() => false),
runInboundClaim: vi.fn(async () => undefined),
runInboundClaimForPlugin: vi.fn(async () => undefined),
runInboundClaimForPluginOutcome: vi.fn<() => Promise<PluginTargetedInboundClaimOutcome>>(
async () => ({ status: "no_handler" as const }),
),
runMessageReceived: vi.fn(async () => {}),
runBeforeDispatch: vi.fn<
(_event: unknown, _ctx: unknown) => Promise<PluginHookBeforeDispatchResult | undefined>
>(async () => undefined),
runReplyDispatch: vi.fn<
(_event: unknown, _ctx: unknown) => Promise<PluginHookReplyDispatchResult | undefined>
>(async () => undefined),
},
}));
const internalHookMocks = vi.hoisted(() => ({
createInternalHookEvent: vi.fn(),
triggerInternalHook: vi.fn(async () => {}),
}));
const acpMocks = vi.hoisted(() => ({
listAcpSessionEntries: vi.fn(async () => []),
readAcpSessionEntry: vi.fn<(params: { sessionKey: string; cfg?: OpenClawConfig }) => unknown>(
() => null,
),
getAcpRuntimeBackend: vi.fn<() => unknown>(() => null),
upsertAcpSessionMeta: vi.fn<
(params: {
sessionKey: string;
cfg?: OpenClawConfig;
mutate: (
current: Record<string, unknown> | undefined,
entry: { acp?: Record<string, unknown> } | undefined,
) => Record<string, unknown> | null | undefined;
}) => Promise<unknown>
>(async () => null),
requireAcpRuntimeBackend: vi.fn<() => unknown>(),
}));
const sessionBindingMocks = vi.hoisted(() => ({
listBySession: vi.fn<(targetSessionKey: string) => SessionBindingRecord[]>(() => []),
resolveByConversation: vi.fn<
(ref: {
channel: string;
accountId: string;
conversationId: string;
parentConversationId?: string;
}) => SessionBindingRecord | null
>(() => null),
touch: vi.fn(),
}));
const pluginConversationBindingMocks = vi.hoisted(() => ({
shownFallbackNoticeBindingIds: new Set<string>(),
}));
const sessionStoreMocks = vi.hoisted(() => ({
currentEntry: undefined as Record<string, unknown> | undefined,
loadSessionStore: vi.fn(() => ({})),
resolveStorePath: vi.fn(() => "/tmp/mock-sessions.json"),
resolveSessionStoreEntry: vi.fn(() => ({ existing: sessionStoreMocks.currentEntry })),
}));
const acpManagerRuntimeMocks = vi.hoisted(() => ({
getAcpSessionManager: vi.fn(),
}));
const agentEventMocks = vi.hoisted(() => ({
emitAgentEvent: vi.fn(),
onAgentEvent: vi.fn<(listener: unknown) => () => void>(() => () => {}),
}));
const ttsMocks = vi.hoisted(() => ({
maybeApplyTtsToPayload: vi.fn(async (paramsUnknown: unknown) => {
const params = paramsUnknown as { payload: ReplyPayload };
return params.payload;
}),
normalizeTtsAutoMode: vi.fn((value: unknown) => (typeof value === "string" ? value : undefined)),
resolveTtsConfig: vi.fn((_cfg: OpenClawConfig) => ({ mode: "final" })),
}));
const threadInfoMocks = vi.hoisted(() => ({
parseSessionThreadInfo: vi.fn<
(sessionKey: string | undefined) => {
baseSessionKey: string | undefined;
threadId: string | undefined;
}
>(),
}));
export {
acpManagerRuntimeMocks,
acpMocks,
agentEventMocks,
diagnosticMocks,
hookMocks,
internalHookMocks,
mocks,
pluginConversationBindingMocks,
sessionBindingMocks,
sessionStoreMocks,
threadInfoMocks,
ttsMocks,
};
export function parseGenericThreadSessionInfo(sessionKey: string | undefined) {
const trimmed = sessionKey?.trim();
if (!trimmed) {
return { baseSessionKey: undefined, threadId: undefined };
}
const threadMarker = ":thread:";
const topicMarker = ":topic:";
const marker = trimmed.includes(threadMarker)
? threadMarker
: trimmed.includes(topicMarker)
? topicMarker
: undefined;
if (!marker) {
return { baseSessionKey: trimmed, threadId: undefined };
}
const index = trimmed.lastIndexOf(marker);
if (index < 0) {
return { baseSessionKey: trimmed, threadId: undefined };
}
const baseSessionKey = trimmed.slice(0, index).trim() || undefined;
const threadId = trimmed.slice(index + marker.length).trim() || undefined;
return { baseSessionKey, threadId };
}
vi.mock("./route-reply.runtime.js", () => ({
isRoutableChannel: () => true,
routeReply: mocks.routeReply,
}));
vi.mock("./route-reply.js", () => ({
isRoutableChannel: () => true,
routeReply: mocks.routeReply,
}));
vi.mock("./abort.runtime.js", () => ({
tryFastAbortFromMessage: mocks.tryFastAbortFromMessage,
formatAbortReplyText: () => "⚙️ Agent was aborted.",
}));
vi.mock("../../logging/diagnostic.js", () => ({
logMessageQueued: diagnosticMocks.logMessageQueued,
logMessageProcessed: diagnosticMocks.logMessageProcessed,
logSessionStateChange: diagnosticMocks.logSessionStateChange,
}));
vi.mock("../../config/sessions/thread-info.js", () => ({
parseSessionThreadInfo: (sessionKey: string | undefined) =>
threadInfoMocks.parseSessionThreadInfo(sessionKey),
}));
vi.mock("./dispatch-from-config.runtime.js", () => ({
createInternalHookEvent: internalHookMocks.createInternalHookEvent,
loadSessionStore: sessionStoreMocks.loadSessionStore,
resolveSessionStoreEntry: sessionStoreMocks.resolveSessionStoreEntry,
resolveStorePath: sessionStoreMocks.resolveStorePath,
triggerInternalHook: internalHookMocks.triggerInternalHook,
}));
vi.mock("../../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: () => hookMocks.runner,
getGlobalPluginRegistry: () => hookMocks.registry,
}));
vi.mock("../../acp/runtime/session-meta.js", () => ({
listAcpSessionEntries: acpMocks.listAcpSessionEntries,
readAcpSessionEntry: acpMocks.readAcpSessionEntry,
upsertAcpSessionMeta: acpMocks.upsertAcpSessionMeta,
}));
vi.mock("../../acp/runtime/registry.js", () => ({
getAcpRuntimeBackend: acpMocks.getAcpRuntimeBackend,
requireAcpRuntimeBackend: acpMocks.requireAcpRuntimeBackend,
}));
vi.mock("../../infra/outbound/session-binding-service.js", () => ({
getSessionBindingService: () => ({
bind: vi.fn(async () => {
throw new Error("bind not mocked");
}),
getCapabilities: vi.fn(() => ({
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current", "child"] as const,
})),
listBySession: (targetSessionKey: string) =>
sessionBindingMocks.listBySession(targetSessionKey),
resolveByConversation: sessionBindingMocks.resolveByConversation,
touch: sessionBindingMocks.touch,
unbind: vi.fn(async () => []),
}),
}));
vi.mock("../../infra/agent-events.js", () => ({
emitAgentEvent: (params: unknown) => agentEventMocks.emitAgentEvent(params),
onAgentEvent: (listener: unknown) => agentEventMocks.onAgentEvent(listener),
}));
vi.mock("../../plugins/conversation-binding.js", () => ({
buildPluginBindingDeclinedText: () => "Plugin binding request was declined.",
buildPluginBindingErrorText: () => "Plugin binding request failed.",
buildPluginBindingUnavailableText: (binding: { pluginName?: string; pluginId: string }) =>
`${binding.pluginName ?? binding.pluginId} is not currently loaded.`,
hasShownPluginBindingFallbackNotice: (bindingId: string) =>
pluginConversationBindingMocks.shownFallbackNoticeBindingIds.has(bindingId),
isPluginOwnedSessionBindingRecord: (
record: SessionBindingRecord | null | undefined,
): record is SessionBindingRecord =>
record?.metadata != null &&
typeof record.metadata === "object" &&
(record.metadata as { pluginBindingOwner?: string }).pluginBindingOwner === "plugin",
markPluginBindingFallbackNoticeShown: (bindingId: string) => {
pluginConversationBindingMocks.shownFallbackNoticeBindingIds.add(bindingId);
},
toPluginConversationBinding: (record: SessionBindingRecord) => ({
bindingId: record.bindingId,
pluginId: "unknown-plugin",
pluginName: undefined,
pluginRoot: "",
channel: record.conversation.channel,
accountId: record.conversation.accountId,
conversationId: record.conversation.conversationId,
parentConversationId: record.conversation.parentConversationId,
}),
}));
vi.mock("./dispatch-acp-manager.runtime.js", () => ({
getAcpSessionManager: () => acpManagerRuntimeMocks.getAcpSessionManager(),
getSessionBindingService: () => ({
listBySession: (targetSessionKey: string) =>
sessionBindingMocks.listBySession(targetSessionKey),
unbind: vi.fn(async () => []),
}),
}));
vi.mock("../../tts/tts.js", () => ({
maybeApplyTtsToPayload: (params: unknown) => ttsMocks.maybeApplyTtsToPayload(params),
normalizeTtsAutoMode: (value: unknown) => ttsMocks.normalizeTtsAutoMode(value),
resolveTtsConfig: (cfg: OpenClawConfig) => ttsMocks.resolveTtsConfig(cfg),
}));
vi.mock("../../tts/tts.runtime.js", () => ({
maybeApplyTtsToPayload: (params: unknown) => ttsMocks.maybeApplyTtsToPayload(params),
}));
vi.mock("../../tts/status-config.js", () => ({
resolveStatusTtsSnapshot: () => ({
autoMode: "always",
provider: "auto",
maxLength: 1500,
summarize: true,
}),
}));
vi.mock("./dispatch-acp-tts.runtime.js", () => ({
maybeApplyTtsToPayload: (params: unknown) => ttsMocks.maybeApplyTtsToPayload(params),
}));
vi.mock("./dispatch-acp-session.runtime.js", () => ({
readAcpSessionEntry: (params: { sessionKey: string; cfg?: OpenClawConfig }) =>
acpMocks.readAcpSessionEntry(params),
}));
vi.mock("../../tts/tts-config.js", () => ({
normalizeTtsAutoMode: (value: unknown) => ttsMocks.normalizeTtsAutoMode(value),
resolveConfiguredTtsMode: (cfg: OpenClawConfig) => ttsMocks.resolveTtsConfig(cfg).mode,
}));
export const noAbortResult = { handled: false, aborted: false } as const;
export const emptyConfig = {} as OpenClawConfig;
export function createDispatcher(): ReplyDispatcher {
const acceptReply = () => true;
const emptyCounts = () => ({ tool: 0, block: 0, final: 0 });
return {
sendToolResult: vi.fn(acceptReply),
sendBlockReply: vi.fn(acceptReply),
sendFinalReply: vi.fn(acceptReply),
waitForIdle: vi.fn(async () => {}),
getQueuedCounts: vi.fn(emptyCounts),
getFailedCounts: vi.fn(emptyCounts),
markComplete: vi.fn(),
};
}
export function resetPluginTtsAndThreadMocks() {
pluginConversationBindingMocks.shownFallbackNoticeBindingIds.clear();
ttsMocks.maybeApplyTtsToPayload.mockReset().mockImplementation(async (paramsUnknown: unknown) => {
const params = paramsUnknown as { payload: ReplyPayload };
return params.payload;
});
ttsMocks.normalizeTtsAutoMode
.mockReset()
.mockImplementation((value: unknown) => (typeof value === "string" ? value : undefined));
ttsMocks.resolveTtsConfig.mockReset().mockReturnValue({ mode: "final" });
threadInfoMocks.parseSessionThreadInfo
.mockReset()
.mockImplementation(parseGenericThreadSessionInfo);
}
export function setDiscordTestRegistry() {
const discordTestPlugin = {
...createChannelTestPluginBase({
id: "discord",
capabilities: { chatTypes: ["direct"], nativeCommands: true },
}),
outbound: {
deliveryMode: "direct",
shouldSuppressLocalPayloadPrompt: () => false,
},
};
setActivePluginRegistry(
createTestRegistry([{ pluginId: "discord", source: "test", plugin: discordTestPlugin }]),
);
}
export function createHookCtx() {
return buildTestCtx({
Body: "hello",
BodyForAgent: "hello",
BodyForCommands: "hello",
From: "user1",
Surface: "telegram",
ChatType: "private",
SessionKey: "agent:test:session",
});
}
+7 -54
View File
@@ -1,60 +1,13 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { defaultRuntime } from "../../runtime.js";
import { describe, expect, it } from "vitest";
import type { FollowupRun, QueueSettings } from "./queue.js";
import { enqueueFollowupRun, scheduleFollowupDrain } from "./queue.js";
import {
createDeferred,
createQueueTestRun as createRun,
installQueueRuntimeErrorSilencer,
} from "./queue.test-helpers.js";
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function createRun(params: {
prompt: string;
messageId?: string;
originatingChannel?: FollowupRun["originatingChannel"];
originatingTo?: string;
originatingAccountId?: string;
originatingThreadId?: string | number;
}): FollowupRun {
return {
prompt: params.prompt,
messageId: params.messageId,
enqueuedAt: Date.now(),
originatingChannel: params.originatingChannel,
originatingTo: params.originatingTo,
originatingAccountId: params.originatingAccountId,
originatingThreadId: params.originatingThreadId,
run: {
agentId: "agent",
agentDir: "/tmp",
sessionId: "sess",
sessionFile: "/tmp/session.json",
workspaceDir: "/tmp",
config: {} as OpenClawConfig,
provider: "openai",
model: "gpt-test",
timeoutMs: 10_000,
blockReplyBreak: "text_end",
},
};
}
let previousRuntimeError: typeof defaultRuntime.error;
beforeAll(() => {
previousRuntimeError = defaultRuntime.error;
defaultRuntime.error = (() => {}) as typeof defaultRuntime.error;
});
afterAll(() => {
defaultRuntime.error = previousRuntimeError;
});
installQueueRuntimeErrorSilencer();
describe("followup queue collect routing", () => {
it("does not collect when destinations differ", async () => {
+7 -54
View File
@@ -1,65 +1,18 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it } from "vitest";
import { importFreshModule } from "../../../test/helpers/import-fresh.js";
import type { OpenClawConfig } from "../../config/config.js";
import { defaultRuntime } from "../../runtime.js";
import type { FollowupRun, QueueSettings } from "./queue.js";
import {
enqueueFollowupRun,
resetRecentQueuedMessageIdDedupe,
scheduleFollowupDrain,
} from "./queue.js";
import {
createDeferred,
createQueueTestRun as createRun,
installQueueRuntimeErrorSilencer,
} from "./queue.test-helpers.js";
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function createRun(params: {
prompt: string;
messageId?: string;
originatingChannel?: FollowupRun["originatingChannel"];
originatingTo?: string;
originatingAccountId?: string;
originatingThreadId?: string | number;
}): FollowupRun {
return {
prompt: params.prompt,
messageId: params.messageId,
enqueuedAt: Date.now(),
originatingChannel: params.originatingChannel,
originatingTo: params.originatingTo,
originatingAccountId: params.originatingAccountId,
originatingThreadId: params.originatingThreadId,
run: {
agentId: "agent",
agentDir: "/tmp",
sessionId: "sess",
sessionFile: "/tmp/session.json",
workspaceDir: "/tmp",
config: {} as OpenClawConfig,
provider: "openai",
model: "gpt-test",
timeoutMs: 10_000,
blockReplyBreak: "text_end",
},
};
}
let previousRuntimeError: typeof defaultRuntime.error;
beforeAll(() => {
previousRuntimeError = defaultRuntime.error;
defaultRuntime.error = (() => {}) as typeof defaultRuntime.error;
});
afterAll(() => {
defaultRuntime.error = previousRuntimeError;
});
installQueueRuntimeErrorSilencer();
describe("followup queue deduplication", () => {
beforeEach(() => {
@@ -1,61 +1,14 @@
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { importFreshModule } from "../../../test/helpers/import-fresh.js";
import type { OpenClawConfig } from "../../config/config.js";
import { defaultRuntime } from "../../runtime.js";
import type { FollowupRun, QueueSettings } from "./queue.js";
import { enqueueFollowupRun, scheduleFollowupDrain } from "./queue.js";
import {
createDeferred,
createQueueTestRun as createRun,
installQueueRuntimeErrorSilencer,
} from "./queue.test-helpers.js";
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function createRun(params: {
prompt: string;
messageId?: string;
originatingChannel?: FollowupRun["originatingChannel"];
originatingTo?: string;
originatingAccountId?: string;
originatingThreadId?: string | number;
}): FollowupRun {
return {
prompt: params.prompt,
messageId: params.messageId,
enqueuedAt: Date.now(),
originatingChannel: params.originatingChannel,
originatingTo: params.originatingTo,
originatingAccountId: params.originatingAccountId,
originatingThreadId: params.originatingThreadId,
run: {
agentId: "agent",
agentDir: "/tmp",
sessionId: "sess",
sessionFile: "/tmp/session.json",
workspaceDir: "/tmp",
config: {} as OpenClawConfig,
provider: "openai",
model: "gpt-test",
timeoutMs: 10_000,
blockReplyBreak: "text_end",
},
};
}
let previousRuntimeError: typeof defaultRuntime.error;
beforeAll(() => {
previousRuntimeError = defaultRuntime.error;
defaultRuntime.error = (() => {}) as typeof defaultRuntime.error;
});
afterAll(() => {
defaultRuntime.error = previousRuntimeError;
});
installQueueRuntimeErrorSilencer();
describe("followup queue drain restart after idle window", () => {
it("does not retain stale callbacks when scheduleFollowupDrain runs with an empty queue", async () => {
@@ -0,0 +1,58 @@
import { afterAll, beforeAll } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { defaultRuntime } from "../../runtime.js";
import type { FollowupRun } from "./queue.js";
export function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
export function createQueueTestRun(params: {
prompt: string;
messageId?: string;
originatingChannel?: FollowupRun["originatingChannel"];
originatingTo?: string;
originatingAccountId?: string;
originatingThreadId?: string | number;
}): FollowupRun {
return {
prompt: params.prompt,
messageId: params.messageId,
enqueuedAt: Date.now(),
originatingChannel: params.originatingChannel,
originatingTo: params.originatingTo,
originatingAccountId: params.originatingAccountId,
originatingThreadId: params.originatingThreadId,
run: {
agentId: "agent",
agentDir: "/tmp",
sessionId: "sess",
sessionFile: "/tmp/session.json",
workspaceDir: "/tmp",
config: {} as OpenClawConfig,
provider: "openai",
model: "gpt-test",
timeoutMs: 10_000,
blockReplyBreak: "text_end",
},
};
}
export function installQueueRuntimeErrorSilencer(): void {
let previousRuntimeError: typeof defaultRuntime.error;
beforeAll(() => {
previousRuntimeError = defaultRuntime.error;
defaultRuntime.error = (() => {}) as typeof defaultRuntime.error;
});
afterAll(() => {
defaultRuntime.error = previousRuntimeError;
});
}
+147 -226
View File
@@ -45,6 +45,14 @@ function createEnabledPluginConfig(pluginId: string): OpenClawConfig {
} as OpenClawConfig;
}
function createEmptyPluginConfig(): OpenClawConfig {
return {
plugins: {
entries: {},
},
} as OpenClawConfig;
}
function createClawHubInstalledConfig(params: {
pluginId: string;
install: Record<string, unknown>;
@@ -86,6 +94,134 @@ function createClawHubInstallResult(params: {
};
}
function createNpmPluginInstallResult(
pluginId = "demo",
): Awaited<ReturnType<typeof installPluginFromNpmSpec>> {
return {
ok: true,
pluginId,
targetDir: cliInstallPath(pluginId),
version: "1.2.3",
npmResolution: {
packageName: pluginId,
resolvedVersion: "1.2.3",
tarballUrl: `https://registry.npmjs.org/${pluginId}/-/${pluginId}-1.2.3.tgz`,
},
};
}
function mockClawHubPackageNotFound(packageName: string) {
installPluginFromClawHub.mockResolvedValue({
ok: false,
error: `ClawHub /api/v1/packages/${packageName} failed (404): Package not found`,
code: "package_not_found",
});
}
function primeNpmPluginFallback(pluginId = "demo") {
const cfg = createEmptyPluginConfig();
const enabledCfg = createEnabledPluginConfig(pluginId);
loadConfig.mockReturnValue(cfg);
mockClawHubPackageNotFound(pluginId);
installPluginFromNpmSpec.mockResolvedValue(createNpmPluginInstallResult(pluginId));
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
recordPluginInstall.mockReturnValue(enabledCfg);
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: [],
});
return { cfg, enabledCfg };
}
function createPathHookPackInstalledConfig(tmpRoot: string): OpenClawConfig {
return {
hooks: {
internal: {
installs: {
"demo-hooks": {
source: "path",
sourcePath: tmpRoot,
installPath: tmpRoot,
},
},
},
},
} as OpenClawConfig;
}
function createNpmHookPackInstalledConfig(): OpenClawConfig {
return {
hooks: {
internal: {
installs: {
"demo-hooks": {
source: "npm",
spec: "@acme/demo-hooks@1.2.3",
},
},
},
},
} as OpenClawConfig;
}
function createHookPackInstallResult(targetDir: string): {
ok: true;
hookPackId: string;
hooks: string[];
targetDir: string;
version: string;
} {
return {
ok: true,
hookPackId: "demo-hooks",
hooks: ["command-audit"],
targetDir,
version: "1.2.3",
};
}
function primeHookPackNpmFallback() {
const cfg = {} as OpenClawConfig;
const installedCfg = createNpmHookPackInstalledConfig();
loadConfig.mockReturnValue(cfg);
mockClawHubPackageNotFound("@acme/demo-hooks");
installPluginFromNpmSpec.mockResolvedValue({
ok: false,
error: "package.json missing openclaw.plugin.json",
});
installHooksFromNpmSpec.mockResolvedValue({
...createHookPackInstallResult("/tmp/hooks/demo-hooks"),
npmResolution: {
name: "@acme/demo-hooks",
spec: "@acme/demo-hooks@1.2.3",
integrity: "sha256-demo",
},
});
recordHookInstall.mockReturnValue(installedCfg);
return { cfg, installedCfg };
}
function primeHookPackPathFallback(params: {
tmpRoot: string;
pluginInstallError: string;
}): OpenClawConfig {
const installedCfg = createPathHookPackInstalledConfig(params.tmpRoot);
loadConfig.mockReturnValue({} as OpenClawConfig);
installPluginFromPath.mockResolvedValueOnce({
ok: false,
error: params.pluginInstallError,
});
installHooksFromPath.mockResolvedValueOnce(createHookPackInstallResult(params.tmpRoot));
recordHookInstall.mockReturnValue(installedCfg);
return installedCfg;
}
describe("plugins cli install", () => {
beforeEach(() => {
resetPluginsCliTestState();
@@ -380,44 +516,7 @@ describe("plugins cli install", () => {
});
it("falls back to npm when ClawHub does not have the package", async () => {
const cfg = {
plugins: {
entries: {},
},
} as OpenClawConfig;
const enabledCfg = {
plugins: {
entries: {
demo: {
enabled: true,
},
},
},
} as OpenClawConfig;
loadConfig.mockReturnValue(cfg);
installPluginFromClawHub.mockResolvedValue({
ok: false,
error: "ClawHub /api/v1/packages/demo failed (404): Package not found",
code: "package_not_found",
});
installPluginFromNpmSpec.mockResolvedValue({
ok: true,
pluginId: "demo",
targetDir: cliInstallPath("demo"),
version: "1.2.3",
npmResolution: {
packageName: "demo",
resolvedVersion: "1.2.3",
tarballUrl: "https://registry.npmjs.org/demo/-/demo-1.2.3.tgz",
},
});
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
recordPluginInstall.mockReturnValue(enabledCfg);
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: [],
});
primeNpmPluginFallback();
await runPluginsCommand(["plugins", "install", "demo"]);
@@ -455,36 +554,7 @@ describe("plugins cli install", () => {
});
it("passes dangerous force unsafe install to npm installs", async () => {
const cfg = {
plugins: {
entries: {},
},
} as OpenClawConfig;
const enabledCfg = createEnabledPluginConfig("demo");
loadConfig.mockReturnValue(cfg);
installPluginFromClawHub.mockResolvedValue({
ok: false,
error: "ClawHub /api/v1/packages/demo failed (404): Package not found",
code: "package_not_found",
});
installPluginFromNpmSpec.mockResolvedValue({
ok: true,
pluginId: "demo",
targetDir: cliInstallPath("demo"),
version: "1.2.3",
npmResolution: {
packageName: "demo",
resolvedVersion: "1.2.3",
tarballUrl: "https://registry.npmjs.org/demo/-/demo-1.2.3.tgz",
},
});
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
recordPluginInstall.mockReturnValue(enabledCfg);
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: [],
});
primeNpmPluginFallback();
await runPluginsCommand(["plugins", "install", "demo", "--dangerously-force-unsafe-install"]);
@@ -542,35 +612,11 @@ describe("plugins cli install", () => {
});
it("passes dangerous force unsafe install to linked hook-pack probe fallback", async () => {
const cfg = {} as OpenClawConfig;
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-hook-link-"));
const installedCfg = {
hooks: {
internal: {
installs: {
"demo-hooks": {
source: "path",
sourcePath: tmpRoot,
installPath: tmpRoot,
},
},
},
},
} as OpenClawConfig;
loadConfig.mockReturnValue(cfg);
installPluginFromPath.mockResolvedValueOnce({
ok: false,
error: "plugin install probe failed",
primeHookPackPathFallback({
tmpRoot,
pluginInstallError: "plugin install probe failed",
});
installHooksFromPath.mockResolvedValueOnce({
ok: true,
hookPackId: "demo-hooks",
hooks: ["command-audit"],
targetDir: tmpRoot,
version: "1.2.3",
});
recordHookInstall.mockReturnValue(installedCfg);
try {
await runPluginsCommand([
@@ -594,35 +640,11 @@ describe("plugins cli install", () => {
});
it("passes dangerous force unsafe install to local hook-pack fallback installs", async () => {
const cfg = {} as OpenClawConfig;
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-hook-install-"));
const installedCfg = {
hooks: {
internal: {
installs: {
"demo-hooks": {
source: "path",
sourcePath: tmpRoot,
installPath: tmpRoot,
},
},
},
},
} as OpenClawConfig;
loadConfig.mockReturnValue(cfg);
installPluginFromPath.mockResolvedValueOnce({
ok: false,
error: "plugin install failed",
primeHookPackPathFallback({
tmpRoot,
pluginInstallError: "plugin install failed",
});
installHooksFromPath.mockResolvedValueOnce({
ok: true,
hookPackId: "demo-hooks",
hooks: ["command-audit"],
targetDir: tmpRoot,
version: "1.2.3",
});
recordHookInstall.mockReturnValue(installedCfg);
try {
await runPluginsCommand([
@@ -644,36 +666,7 @@ describe("plugins cli install", () => {
);
});
it("passes force through as overwrite mode for npm installs", async () => {
const cfg = {
plugins: {
entries: {},
},
} as OpenClawConfig;
const enabledCfg = createEnabledPluginConfig("demo");
loadConfig.mockReturnValue(cfg);
installPluginFromClawHub.mockResolvedValue({
ok: false,
error: "ClawHub /api/v1/packages/demo failed (404): Package not found",
code: "package_not_found",
});
installPluginFromNpmSpec.mockResolvedValue({
ok: true,
pluginId: "demo",
targetDir: cliInstallPath("demo"),
version: "1.2.3",
npmResolution: {
packageName: "demo",
resolvedVersion: "1.2.3",
tarballUrl: "https://registry.npmjs.org/demo/-/demo-1.2.3.tgz",
},
});
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
recordPluginInstall.mockReturnValue(enabledCfg);
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: [],
});
primeNpmPluginFallback();
await runPluginsCommand(["plugins", "install", "demo", "--force"]);
@@ -699,43 +692,7 @@ describe("plugins cli install", () => {
});
it("falls back to installing hook packs from npm specs", async () => {
const cfg = {} as OpenClawConfig;
const installedCfg = {
hooks: {
internal: {
installs: {
"demo-hooks": {
source: "npm",
spec: "@acme/demo-hooks@1.2.3",
},
},
},
},
} as OpenClawConfig;
loadConfig.mockReturnValue(cfg);
installPluginFromClawHub.mockResolvedValue({
ok: false,
error: "ClawHub /api/v1/packages/@acme/demo-hooks failed (404): Package not found",
code: "package_not_found",
});
installPluginFromNpmSpec.mockResolvedValue({
ok: false,
error: "package.json missing openclaw.plugin.json",
});
installHooksFromNpmSpec.mockResolvedValue({
ok: true,
hookPackId: "demo-hooks",
hooks: ["command-audit"],
targetDir: "/tmp/hooks/demo-hooks",
version: "1.2.3",
npmResolution: {
name: "@acme/demo-hooks",
spec: "@acme/demo-hooks@1.2.3",
integrity: "sha256-demo",
},
});
recordHookInstall.mockReturnValue(installedCfg);
const { installedCfg } = primeHookPackNpmFallback();
await runPluginsCommand(["plugins", "install", "@acme/demo-hooks"]);
@@ -756,43 +713,7 @@ describe("plugins cli install", () => {
});
it("passes force through as overwrite mode for hook-pack npm fallback installs", async () => {
const cfg = {} as OpenClawConfig;
const installedCfg = {
hooks: {
internal: {
installs: {
"demo-hooks": {
source: "npm",
spec: "@acme/demo-hooks@1.2.3",
},
},
},
},
} as OpenClawConfig;
loadConfig.mockReturnValue(cfg);
installPluginFromClawHub.mockResolvedValue({
ok: false,
error: "ClawHub /api/v1/packages/@acme/demo-hooks failed (404): Package not found",
code: "package_not_found",
});
installPluginFromNpmSpec.mockResolvedValue({
ok: false,
error: "package.json missing openclaw.plugin.json",
});
installHooksFromNpmSpec.mockResolvedValue({
ok: true,
hookPackId: "demo-hooks",
hooks: ["command-audit"],
targetDir: "/tmp/hooks/demo-hooks",
version: "1.2.3",
npmResolution: {
name: "@acme/demo-hooks",
spec: "@acme/demo-hooks@1.2.3",
integrity: "sha256-demo",
},
});
recordHookInstall.mockReturnValue(installedCfg);
primeHookPackNpmFallback();
await runPluginsCommand(["plugins", "install", "@acme/demo-hooks", "--force"]);
@@ -3,8 +3,7 @@ import type { OpenClawConfig } from "../config/config.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { getChannelSetupWizardAdapter } from "./channel-setup/registry.js";
import type { ChannelSetupWizardAdapter } from "./channel-setup/types.js";
import { patchChannelSetupWizardAdapter } from "./channel-test-helpers.js";
import {
createChannelOnboardingPostWriteHookCollector,
runCollectedChannelOnboardingPostWriteHooks,
@@ -43,73 +42,6 @@ function setMinimalTelegramOnboardingRegistryForTests(): void {
);
}
type ChannelSetupWizardAdapterPatch = Partial<
Pick<
ChannelSetupWizardAdapter,
| "afterConfigWritten"
| "configure"
| "configureInteractive"
| "configureWhenConfigured"
| "getStatus"
>
>;
type PatchedSetupAdapterFields = {
afterConfigWritten?: ChannelSetupWizardAdapter["afterConfigWritten"];
configure?: ChannelSetupWizardAdapter["configure"];
configureInteractive?: ChannelSetupWizardAdapter["configureInteractive"];
configureWhenConfigured?: ChannelSetupWizardAdapter["configureWhenConfigured"];
getStatus?: ChannelSetupWizardAdapter["getStatus"];
};
function patchChannelOnboardingAdapterForTest(patch: ChannelSetupWizardAdapterPatch): () => void {
const adapter = getChannelSetupWizardAdapter("telegram");
if (!adapter) {
throw new Error("missing setup adapter for telegram");
}
const previous: PatchedSetupAdapterFields = {};
if (Object.prototype.hasOwnProperty.call(patch, "getStatus")) {
previous.getStatus = adapter.getStatus;
adapter.getStatus = patch.getStatus ?? adapter.getStatus;
}
if (Object.prototype.hasOwnProperty.call(patch, "afterConfigWritten")) {
previous.afterConfigWritten = adapter.afterConfigWritten;
adapter.afterConfigWritten = patch.afterConfigWritten;
}
if (Object.prototype.hasOwnProperty.call(patch, "configure")) {
previous.configure = adapter.configure;
adapter.configure = patch.configure ?? adapter.configure;
}
if (Object.prototype.hasOwnProperty.call(patch, "configureInteractive")) {
previous.configureInteractive = adapter.configureInteractive;
adapter.configureInteractive = patch.configureInteractive;
}
if (Object.prototype.hasOwnProperty.call(patch, "configureWhenConfigured")) {
previous.configureWhenConfigured = adapter.configureWhenConfigured;
adapter.configureWhenConfigured = patch.configureWhenConfigured;
}
return () => {
if (Object.prototype.hasOwnProperty.call(patch, "getStatus")) {
adapter.getStatus = previous.getStatus!;
}
if (Object.prototype.hasOwnProperty.call(patch, "afterConfigWritten")) {
adapter.afterConfigWritten = previous.afterConfigWritten;
}
if (Object.prototype.hasOwnProperty.call(patch, "configure")) {
adapter.configure = previous.configure!;
}
if (Object.prototype.hasOwnProperty.call(patch, "configureInteractive")) {
adapter.configureInteractive = previous.configureInteractive;
}
if (Object.prototype.hasOwnProperty.call(patch, "configureWhenConfigured")) {
adapter.configureWhenConfigured = previous.configureWhenConfigured;
}
};
}
function createPrompter(overrides: Partial<WizardPrompter>): WizardPrompter {
return createWizardPrompter(
{
@@ -159,7 +91,7 @@ describe("setupChannels post-write hooks", () => {
} as OpenClawConfig,
accountId: "acct-1",
}));
const restore = patchChannelOnboardingAdapterForTest({
const restore = patchChannelSetupWizardAdapter("telegram", {
configureInteractive,
afterConfigWritten,
getStatus: vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({
+57 -152
View File
@@ -6,158 +6,85 @@ import {
buildStatusOverviewSurfaceFromScan,
} from "./status-overview-surface.ts";
const baseCfg = { update: { channel: "stable" }, gateway: { bind: "loopback" } } as const;
const baseUpdate = { installKind: "git", git: { branch: "main", tag: "v1.2.3" } } as never;
const baseGatewaySnapshot = {
gatewayMode: "remote",
remoteUrlMissing: false,
gatewayConnection: {
url: "wss://gateway.example.com",
urlSource: "config",
message: "Gateway target: wss://gateway.example.com",
},
gatewayReachable: true,
gatewayProbe: { connectLatencyMs: 42, error: null } as never,
gatewayProbeAuth: { token: "tok" },
gatewayProbeAuthWarning: "warn-text",
gatewaySelf: { host: "gateway", version: "1.2.3" },
} as const;
const baseScanFields = {
cfg: baseCfg,
update: baseUpdate,
tailscaleMode: "serve",
tailscaleDns: "box.tail.ts.net",
tailscaleHttpsUrl: "https://box.tail.ts.net",
...baseGatewaySnapshot,
};
const baseGatewayService = {
label: "LaunchAgent",
installed: true,
managedByOpenClaw: true,
loadedText: "loaded",
runtimeShort: "running",
};
const baseNodeService = {
label: "node",
installed: true,
loadedText: "loaded",
runtime: { status: "running", pid: 42 },
};
const baseServices = {
gatewayService: baseGatewayService,
nodeService: baseNodeService,
nodeOnlyGateway: null,
};
const baseOverviewSurface = {
...baseScanFields,
...baseServices,
};
describe("status-overview-surface", () => {
it("builds the shared overview surface from a status scan result", () => {
expect(
buildStatusOverviewSurfaceFromScan({
scan: {
cfg: { update: { channel: "stable" }, gateway: { bind: "loopback" } },
update: { installKind: "git", git: { branch: "main", tag: "v1.2.3" } } as never,
tailscaleMode: "serve",
tailscaleDns: "box.tail.ts.net",
tailscaleHttpsUrl: "https://box.tail.ts.net",
gatewayMode: "remote",
remoteUrlMissing: false,
gatewayConnection: {
url: "wss://gateway.example.com",
urlSource: "config",
message: "Gateway target: wss://gateway.example.com",
},
gatewayReachable: true,
gatewayProbe: { connectLatencyMs: 42, error: null } as never,
gatewayProbeAuth: { token: "tok" },
gatewayProbeAuthWarning: "warn-text",
gatewaySelf: { host: "gateway", version: "1.2.3" },
},
gatewayService: {
label: "LaunchAgent",
installed: true,
managedByOpenClaw: true,
loadedText: "loaded",
runtimeShort: "running",
},
nodeService: {
label: "node",
installed: true,
loadedText: "loaded",
runtime: { status: "running", pid: 42 },
},
nodeOnlyGateway: null,
scan: baseScanFields,
...baseServices,
}),
).toEqual({
cfg: { update: { channel: "stable" }, gateway: { bind: "loopback" } },
update: { installKind: "git", git: { branch: "main", tag: "v1.2.3" } },
tailscaleMode: "serve",
tailscaleDns: "box.tail.ts.net",
tailscaleHttpsUrl: "https://box.tail.ts.net",
gatewayMode: "remote",
remoteUrlMissing: false,
gatewayConnection: {
url: "wss://gateway.example.com",
urlSource: "config",
message: "Gateway target: wss://gateway.example.com",
},
gatewayReachable: true,
gatewayProbe: { connectLatencyMs: 42, error: null } as never,
gatewayProbeAuth: { token: "tok" },
gatewayProbeAuthWarning: "warn-text",
gatewaySelf: { host: "gateway", version: "1.2.3" },
gatewayService: {
label: "LaunchAgent",
installed: true,
managedByOpenClaw: true,
loadedText: "loaded",
runtimeShort: "running",
},
nodeService: {
label: "node",
installed: true,
loadedText: "loaded",
runtime: { status: "running", pid: 42 },
},
nodeOnlyGateway: null,
});
).toEqual(baseOverviewSurface);
});
it("builds the shared overview surface from scan overview data", () => {
expect(
buildStatusOverviewSurfaceFromOverview({
overview: {
cfg: { update: { channel: "stable" }, gateway: { bind: "loopback" } },
update: { installKind: "git", git: { branch: "main", tag: "v1.2.3" } } as never,
cfg: baseCfg,
update: baseUpdate,
tailscaleMode: "serve",
tailscaleDns: "box.tail.ts.net",
tailscaleHttpsUrl: "https://box.tail.ts.net",
gatewaySnapshot: {
gatewayMode: "remote",
remoteUrlMissing: false,
gatewayConnection: {
url: "wss://gateway.example.com",
urlSource: "config",
message: "Gateway target: wss://gateway.example.com",
},
gatewayReachable: true,
gatewayProbe: { connectLatencyMs: 42, error: null } as never,
gatewayProbeAuth: { token: "tok" },
gatewayProbeAuthWarning: "warn-text",
gatewaySelf: { host: "gateway", version: "1.2.3" },
},
gatewaySnapshot: baseGatewaySnapshot,
} as never,
gatewayService: {
label: "LaunchAgent",
installed: true,
managedByOpenClaw: true,
loadedText: "loaded",
runtimeShort: "running",
},
nodeService: {
label: "node",
installed: true,
loadedText: "loaded",
runtime: { status: "running", pid: 42 },
},
nodeOnlyGateway: null,
...baseServices,
}),
).toEqual({
cfg: { update: { channel: "stable" }, gateway: { bind: "loopback" } },
update: { installKind: "git", git: { branch: "main", tag: "v1.2.3" } },
tailscaleMode: "serve",
tailscaleDns: "box.tail.ts.net",
tailscaleHttpsUrl: "https://box.tail.ts.net",
gatewayMode: "remote",
remoteUrlMissing: false,
gatewayConnection: {
url: "wss://gateway.example.com",
urlSource: "config",
message: "Gateway target: wss://gateway.example.com",
},
gatewayReachable: true,
gatewayProbe: { connectLatencyMs: 42, error: null } as never,
gatewayProbeAuth: { token: "tok" },
gatewayProbeAuthWarning: "warn-text",
gatewaySelf: { host: "gateway", version: "1.2.3" },
gatewayService: {
label: "LaunchAgent",
installed: true,
managedByOpenClaw: true,
loadedText: "loaded",
runtimeShort: "running",
},
nodeService: {
label: "node",
installed: true,
loadedText: "loaded",
runtime: { status: "running", pid: 42 },
},
nodeOnlyGateway: null,
});
).toEqual(baseOverviewSurface);
});
it("builds overview rows from the shared surface bundle", () => {
expect(
buildStatusOverviewRowsFromSurface({
surface: {
cfg: { update: { channel: "stable" }, gateway: { bind: "loopback" } },
...baseOverviewSurface,
cfg: baseCfg,
update: {
installKind: "git",
git: {
@@ -172,33 +99,11 @@ describe("status-overview-surface", () => {
registry: { latestVersion: "2026.4.9" },
} as never,
tailscaleMode: "off",
tailscaleDns: "box.tail.ts.net",
tailscaleHttpsUrl: null,
gatewayMode: "remote",
remoteUrlMissing: false,
gatewayConnection: {
url: "wss://gateway.example.com",
urlSource: "config",
},
gatewayReachable: true,
gatewayProbe: { connectLatencyMs: 42, error: null } as never,
gatewayProbeAuth: { token: "tok" },
gatewayProbeAuthWarning: "warn-text",
gatewaySelf: { host: "gateway", version: "1.2.3" },
gatewayService: {
label: "LaunchAgent",
installed: true,
managedByOpenClaw: true,
loadedText: "loaded",
runtimeShort: "running",
},
nodeService: {
label: "node",
installed: true,
loadedText: "loaded",
runtime: { status: "running", pid: 42 },
},
nodeOnlyGateway: null,
},
prefixRows: [{ Item: "OS", Value: "macOS · node 22" }],
suffixRows: [{ Item: "Secrets", Value: "none" }],
+91 -132
View File
@@ -4,111 +4,25 @@ import { buildColdStartStatusSummary } from "./status.scan.bootstrap-shared.ts";
describe("buildStatusScanResult", () => {
it("builds the full shared scan result shape", () => {
expect(
buildStatusScanResult({
cfg: { gateway: {} },
sourceConfig: { gateway: {} },
secretDiagnostics: ["diag"],
osSummary: {
platform: "linux",
arch: "x64",
release: "6.8.0",
label: "linux 6.8.0 (x64)",
},
tailscaleMode: "serve",
tailscaleDns: "box.tail.ts.net",
tailscaleHttpsUrl: "https://box.tail.ts.net",
update: {
root: "/tmp/openclaw",
installKind: "package",
packageManager: "npm",
},
gatewaySnapshot: {
gatewayConnection: {
url: "ws://127.0.0.1:18789",
urlSource: "config",
message: "Gateway target: ws://127.0.0.1:18789",
},
remoteUrlMissing: false,
gatewayMode: "local",
gatewayProbeAuth: { token: "tok" },
gatewayProbeAuthWarning: "warn",
gatewayProbe: {
ok: true,
url: "ws://127.0.0.1:18789",
connectLatencyMs: 42,
error: null,
close: null,
health: null,
status: null,
presence: null,
configSnapshot: null,
},
gatewayReachable: true,
gatewaySelf: { host: "gateway" },
},
channelIssues: [
{
channel: "discord",
accountId: "default",
kind: "runtime",
message: "warn",
},
],
agentStatus: {
defaultId: "main",
totalSessions: 0,
bootstrapPendingCount: 0,
agents: [
{
id: "main",
workspaceDir: null,
bootstrapPending: false,
sessionsPath: "/tmp/main.json",
sessionsCount: 0,
lastUpdatedAt: null,
lastActiveAgeMs: null,
},
],
},
channels: { rows: [], details: [] },
summary: buildColdStartStatusSummary(),
memory: { agentId: "main", backend: "builtin", provider: "sqlite" },
memoryPlugin: { enabled: true, slot: "memory-core" },
pluginCompatibility: [
{
pluginId: "legacy",
code: "legacy-before-agent-start",
severity: "warn",
message: "warn",
},
],
}),
).toEqual({
cfg: { gateway: {} },
sourceConfig: { gateway: {} },
secretDiagnostics: ["diag"],
osSummary: {
platform: "linux",
arch: "x64",
release: "6.8.0",
label: "linux 6.8.0 (x64)",
},
tailscaleMode: "serve",
tailscaleDns: "box.tail.ts.net",
tailscaleHttpsUrl: "https://box.tail.ts.net",
update: {
root: "/tmp/openclaw",
installKind: "package",
packageManager: "npm",
},
const osSummary = {
platform: "linux" as const,
arch: "x64",
release: "6.8.0",
label: "linux 6.8.0 (x64)",
};
const update = {
root: "/tmp/openclaw",
installKind: "package" as const,
packageManager: "npm" as const,
};
const gatewaySnapshot = {
gatewayConnection: {
url: "ws://127.0.0.1:18789",
urlSource: "config",
urlSource: "config" as const,
message: "Gateway target: ws://127.0.0.1:18789",
},
remoteUrlMissing: false,
gatewayMode: "local",
gatewayMode: "local" as const,
gatewayProbeAuth: { token: "tok" },
gatewayProbeAuthWarning: "warn",
gatewayProbe: {
@@ -124,42 +38,87 @@ describe("buildStatusScanResult", () => {
},
gatewayReachable: true,
gatewaySelf: { host: "gateway" },
channelIssues: [
{
channel: "discord",
accountId: "default",
kind: "runtime",
message: "warn",
},
],
agentStatus: {
defaultId: "main",
totalSessions: 0,
bootstrapPendingCount: 0,
agents: [
{
id: "main",
workspaceDir: null,
bootstrapPending: false,
sessionsPath: "/tmp/main.json",
sessionsCount: 0,
lastUpdatedAt: null,
lastActiveAgeMs: null,
},
],
};
const channelIssues = [
{
channel: "discord",
accountId: "default",
kind: "runtime" as const,
message: "warn",
},
channels: { rows: [], details: [] },
summary: buildColdStartStatusSummary(),
memory: { agentId: "main", backend: "builtin", provider: "sqlite" },
memoryPlugin: { enabled: true, slot: "memory-core" },
pluginCompatibility: [
];
const agentStatus = {
defaultId: "main",
totalSessions: 0,
bootstrapPendingCount: 0,
agents: [
{
pluginId: "legacy",
code: "legacy-before-agent-start",
severity: "warn",
message: "warn",
id: "main",
workspaceDir: null,
bootstrapPending: false,
sessionsPath: "/tmp/main.json",
sessionsCount: 0,
lastUpdatedAt: null,
lastActiveAgeMs: null,
},
],
};
const channels = { rows: [], details: [] };
const summary = buildColdStartStatusSummary();
const memory = { agentId: "main", backend: "builtin" as const, provider: "sqlite" };
const memoryPlugin = { enabled: true, slot: "memory-core" };
const pluginCompatibility = [
{
pluginId: "legacy",
code: "legacy-before-agent-start" as const,
severity: "warn" as const,
message: "warn",
},
];
expect(
buildStatusScanResult({
cfg: { gateway: {} },
sourceConfig: { gateway: {} },
secretDiagnostics: ["diag"],
osSummary,
tailscaleMode: "serve",
tailscaleDns: "box.tail.ts.net",
tailscaleHttpsUrl: "https://box.tail.ts.net",
update,
gatewaySnapshot,
channelIssues,
agentStatus,
channels,
summary,
memory,
memoryPlugin,
pluginCompatibility,
}),
).toEqual({
cfg: { gateway: {} },
sourceConfig: { gateway: {} },
secretDiagnostics: ["diag"],
osSummary,
tailscaleMode: "serve",
tailscaleDns: "box.tail.ts.net",
tailscaleHttpsUrl: "https://box.tail.ts.net",
update,
gatewayConnection: gatewaySnapshot.gatewayConnection,
remoteUrlMissing: gatewaySnapshot.remoteUrlMissing,
gatewayMode: gatewaySnapshot.gatewayMode,
gatewayProbeAuth: gatewaySnapshot.gatewayProbeAuth,
gatewayProbeAuthWarning: gatewaySnapshot.gatewayProbeAuthWarning,
gatewayProbe: gatewaySnapshot.gatewayProbe,
gatewayReachable: gatewaySnapshot.gatewayReachable,
gatewaySelf: gatewaySnapshot.gatewaySelf,
channelIssues,
agentStatus,
channels,
summary,
memory,
memoryPlugin,
pluginCompatibility,
});
});
});
+55 -78
View File
@@ -10,6 +10,53 @@ import {
resolveConfigAuditLogPath,
} from "./io.audit.js";
function createRenameAuditRecord(home: string) {
return finalizeConfigWriteAuditRecord({
base: createConfigWriteAuditRecordBase({
configPath: path.join(home, ".openclaw", "openclaw.json"),
env: {} as NodeJS.ProcessEnv,
existsBefore: true,
previousHash: "prev-hash",
nextHash: "next-hash",
previousBytes: 12,
nextBytes: 24,
previousMetadata: {
dev: "10",
ino: "11",
mode: 0o600,
nlink: 1,
uid: 501,
gid: 20,
},
changedPathCount: 1,
hasMetaBefore: true,
hasMetaAfter: true,
gatewayModeBefore: "local",
gatewayModeAfter: "local",
suspicious: [],
now: "2026-04-07T08:00:00.000Z",
}),
result: "rename",
nextMetadata: {
dev: "12",
ino: "13",
mode: 0o600,
nlink: 1,
uid: 501,
gid: 20,
},
});
}
function readAuditLog(home: string): unknown[] {
const auditPath = path.join(home, ".openclaw", "logs", "config-audit.jsonl");
return fs
.readFileSync(auditPath, "utf-8")
.trim()
.split("\n")
.map((line) => JSON.parse(line));
}
describe("config io audit helpers", () => {
const suiteRootTracker = createSuiteTempRootTracker({ prefix: "openclaw-config-audit-" });
@@ -149,41 +196,7 @@ describe("config io audit helpers", () => {
it("appends JSONL audit entries to the resolved audit path", async () => {
const home = await suiteRootTracker.make("append");
const record = finalizeConfigWriteAuditRecord({
base: createConfigWriteAuditRecordBase({
configPath: path.join(home, ".openclaw", "openclaw.json"),
env: {} as NodeJS.ProcessEnv,
existsBefore: true,
previousHash: "prev-hash",
nextHash: "next-hash",
previousBytes: 12,
nextBytes: 24,
previousMetadata: {
dev: "10",
ino: "11",
mode: 0o600,
nlink: 1,
uid: 501,
gid: 20,
},
changedPathCount: 1,
hasMetaBefore: true,
hasMetaAfter: true,
gatewayModeBefore: "local",
gatewayModeAfter: "local",
suspicious: [],
now: "2026-04-07T08:00:00.000Z",
}),
result: "rename",
nextMetadata: {
dev: "12",
ino: "13",
mode: 0o600,
nlink: 1,
uid: 501,
gid: 20,
},
});
const record = createRenameAuditRecord(home);
await appendConfigAuditRecord({
fs,
@@ -192,10 +205,9 @@ describe("config io audit helpers", () => {
record,
});
const auditPath = path.join(home, ".openclaw", "logs", "config-audit.jsonl");
const lines = fs.readFileSync(auditPath, "utf-8").trim().split("\n");
expect(lines).toHaveLength(1);
expect(JSON.parse(lines[0])).toMatchObject({
const records = readAuditLog(home);
expect(records).toHaveLength(1);
expect(records[0]).toMatchObject({
event: "config.write",
result: "rename",
nextHash: "next-hash",
@@ -204,41 +216,7 @@ describe("config io audit helpers", () => {
it("also accepts flattened audit record params from legacy call sites", async () => {
const home = await suiteRootTracker.make("append-flat");
const record = finalizeConfigWriteAuditRecord({
base: createConfigWriteAuditRecordBase({
configPath: path.join(home, ".openclaw", "openclaw.json"),
env: {} as NodeJS.ProcessEnv,
existsBefore: true,
previousHash: "prev-hash",
nextHash: "next-hash",
previousBytes: 12,
nextBytes: 24,
previousMetadata: {
dev: "10",
ino: "11",
mode: 0o600,
nlink: 1,
uid: 501,
gid: 20,
},
changedPathCount: 1,
hasMetaBefore: true,
hasMetaAfter: true,
gatewayModeBefore: "local",
gatewayModeAfter: "local",
suspicious: [],
now: "2026-04-07T08:00:00.000Z",
}),
result: "rename",
nextMetadata: {
dev: "12",
ino: "13",
mode: 0o600,
nlink: 1,
uid: 501,
gid: 20,
},
});
const record = createRenameAuditRecord(home);
await appendConfigAuditRecord({
fs,
@@ -247,10 +225,9 @@ describe("config io audit helpers", () => {
...record,
});
const auditPath = path.join(home, ".openclaw", "logs", "config-audit.jsonl");
const lines = fs.readFileSync(auditPath, "utf-8").trim().split("\n");
expect(lines).toHaveLength(1);
expect(JSON.parse(lines[0])).toMatchObject({
const records = readAuditLog(home);
expect(records).toHaveLength(1);
expect(records[0]).toMatchObject({
event: "config.write",
result: "rename",
nextHash: "next-hash",
+141 -221
View File
@@ -1,7 +1,11 @@
import crypto from "node:crypto";
import path from "node:path";
import { isRecord } from "../utils.js";
import { appendConfigAuditRecord, appendConfigAuditRecordSync } from "./io.audit.js";
import {
appendConfigAuditRecord,
appendConfigAuditRecordSync,
type ConfigObserveAuditRecord,
} from "./io.audit.js";
import { resolveStateDir } from "./paths.js";
export type ObserveRecoveryDeps = {
@@ -99,6 +103,86 @@ type ConfigHealthState = {
entries?: Record<string, ConfigHealthEntry>;
};
function createConfigObserveAuditRecord(params: {
ts: string;
configPath: string;
valid: boolean;
current: ConfigHealthFingerprint;
suspicious: string[];
lastKnownGood: ConfigHealthFingerprint | undefined;
backup: ConfigHealthFingerprint | null | undefined;
clobberedPath: string | null;
restoredFromBackup: boolean;
restoredBackupPath: string | null;
}): ConfigObserveAuditRecord {
return {
ts: params.ts,
source: "config-io",
event: "config.observe",
phase: "read",
configPath: params.configPath,
pid: process.pid,
ppid: process.ppid,
cwd: process.cwd(),
argv: process.argv.slice(0, 8),
execArgv: process.execArgv.slice(0, 8),
exists: true,
valid: params.valid,
hash: params.current.hash,
bytes: params.current.bytes,
mtimeMs: params.current.mtimeMs,
ctimeMs: params.current.ctimeMs,
dev: params.current.dev,
ino: params.current.ino,
mode: params.current.mode,
nlink: params.current.nlink,
uid: params.current.uid,
gid: params.current.gid,
hasMeta: params.current.hasMeta,
gatewayMode: params.current.gatewayMode,
suspicious: params.suspicious,
lastKnownGoodHash: params.lastKnownGood?.hash ?? null,
lastKnownGoodBytes: params.lastKnownGood?.bytes ?? null,
lastKnownGoodMtimeMs: params.lastKnownGood?.mtimeMs ?? null,
lastKnownGoodCtimeMs: params.lastKnownGood?.ctimeMs ?? null,
lastKnownGoodDev: params.lastKnownGood?.dev ?? null,
lastKnownGoodIno: params.lastKnownGood?.ino ?? null,
lastKnownGoodMode: params.lastKnownGood?.mode ?? null,
lastKnownGoodNlink: params.lastKnownGood?.nlink ?? null,
lastKnownGoodUid: params.lastKnownGood?.uid ?? null,
lastKnownGoodGid: params.lastKnownGood?.gid ?? null,
lastKnownGoodGatewayMode: params.lastKnownGood?.gatewayMode ?? null,
backupHash: params.backup?.hash ?? null,
backupBytes: params.backup?.bytes ?? null,
backupMtimeMs: params.backup?.mtimeMs ?? null,
backupCtimeMs: params.backup?.ctimeMs ?? null,
backupDev: params.backup?.dev ?? null,
backupIno: params.backup?.ino ?? null,
backupMode: params.backup?.mode ?? null,
backupNlink: params.backup?.nlink ?? null,
backupUid: params.backup?.uid ?? null,
backupGid: params.backup?.gid ?? null,
backupGatewayMode: params.backup?.gatewayMode ?? null,
clobberedPath: params.clobberedPath,
restoredFromBackup: params.restoredFromBackup,
restoredBackupPath: params.restoredBackupPath,
};
}
type ConfigObserveAuditRecordParams = Parameters<typeof createConfigObserveAuditRecord>[0];
function createConfigObserveAuditAppendParams(
deps: ObserveRecoveryDeps,
params: ConfigObserveAuditRecordParams,
) {
return {
fs: deps.fs,
env: deps.env,
homedir: deps.homedir,
record: createConfigObserveAuditRecord(params),
};
}
function hashConfigRaw(raw: string | null): string {
return crypto
.createHash("sha256")
@@ -462,61 +546,20 @@ export async function maybeRecoverSuspiciousConfigRead(params: {
params.deps.logger.warn(
`Config auto-restored from backup: ${params.configPath} (${suspicious.join(", ")})`,
);
await appendConfigAuditRecord({
fs: params.deps.fs,
env: params.deps.env,
homedir: params.deps.homedir,
ts: now,
source: "config-io",
event: "config.observe",
phase: "read",
configPath: params.configPath,
pid: process.pid,
ppid: process.ppid,
cwd: process.cwd(),
argv: process.argv.slice(0, 8),
execArgv: process.execArgv.slice(0, 8),
exists: true,
valid: true,
hash: current.hash,
bytes: current.bytes,
mtimeMs: current.mtimeMs,
ctimeMs: current.ctimeMs,
dev: current.dev,
ino: current.ino,
mode: current.mode,
nlink: current.nlink,
uid: current.uid,
gid: current.gid,
hasMeta: current.hasMeta,
gatewayMode: current.gatewayMode,
suspicious,
lastKnownGoodHash: entry.lastKnownGood?.hash ?? null,
lastKnownGoodBytes: entry.lastKnownGood?.bytes ?? null,
lastKnownGoodMtimeMs: entry.lastKnownGood?.mtimeMs ?? null,
lastKnownGoodCtimeMs: entry.lastKnownGood?.ctimeMs ?? null,
lastKnownGoodDev: entry.lastKnownGood?.dev ?? null,
lastKnownGoodIno: entry.lastKnownGood?.ino ?? null,
lastKnownGoodMode: entry.lastKnownGood?.mode ?? null,
lastKnownGoodNlink: entry.lastKnownGood?.nlink ?? null,
lastKnownGoodUid: entry.lastKnownGood?.uid ?? null,
lastKnownGoodGid: entry.lastKnownGood?.gid ?? null,
lastKnownGoodGatewayMode: entry.lastKnownGood?.gatewayMode ?? null,
backupHash: backup?.hash ?? null,
backupBytes: backup?.bytes ?? null,
backupMtimeMs: backup?.mtimeMs ?? null,
backupCtimeMs: backup?.ctimeMs ?? null,
backupDev: backup?.dev ?? null,
backupIno: backup?.ino ?? null,
backupMode: backup?.mode ?? null,
backupNlink: backup?.nlink ?? null,
backupUid: backup?.uid ?? null,
backupGid: backup?.gid ?? null,
backupGatewayMode: backup?.gatewayMode ?? null,
clobberedPath,
restoredFromBackup,
restoredBackupPath: backupPath,
});
await appendConfigAuditRecord(
createConfigObserveAuditAppendParams(params.deps, {
ts: now,
configPath: params.configPath,
valid: true,
current,
suspicious,
lastKnownGood: entry.lastKnownGood,
backup,
clobberedPath,
restoredFromBackup,
restoredBackupPath: backupPath,
}),
);
healthState = setConfigHealthEntry(healthState, params.configPath, {
...entry,
@@ -599,61 +642,20 @@ export function maybeRecoverSuspiciousConfigReadSync(params: {
params.deps.logger.warn(
`Config auto-restored from backup: ${params.configPath} (${suspicious.join(", ")})`,
);
appendConfigAuditRecordSync({
fs: params.deps.fs,
env: params.deps.env,
homedir: params.deps.homedir,
ts: now,
source: "config-io",
event: "config.observe",
phase: "read",
configPath: params.configPath,
pid: process.pid,
ppid: process.ppid,
cwd: process.cwd(),
argv: process.argv.slice(0, 8),
execArgv: process.execArgv.slice(0, 8),
exists: true,
valid: true,
hash: current.hash,
bytes: current.bytes,
mtimeMs: current.mtimeMs,
ctimeMs: current.ctimeMs,
dev: current.dev,
ino: current.ino,
mode: current.mode,
nlink: current.nlink,
uid: current.uid,
gid: current.gid,
hasMeta: current.hasMeta,
gatewayMode: current.gatewayMode,
suspicious,
lastKnownGoodHash: entry.lastKnownGood?.hash ?? null,
lastKnownGoodBytes: entry.lastKnownGood?.bytes ?? null,
lastKnownGoodMtimeMs: entry.lastKnownGood?.mtimeMs ?? null,
lastKnownGoodCtimeMs: entry.lastKnownGood?.ctimeMs ?? null,
lastKnownGoodDev: entry.lastKnownGood?.dev ?? null,
lastKnownGoodIno: entry.lastKnownGood?.ino ?? null,
lastKnownGoodMode: entry.lastKnownGood?.mode ?? null,
lastKnownGoodNlink: entry.lastKnownGood?.nlink ?? null,
lastKnownGoodUid: entry.lastKnownGood?.uid ?? null,
lastKnownGoodGid: entry.lastKnownGood?.gid ?? null,
lastKnownGoodGatewayMode: entry.lastKnownGood?.gatewayMode ?? null,
backupHash: backup?.hash ?? null,
backupBytes: backup?.bytes ?? null,
backupMtimeMs: backup?.mtimeMs ?? null,
backupCtimeMs: backup?.ctimeMs ?? null,
backupDev: backup?.dev ?? null,
backupIno: backup?.ino ?? null,
backupMode: backup?.mode ?? null,
backupNlink: backup?.nlink ?? null,
backupUid: backup?.uid ?? null,
backupGid: backup?.gid ?? null,
backupGatewayMode: backup?.gatewayMode ?? null,
clobberedPath,
restoredFromBackup,
restoredBackupPath: backupPath,
});
appendConfigAuditRecordSync(
createConfigObserveAuditAppendParams(params.deps, {
ts: now,
configPath: params.configPath,
valid: true,
current,
suspicious,
lastKnownGood: entry.lastKnownGood,
backup,
clobberedPath,
restoredFromBackup,
restoredBackupPath: backupPath,
}),
);
healthState = setConfigHealthEntry(healthState, params.configPath, {
...entry,
@@ -742,61 +744,20 @@ export async function observeConfigSnapshot(
});
deps.logger.warn(`Config observe anomaly: ${snapshot.path} (${suspicious.join(", ")})`);
await appendConfigAuditRecord({
fs: deps.fs,
env: deps.env,
homedir: deps.homedir,
ts: now,
source: "config-io",
event: "config.observe",
phase: "read",
configPath: snapshot.path,
pid: process.pid,
ppid: process.ppid,
cwd: process.cwd(),
argv: process.argv.slice(0, 8),
execArgv: process.execArgv.slice(0, 8),
exists: true,
valid: snapshot.valid,
hash: current.hash,
bytes: current.bytes,
mtimeMs: current.mtimeMs,
ctimeMs: current.ctimeMs,
dev: current.dev,
ino: current.ino,
mode: current.mode,
nlink: current.nlink,
uid: current.uid,
gid: current.gid,
hasMeta: current.hasMeta,
gatewayMode: current.gatewayMode,
suspicious,
lastKnownGoodHash: entry.lastKnownGood?.hash ?? null,
lastKnownGoodBytes: entry.lastKnownGood?.bytes ?? null,
lastKnownGoodMtimeMs: entry.lastKnownGood?.mtimeMs ?? null,
lastKnownGoodCtimeMs: entry.lastKnownGood?.ctimeMs ?? null,
lastKnownGoodDev: entry.lastKnownGood?.dev ?? null,
lastKnownGoodIno: entry.lastKnownGood?.ino ?? null,
lastKnownGoodMode: entry.lastKnownGood?.mode ?? null,
lastKnownGoodNlink: entry.lastKnownGood?.nlink ?? null,
lastKnownGoodUid: entry.lastKnownGood?.uid ?? null,
lastKnownGoodGid: entry.lastKnownGood?.gid ?? null,
lastKnownGoodGatewayMode: entry.lastKnownGood?.gatewayMode ?? null,
backupHash: backup?.hash ?? null,
backupBytes: backup?.bytes ?? null,
backupMtimeMs: backup?.mtimeMs ?? null,
backupCtimeMs: backup?.ctimeMs ?? null,
backupDev: backup?.dev ?? null,
backupIno: backup?.ino ?? null,
backupMode: backup?.mode ?? null,
backupNlink: backup?.nlink ?? null,
backupUid: backup?.uid ?? null,
backupGid: backup?.gid ?? null,
backupGatewayMode: backup?.gatewayMode ?? null,
clobberedPath,
restoredFromBackup: false,
restoredBackupPath: null,
});
await appendConfigAuditRecord(
createConfigObserveAuditAppendParams(deps, {
ts: now,
configPath: snapshot.path,
valid: snapshot.valid,
current,
suspicious,
lastKnownGood: entry.lastKnownGood,
backup,
clobberedPath,
restoredFromBackup: false,
restoredBackupPath: null,
}),
);
healthState = setConfigHealthEntry(healthState, snapshot.path, {
...entry,
@@ -867,61 +828,20 @@ export function observeConfigSnapshotSync(
});
deps.logger.warn(`Config observe anomaly: ${snapshot.path} (${suspicious.join(", ")})`);
appendConfigAuditRecordSync({
fs: deps.fs,
env: deps.env,
homedir: deps.homedir,
ts: now,
source: "config-io",
event: "config.observe",
phase: "read",
configPath: snapshot.path,
pid: process.pid,
ppid: process.ppid,
cwd: process.cwd(),
argv: process.argv.slice(0, 8),
execArgv: process.execArgv.slice(0, 8),
exists: true,
valid: snapshot.valid,
hash: current.hash,
bytes: current.bytes,
mtimeMs: current.mtimeMs,
ctimeMs: current.ctimeMs,
dev: current.dev,
ino: current.ino,
mode: current.mode,
nlink: current.nlink,
uid: current.uid,
gid: current.gid,
hasMeta: current.hasMeta,
gatewayMode: current.gatewayMode,
suspicious,
lastKnownGoodHash: entry.lastKnownGood?.hash ?? null,
lastKnownGoodBytes: entry.lastKnownGood?.bytes ?? null,
lastKnownGoodMtimeMs: entry.lastKnownGood?.mtimeMs ?? null,
lastKnownGoodCtimeMs: entry.lastKnownGood?.ctimeMs ?? null,
lastKnownGoodDev: entry.lastKnownGood?.dev ?? null,
lastKnownGoodIno: entry.lastKnownGood?.ino ?? null,
lastKnownGoodMode: entry.lastKnownGood?.mode ?? null,
lastKnownGoodNlink: entry.lastKnownGood?.nlink ?? null,
lastKnownGoodUid: entry.lastKnownGood?.uid ?? null,
lastKnownGoodGid: entry.lastKnownGood?.gid ?? null,
lastKnownGoodGatewayMode: entry.lastKnownGood?.gatewayMode ?? null,
backupHash: backup?.hash ?? null,
backupBytes: backup?.bytes ?? null,
backupMtimeMs: backup?.mtimeMs ?? null,
backupCtimeMs: backup?.ctimeMs ?? null,
backupDev: backup?.dev ?? null,
backupIno: backup?.ino ?? null,
backupMode: backup?.mode ?? null,
backupNlink: backup?.nlink ?? null,
backupUid: backup?.uid ?? null,
backupGid: backup?.gid ?? null,
backupGatewayMode: backup?.gatewayMode ?? null,
clobberedPath,
restoredFromBackup: false,
restoredBackupPath: null,
});
appendConfigAuditRecordSync(
createConfigObserveAuditAppendParams(deps, {
ts: now,
configPath: snapshot.path,
valid: snapshot.valid,
current,
suspicious,
lastKnownGood: entry.lastKnownGood,
backup,
clobberedPath,
restoredFromBackup: false,
restoredBackupPath: null,
}),
);
healthState = setConfigHealthEntry(healthState, snapshot.path, {
...entry,
@@ -1,69 +1,8 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { MsgContext } from "../../auto-reply/templating.js";
import type { ChannelPlugin } from "../../channels/plugins/types.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js";
import {
createChannelTestPluginBase,
createTestRegistry,
} from "../../test-utils/channel-plugins.js";
import { describe, expect, it } from "vitest";
import { normalizeExplicitSessionKey } from "./explicit-session-key-normalization.js";
import { installDiscordSessionKeyNormalizerFixture, makeCtx } from "./session-key.test-helpers.js";
function makeCtx(overrides: Partial<MsgContext>): MsgContext {
return {
Body: "",
From: "",
To: "",
...overrides,
} as MsgContext;
}
beforeEach(() => {
const discordPlugin: ChannelPlugin = {
...createChannelTestPluginBase({
id: "discord",
label: "Discord",
docsPath: "/channels/discord",
}),
messaging: {
normalizeExplicitSessionKey: ({ sessionKey, ctx }) => {
const normalizedChatType = ctx.ChatType?.trim().toLowerCase();
let normalized = sessionKey.trim().toLowerCase();
if (normalizedChatType !== "direct" && normalizedChatType !== "dm") {
return normalized;
}
normalized = normalized.replace(/^(discord:)dm:/, "$1direct:");
normalized = normalized.replace(/^(agent:[^:]+:discord:)dm:/, "$1direct:");
const match = normalized.match(/^((?:agent:[^:]+:)?)discord:channel:([^:]+)$/);
if (!match) {
return normalized;
}
const from = (ctx.From ?? "").trim().toLowerCase();
const senderId = (ctx.SenderId ?? "").trim().toLowerCase();
const fromDiscordId =
from.startsWith("discord:") && !from.includes(":channel:") && !from.includes(":group:")
? from.slice("discord:".length)
: "";
const directId = senderId || fromDiscordId;
return directId && directId === match[2]
? `${match[1]}discord:direct:${match[2]}`
: normalized;
},
},
};
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "discord",
plugin: discordPlugin,
source: "test",
},
]),
);
});
afterEach(() => {
resetPluginRuntimeStateForTest();
});
installDiscordSessionKeyNormalizerFixture();
describe("normalizeExplicitSessionKey", () => {
it("dispatches discord keys through the provider normalizer", () => {
@@ -0,0 +1,67 @@
import { afterEach, beforeEach } from "vitest";
import type { MsgContext } from "../../auto-reply/templating.js";
import type { ChannelPlugin } from "../../channels/plugins/types.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js";
import {
createChannelTestPluginBase,
createTestRegistry,
} from "../../test-utils/channel-plugins.js";
export function makeCtx(overrides: Partial<MsgContext>): MsgContext {
return {
Body: "",
From: "",
To: "",
...overrides,
} as MsgContext;
}
export function installDiscordSessionKeyNormalizerFixture(): void {
beforeEach(() => {
const discordPlugin: ChannelPlugin = {
...createChannelTestPluginBase({
id: "discord",
label: "Discord",
docsPath: "/channels/discord",
}),
messaging: {
normalizeExplicitSessionKey: ({ sessionKey, ctx }) => {
const normalizedChatType = ctx.ChatType?.trim().toLowerCase();
let normalized = sessionKey.trim().toLowerCase();
if (normalizedChatType !== "direct" && normalizedChatType !== "dm") {
return normalized;
}
normalized = normalized.replace(/^(discord:)dm:/, "$1direct:");
normalized = normalized.replace(/^(agent:[^:]+:discord:)dm:/, "$1direct:");
const match = normalized.match(/^((?:agent:[^:]+:)?)discord:channel:([^:]+)$/);
if (!match) {
return normalized;
}
const from = (ctx.From ?? "").trim().toLowerCase();
const senderId = (ctx.SenderId ?? "").trim().toLowerCase();
const fromDiscordId =
from.startsWith("discord:") && !from.includes(":channel:") && !from.includes(":group:")
? from.slice("discord:".length)
: "";
const directId = senderId || fromDiscordId;
return directId && directId === match[2]
? `${match[1]}discord:direct:${match[2]}`
: normalized;
},
},
};
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "discord",
plugin: discordPlugin,
source: "test",
},
]),
);
});
afterEach(() => {
resetPluginRuntimeStateForTest();
});
}
+3 -64
View File
@@ -1,69 +1,8 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { MsgContext } from "../../auto-reply/templating.js";
import type { ChannelPlugin } from "../../channels/plugins/types.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js";
import {
createChannelTestPluginBase,
createTestRegistry,
} from "../../test-utils/channel-plugins.js";
import { describe, expect, it } from "vitest";
import { resolveSessionKey } from "./session-key.js";
import { installDiscordSessionKeyNormalizerFixture, makeCtx } from "./session-key.test-helpers.js";
function makeCtx(overrides: Partial<MsgContext>): MsgContext {
return {
Body: "",
From: "",
To: "",
...overrides,
} as MsgContext;
}
beforeEach(() => {
const discordPlugin: ChannelPlugin = {
...createChannelTestPluginBase({
id: "discord",
label: "Discord",
docsPath: "/channels/discord",
}),
messaging: {
normalizeExplicitSessionKey: ({ sessionKey, ctx }) => {
const normalizedChatType = ctx.ChatType?.trim().toLowerCase();
let normalized = sessionKey.trim().toLowerCase();
if (normalizedChatType !== "direct" && normalizedChatType !== "dm") {
return normalized;
}
normalized = normalized.replace(/^(discord:)dm:/, "$1direct:");
normalized = normalized.replace(/^(agent:[^:]+:discord:)dm:/, "$1direct:");
const match = normalized.match(/^((?:agent:[^:]+:)?)discord:channel:([^:]+)$/);
if (!match) {
return normalized;
}
const from = (ctx.From ?? "").trim().toLowerCase();
const senderId = (ctx.SenderId ?? "").trim().toLowerCase();
const fromDiscordId =
from.startsWith("discord:") && !from.includes(":channel:") && !from.includes(":group:")
? from.slice("discord:".length)
: "";
const directId = senderId || fromDiscordId;
return directId && directId === match[2]
? `${match[1]}discord:direct:${match[2]}`
: normalized;
},
},
};
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "discord",
plugin: discordPlugin,
source: "test",
},
]),
);
});
afterEach(() => {
resetPluginRuntimeStateForTest();
});
installDiscordSessionKeyNormalizerFixture();
describe("resolveSessionKey", () => {
describe("Discord DM session key normalization", () => {
+58 -74
View File
@@ -10,49 +10,56 @@ const { logger, makeStorePath } = setupCronServiceSuite({
prefix: "cron-service-store-seam",
});
const STORE_TEST_NOW = Date.parse("2026-03-23T12:00:00.000Z");
async function writeSingleJobStore(storePath: string, job: Record<string, unknown>) {
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.writeFile(
storePath,
JSON.stringify(
{
version: 1,
jobs: [job],
},
null,
2,
),
"utf8",
);
}
function createStoreTestState(storePath: string) {
return createCronServiceState({
storePath,
cronEnabled: true,
log: logger,
nowMs: () => STORE_TEST_NOW,
enqueueSystemEvent: vi.fn(),
requestHeartbeatNow: vi.fn(),
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
});
}
describe("cron service store seam coverage", () => {
it("loads stored jobs, recomputes next runs, and does not rewrite the store on load", async () => {
const { storePath } = await makeStorePath();
const now = Date.parse("2026-03-23T12:00:00.000Z");
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.writeFile(
storePath,
JSON.stringify(
{
version: 1,
jobs: [
{
id: "modern-job",
name: "modern job",
enabled: true,
createdAtMs: now - 60_000,
updatedAtMs: now - 60_000,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "now",
payload: { kind: "agentTurn", message: "ping" },
delivery: { mode: "announce", channel: "telegram", to: "123" },
state: {},
},
],
},
null,
2,
),
"utf8",
);
const state = createCronServiceState({
storePath,
cronEnabled: true,
log: logger,
nowMs: () => now,
enqueueSystemEvent: vi.fn(),
requestHeartbeatNow: vi.fn(),
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
await writeSingleJobStore(storePath, {
id: "modern-job",
name: "modern job",
enabled: true,
createdAtMs: STORE_TEST_NOW - 60_000,
updatedAtMs: STORE_TEST_NOW - 60_000,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "now",
payload: { kind: "agentTurn", message: "ping" },
delivery: { mode: "announce", channel: "telegram", to: "123" },
state: {},
});
const state = createStoreTestState(storePath);
await ensureLoaded(state);
const job = state.store?.jobs[0];
@@ -67,7 +74,7 @@ describe("cron service store seam coverage", () => {
channel: "telegram",
to: "123",
});
expect(job?.state.nextRunAtMs).toBe(now);
expect(job?.state.nextRunAtMs).toBe(STORE_TEST_NOW);
const persisted = JSON.parse(await fs.readFile(storePath, "utf8")) as {
jobs: Array<Record<string, unknown>>;
@@ -93,45 +100,22 @@ describe("cron service store seam coverage", () => {
it("normalizes jobId-only jobs in memory so scheduler lookups resolve by stable id", async () => {
const { storePath } = await makeStorePath();
const now = Date.parse("2026-03-23T12:00:00.000Z");
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.writeFile(
storePath,
JSON.stringify(
{
version: 1,
jobs: [
{
jobId: "repro-stable-id",
name: "handed",
enabled: true,
createdAtMs: now - 60_000,
updatedAtMs: now - 60_000,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "now",
payload: { kind: "systemEvent", text: "tick" },
state: {},
},
],
},
null,
2,
),
"utf8",
);
const state = createCronServiceState({
storePath,
cronEnabled: true,
log: logger,
nowMs: () => now,
enqueueSystemEvent: vi.fn(),
requestHeartbeatNow: vi.fn(),
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
await writeSingleJobStore(storePath, {
jobId: "repro-stable-id",
name: "handed",
enabled: true,
createdAtMs: STORE_TEST_NOW - 60_000,
updatedAtMs: STORE_TEST_NOW - 60_000,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "now",
payload: { kind: "systemEvent", text: "tick" },
state: {},
});
const state = createStoreTestState(storePath);
await ensureLoaded(state);
expect(logger.warn).toHaveBeenCalledWith(
+1 -18
View File
@@ -6,11 +6,10 @@ import {
publicKeyRawBase64UrlFromPem,
signDevicePayload,
} from "../infra/device-identity.js";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js";
import { withEnvAsync } from "../test-utils/env.js";
import { buildDeviceAuthPayload } from "./device-auth.js";
import { validateTalkConfigResult } from "./protocol/index.js";
import { withSpeechProviders } from "./talk.test-helpers.js";
import {
connectOk,
createGatewaySuiteHarness,
@@ -137,22 +136,6 @@ async function withTalkConfigConnection<T>(
}
}
async function withSpeechProviders<T>(
speechProviders: NonNullable<ReturnType<typeof createEmptyPluginRegistry>["speechProviders"]>,
run: () => Promise<T>,
): Promise<T> {
const previousRegistry = getActivePluginRegistry() ?? createEmptyPluginRegistry();
setActivePluginRegistry({
...createEmptyPluginRegistry(),
speechProviders,
});
try {
return await run();
} finally {
setActivePluginRegistry(previousRegistry);
}
}
function expectTalkConfig(
talk: TalkConfig | undefined,
expected: {
+56 -121
View File
@@ -1,7 +1,9 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js";
import { talkHandlers } from "./server-methods/talk.js";
import {
invokeTalkSpeakDirect,
type TalkSpeakTestPayload,
withSpeechProviders,
} from "./talk.test-helpers.js";
const synthesizeSpeechMock = vi.hoisted(() =>
vi.fn<typeof import("../tts/tts.js").synthesizeSpeech>(async () => ({
@@ -18,49 +20,43 @@ vi.mock("../tts/tts.js", () => ({
synthesizeSpeech: synthesizeSpeechMock,
}));
type TalkSpeakPayload = {
audioBase64?: string;
provider?: string;
outputFormat?: string;
};
type SpeechProvider = Parameters<typeof withSpeechProviders>[0][number]["provider"];
const ALIAS_STUB_VOICE_ID = "VoiceAlias1234567890";
async function invokeTalkSpeakDirect(params: Record<string, unknown>) {
let response:
| {
ok: boolean;
payload?: unknown;
error?: { code?: string; message?: string; details?: unknown };
}
| undefined;
await talkHandlers["talk.speak"]({
req: { type: "req", id: "test", method: "talk.speak", params },
params,
client: null,
isWebchatConnect: () => false,
respond: (ok, payload, error) => {
response = { ok, payload, error };
async function writeAcmeTalkConfig() {
const { writeConfigFile } = await import("../config/config.js");
await writeConfigFile({
talk: {
provider: "acme",
providers: {
acme: {
voiceId: "plugin-voice",
},
},
},
context: {} as never,
});
return response;
}
async function withSpeechProviders<T>(
speechProviders: NonNullable<ReturnType<typeof createEmptyPluginRegistry>["speechProviders"]>,
run: () => Promise<T>,
): Promise<T> {
const previousRegistry = getActivePluginRegistry() ?? createEmptyPluginRegistry();
setActivePluginRegistry({
...createEmptyPluginRegistry(),
speechProviders,
});
try {
return await run();
} finally {
setActivePluginRegistry(previousRegistry);
}
async function withAcmeSpeechProvider(
synthesize: SpeechProvider["synthesize"],
run: () => Promise<void>,
) {
await withSpeechProviders(
[
{
pluginId: "acme-plugin",
source: "test",
provider: {
id: "acme",
label: "Acme Speech",
isConfigured: () => true,
synthesize,
},
},
],
run,
);
}
describe("gateway talk runtime", () => {
@@ -138,43 +134,22 @@ describe("gateway talk runtime", () => {
});
it("allows extension speech providers through talk.speak", async () => {
const { writeConfigFile } = await import("../config/config.js");
await writeConfigFile({
talk: {
provider: "acme",
providers: {
acme: {
voiceId: "plugin-voice",
},
},
},
});
await writeAcmeTalkConfig();
await withSpeechProviders(
[
{
pluginId: "acme-plugin",
source: "test",
provider: {
id: "acme",
label: "Acme Speech",
isConfigured: () => true,
synthesize: async () => ({
audioBuffer: Buffer.from([7, 8, 9]),
outputFormat: "mp3",
fileExtension: ".mp3",
voiceCompatible: false,
}),
},
},
],
await withAcmeSpeechProvider(
async () => ({
audioBuffer: Buffer.from([7, 8, 9]),
outputFormat: "mp3",
fileExtension: ".mp3",
voiceCompatible: false,
}),
async () => {
const res = await invokeTalkSpeakDirect({
text: "Hello from talk mode.",
});
expect(res?.ok, JSON.stringify(res?.error)).toBe(true);
expect((res?.payload as TalkSpeakPayload | undefined)?.provider).toBe("acme");
expect((res?.payload as TalkSpeakPayload | undefined)?.audioBase64).toBe(
expect((res?.payload as TalkSpeakTestPayload | undefined)?.provider).toBe("acme");
expect((res?.payload as TalkSpeakTestPayload | undefined)?.audioBase64).toBe(
Buffer.from([7, 8, 9]).toString("base64"),
);
},
@@ -241,9 +216,11 @@ describe("gateway talk runtime", () => {
});
expect(res?.ok, JSON.stringify(res?.error)).toBe(true);
expect((res?.payload as TalkSpeakPayload | undefined)?.provider).toBe("elevenlabs");
expect((res?.payload as TalkSpeakPayload | undefined)?.outputFormat).toBe("pcm_44100");
expect((res?.payload as TalkSpeakPayload | undefined)?.audioBase64).toBe(
expect((res?.payload as TalkSpeakTestPayload | undefined)?.provider).toBe("elevenlabs");
expect((res?.payload as TalkSpeakTestPayload | undefined)?.outputFormat).toBe(
"pcm_44100",
);
expect((res?.payload as TalkSpeakTestPayload | undefined)?.audioBase64).toBe(
Buffer.from([4, 5, 6]).toString("base64"),
);
expect(synthesizeSpeechMock).toHaveBeenCalledWith(
@@ -280,31 +257,10 @@ describe("gateway talk runtime", () => {
});
it("returns synthesis_failed details when the provider rejects synthesis", async () => {
const { writeConfigFile } = await import("../config/config.js");
await writeConfigFile({
talk: {
provider: "acme",
providers: {
acme: {
voiceId: "plugin-voice",
},
},
},
});
await writeAcmeTalkConfig();
await withSpeechProviders(
[
{
pluginId: "acme-plugin",
source: "test",
provider: {
id: "acme",
label: "Acme Speech",
isConfigured: () => true,
synthesize: async () => ({}) as never,
},
},
],
await withAcmeSpeechProvider(
async () => ({}) as never,
async () => {
synthesizeSpeechMock.mockResolvedValue({
success: false,
@@ -321,31 +277,10 @@ describe("gateway talk runtime", () => {
});
it("rejects empty audio results as invalid_audio_result", async () => {
const { writeConfigFile } = await import("../config/config.js");
await writeConfigFile({
talk: {
provider: "acme",
providers: {
acme: {
voiceId: "plugin-voice",
},
},
},
});
await writeAcmeTalkConfig();
await withSpeechProviders(
[
{
pluginId: "acme-plugin",
source: "test",
provider: {
id: "acme",
label: "Acme Speech",
isConfigured: () => true,
synthesize: async () => ({}) as never,
},
},
],
await withAcmeSpeechProvider(
async () => ({}) as never,
async () => {
synthesizeSpeechMock.mockResolvedValue({
success: true,
+48
View File
@@ -0,0 +1,48 @@
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js";
export type TalkSpeakTestPayload = {
audioBase64?: string;
provider?: string;
outputFormat?: string;
mimeType?: string;
fileExtension?: string;
};
export async function invokeTalkSpeakDirect(params: Record<string, unknown>) {
const { talkHandlers } = await import("./server-methods/talk.js");
let response:
| {
ok: boolean;
payload?: unknown;
error?: { code?: string; message?: string; details?: unknown };
}
| undefined;
await talkHandlers["talk.speak"]({
req: { type: "req", id: "test", method: "talk.speak", params },
params,
client: null,
isWebchatConnect: () => false,
respond: (ok, payload, error) => {
response = { ok, payload, error };
},
context: {} as never,
});
return response;
}
export async function withSpeechProviders<T>(
speechProviders: NonNullable<ReturnType<typeof createEmptyPluginRegistry>["speechProviders"]>,
run: () => Promise<T>,
): Promise<T> {
const previousRegistry = getActivePluginRegistry() ?? createEmptyPluginRegistry();
setActivePluginRegistry({
...createEmptyPluginRegistry(),
speechProviders,
});
try {
return await run();
} finally {
setActivePluginRegistry(previousRegistry);
}
}
+13 -75
View File
@@ -1,7 +1,15 @@
import type { AuthProfileStore } from "../agents/auth-profiles.js";
import type { OpenClawConfig } from "../config/config.js";
import {
parseLiveCsvFilter,
parseProviderModelMap,
redactLiveApiKey,
resolveConfiguredLiveProviderModels,
resolveLiveAuthStore,
} from "../media-generation/live-test-helpers.js";
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
export { parseProviderModelMap, redactLiveApiKey };
export const DEFAULT_LIVE_IMAGE_MODELS: Record<string, string> = {
fal: "fal/fal-ai/flux/dev",
google: "google/gemini-3.1-flash-image-preview",
@@ -22,87 +30,17 @@ export function parseCaseFilter(raw?: string): Set<string> | null {
return values.length > 0 ? new Set(values) : null;
}
export function redactLiveApiKey(value: string | undefined): string {
const trimmed = value?.trim();
if (!trimmed) {
return "none";
}
if (trimmed.length <= 12) {
return trimmed;
}
return `${trimmed.slice(0, 8)}...${trimmed.slice(-4)}`;
}
export function parseCsvFilter(raw?: string): Set<string> | null {
const trimmed = raw?.trim();
if (!trimmed || trimmed === "all") {
return null;
}
const values = trimmed
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
return values.length > 0 ? new Set(values) : null;
}
export function parseProviderModelMap(raw?: string): Map<string, string> {
const entries = new Map<string, string>();
for (const token of raw?.split(",") ?? []) {
const trimmed = token.trim();
if (!trimmed) {
continue;
}
const slash = trimmed.indexOf("/");
if (slash <= 0 || slash === trimmed.length - 1) {
continue;
}
const providerId = normalizeOptionalLowercaseString(trimmed.slice(0, slash));
if (!providerId) {
continue;
}
entries.set(providerId, trimmed);
}
return entries;
return parseLiveCsvFilter(raw, { lowercase: false });
}
export function resolveConfiguredLiveImageModels(cfg: OpenClawConfig): Map<string, string> {
const resolved = new Map<string, string>();
const configured = cfg.agents?.defaults?.imageGenerationModel;
const add = (value: string | undefined) => {
const trimmed = value?.trim();
if (!trimmed) {
return;
}
const slash = trimmed.indexOf("/");
if (slash <= 0 || slash === trimmed.length - 1) {
return;
}
const providerId = normalizeOptionalLowercaseString(trimmed.slice(0, slash));
if (!providerId) {
return;
}
resolved.set(providerId, trimmed);
};
if (typeof configured === "string") {
add(configured);
return resolved;
}
add(configured?.primary);
for (const fallback of configured?.fallbacks ?? []) {
add(fallback);
}
return resolved;
return resolveConfiguredLiveProviderModels(cfg.agents?.defaults?.imageGenerationModel);
}
export function resolveLiveImageAuthStore(params: {
requireProfileKeys: boolean;
hasLiveKeys: boolean;
}): AuthProfileStore | undefined {
if (params.requireProfileKeys || !params.hasLiveKeys) {
return undefined;
}
return {
version: 1,
profiles: {},
};
}) {
return resolveLiveAuthStore(params);
}
+2 -18
View File
@@ -1,23 +1,7 @@
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { parseGenerationModelRef } from "../media-generation/model-ref.js";
export function parseImageGenerationModelRef(
raw: string | undefined,
): { provider: string; model: string } | null {
const trimmed = normalizeOptionalString(raw);
if (!trimmed) {
return null;
}
const slashIndex = trimmed.indexOf("/");
if (slashIndex <= 0 || slashIndex === trimmed.length - 1) {
return null;
}
const provider = normalizeOptionalString(trimmed.slice(0, slashIndex));
const model = normalizeOptionalString(trimmed.slice(slashIndex + 1));
if (!provider || !model) {
return null;
}
return {
provider,
model,
};
return parseGenerationModelRef(raw);
}
@@ -1,35 +1,15 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeAll, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { createEmptyPluginRegistry } from "../plugins/registry.js";
const mocks = vi.hoisted(() => ({
resolveRuntimePluginRegistry: vi.fn<
(params?: unknown) => ReturnType<typeof createEmptyPluginRegistry> | undefined
>(() => undefined),
loadPluginManifestRegistry: vi.fn(() => ({ plugins: [], diagnostics: [] })),
withBundledPluginEnablementCompat: vi.fn(({ config }) => config),
withBundledPluginVitestCompat: vi.fn(({ config }) => config),
}));
vi.mock("../plugins/loader.js", () => ({
resolveRuntimePluginRegistry: mocks.resolveRuntimePluginRegistry,
}));
vi.mock("../plugins/manifest-registry.js", () => ({
loadPluginManifestRegistry: mocks.loadPluginManifestRegistry,
}));
vi.mock("../plugins/bundled-compat.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../plugins/bundled-compat.js")>();
return {
...actual,
withBundledPluginEnablementCompat: mocks.withBundledPluginEnablementCompat,
withBundledPluginVitestCompat: mocks.withBundledPluginVitestCompat,
};
});
import {
createEmptyProviderRegistryAllowlistFallbackRegistry,
getProviderRegistryAllowlistMocks,
installProviderRegistryAllowlistMockDefaults,
} from "../test-utils/provider-registry-allowlist.test-helpers.js";
let getImageGenerationProvider: typeof import("./provider-registry.js").getImageGenerationProvider;
let listImageGenerationProviders: typeof import("./provider-registry.js").listImageGenerationProviders;
const mocks = getProviderRegistryAllowlistMocks();
installProviderRegistryAllowlistMockDefaults();
describe("image-generation provider registry allowlist fallback", () => {
beforeAll(async () => {
@@ -37,17 +17,6 @@ describe("image-generation provider registry allowlist fallback", () => {
await import("./provider-registry.js"));
});
beforeEach(() => {
mocks.resolveRuntimePluginRegistry.mockReset();
mocks.resolveRuntimePluginRegistry.mockReturnValue(undefined);
mocks.loadPluginManifestRegistry.mockReset();
mocks.loadPluginManifestRegistry.mockReturnValue({ plugins: [], diagnostics: [] });
mocks.withBundledPluginEnablementCompat.mockReset();
mocks.withBundledPluginEnablementCompat.mockImplementation(({ config }) => config);
mocks.withBundledPluginVitestCompat.mockReset();
mocks.withBundledPluginVitestCompat.mockImplementation(({ config }) => config);
});
it("adds bundled capability plugin ids to plugins.allow before fallback registry load", () => {
const cfg = { plugins: { allow: ["custom-plugin"] } } as OpenClawConfig;
const compatConfig = {
@@ -69,7 +38,9 @@ describe("image-generation provider registry allowlist fallback", () => {
});
mocks.withBundledPluginEnablementCompat.mockReturnValue(compatConfig);
mocks.withBundledPluginVitestCompat.mockReturnValue(compatConfig);
mocks.resolveRuntimePluginRegistry.mockImplementation(() => createEmptyPluginRegistry());
mocks.resolveRuntimePluginRegistry.mockImplementation(() =>
createEmptyProviderRegistryAllowlistFallbackRegistry(),
);
expect(listImageGenerationProviders(cfg)).toEqual([]);
expect(getImageGenerationProvider("openai", cfg)).toBeUndefined();
+7 -17
View File
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resetGenerationRuntimeMocks } from "../../test/helpers/media-generation/runtime-test-mocks.js";
import type { OpenClawConfig } from "../config/config.js";
import { generateImage, listRuntimeImageGenerationProviders } from "./runtime.js";
import type { ImageGenerationProvider } from "./types.js";
@@ -68,23 +69,12 @@ vi.mock("./provider-registry.js", () => ({
describe("image-generation runtime", () => {
beforeEach(() => {
mocks.createSubsystemLogger.mockClear();
mocks.describeFailoverError.mockReset();
mocks.getImageGenerationProvider.mockReset();
mocks.getProviderEnvVars.mockReset();
mocks.getProviderEnvVars.mockReturnValue([]);
mocks.resolveProviderAuthEnvVarCandidates.mockReset();
mocks.resolveProviderAuthEnvVarCandidates.mockReturnValue({});
mocks.isFailoverError.mockReset();
mocks.isFailoverError.mockReturnValue(false);
mocks.listImageGenerationProviders.mockReset();
mocks.listImageGenerationProviders.mockReturnValue([]);
mocks.parseImageGenerationModelRef.mockClear();
mocks.resolveAgentModelFallbackValues.mockReset();
mocks.resolveAgentModelFallbackValues.mockReturnValue([]);
mocks.resolveAgentModelPrimaryValue.mockReset();
mocks.resolveAgentModelPrimaryValue.mockReturnValue(undefined);
mocks.debug.mockReset();
resetGenerationRuntimeMocks({
...mocks,
getProvider: mocks.getImageGenerationProvider,
listProviders: mocks.listImageGenerationProviders,
parseModelRef: mocks.parseImageGenerationModelRef,
});
});
it("generates images through the active image-generation provider", async () => {
+5 -29
View File
@@ -5,8 +5,8 @@ import type { OpenClawConfig } from "../config/config.js";
import { formatErrorMessage } from "../infra/errors.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import {
buildMediaGenerationNormalizationMetadata,
buildNoCapabilityModelConfiguredMessage,
deriveAspectRatioFromSize,
resolveCapabilityModelCandidates,
throwCapabilityGenerationFailure,
} from "../media-generation/runtime-shared.js";
@@ -122,34 +122,10 @@ export async function generateImage(
normalization: sanitized.normalization,
metadata: {
...result.metadata,
...(sanitized.normalization?.size?.requested !== undefined &&
sanitized.normalization.size.applied !== undefined
? {
requestedSize: sanitized.normalization.size.requested,
normalizedSize: sanitized.normalization.size.applied,
}
: {}),
...(sanitized.normalization?.aspectRatio?.applied !== undefined
? {
...(sanitized.normalization.aspectRatio.requested !== undefined
? { requestedAspectRatio: sanitized.normalization.aspectRatio.requested }
: {}),
normalizedAspectRatio: sanitized.normalization.aspectRatio.applied,
...(sanitized.normalization.aspectRatio.derivedFrom === "size" && params.size
? {
requestedSize: params.size,
aspectRatioDerivedFromSize: deriveAspectRatioFromSize(params.size),
}
: {}),
}
: {}),
...(sanitized.normalization?.resolution?.requested !== undefined &&
sanitized.normalization.resolution.applied !== undefined
? {
requestedResolution: sanitized.normalization.resolution.requested,
normalizedResolution: sanitized.normalization.resolution.applied,
}
: {}),
...buildMediaGenerationNormalizationMetadata({
normalization: sanitized.normalization,
requestedSizeForDerivedAspectRatio: params.size,
}),
},
ignoredOverrides: sanitized.ignoredOverrides,
};
+60 -205
View File
@@ -28,6 +28,52 @@ describe("startChannelApprovalHandlerBootstrap", () => {
await Promise.resolve();
};
const createApprovalPlugin = () =>
({
id: "slack",
meta: { label: "Slack" },
approvalCapability: {
nativeRuntime: {
availability: {
isConfigured: vi.fn().mockReturnValue(true),
shouldHandle: vi.fn().mockReturnValue(true),
},
presentation: {
buildPendingPayload: vi.fn(),
buildResolvedResult: vi.fn(),
buildExpiredResult: vi.fn(),
},
transport: {
prepareTarget: vi.fn(),
deliverPending: vi.fn(),
},
},
},
}) as never;
const startTestBootstrap = (params: {
channelRuntime: ReturnType<typeof createRuntimeChannel>;
logger?: unknown;
}) =>
startChannelApprovalHandlerBootstrap({
plugin: createApprovalPlugin(),
cfg: {} as never,
accountId: "default",
channelRuntime: params.channelRuntime,
logger: params.logger as never,
});
const registerApprovalContext = (
channelRuntime: ReturnType<typeof createRuntimeChannel>,
app: unknown = { ok: true },
) =>
channelRuntime.runtimeContexts.register({
channelId: "slack",
accountId: "default",
capability: "approval.native",
context: { app },
});
it("starts and stops the shared approval handler from runtime context registration", async () => {
const channelRuntime = createRuntimeChannel();
const start = vi.fn().mockResolvedValue(undefined);
@@ -37,39 +83,9 @@ describe("startChannelApprovalHandlerBootstrap", () => {
stop,
});
const cleanup = await startChannelApprovalHandlerBootstrap({
plugin: {
id: "slack",
meta: { label: "Slack" },
approvalCapability: {
nativeRuntime: {
availability: {
isConfigured: vi.fn().mockReturnValue(true),
shouldHandle: vi.fn().mockReturnValue(true),
},
presentation: {
buildPendingPayload: vi.fn(),
buildResolvedResult: vi.fn(),
buildExpiredResult: vi.fn(),
},
transport: {
prepareTarget: vi.fn(),
deliverPending: vi.fn(),
},
},
},
} as never,
cfg: {} as never,
accountId: "default",
channelRuntime,
});
const cleanup = await startTestBootstrap({ channelRuntime });
const lease = channelRuntime.runtimeContexts.register({
channelId: "slack",
accountId: "default",
capability: "approval.native",
context: { app: { ok: true } },
});
const lease = registerApprovalContext(channelRuntime);
await flushTransitions();
expect(createChannelApprovalHandlerFromCapability).toHaveBeenCalled();
@@ -92,39 +108,9 @@ describe("startChannelApprovalHandlerBootstrap", () => {
stop,
});
const lease = channelRuntime.runtimeContexts.register({
channelId: "slack",
accountId: "default",
capability: "approval.native",
context: { app: { ok: true } },
});
const lease = registerApprovalContext(channelRuntime);
const cleanup = await startChannelApprovalHandlerBootstrap({
plugin: {
id: "slack",
meta: { label: "Slack" },
approvalCapability: {
nativeRuntime: {
availability: {
isConfigured: vi.fn().mockReturnValue(true),
shouldHandle: vi.fn().mockReturnValue(true),
},
presentation: {
buildPendingPayload: vi.fn(),
buildResolvedResult: vi.fn(),
buildExpiredResult: vi.fn(),
},
transport: {
prepareTarget: vi.fn(),
deliverPending: vi.fn(),
},
},
},
} as never,
cfg: {} as never,
accountId: "default",
channelRuntime,
});
const cleanup = await startTestBootstrap({ channelRuntime });
expect(createChannelApprovalHandlerFromCapability).toHaveBeenCalledTimes(1);
expect(start).toHaveBeenCalledTimes(1);
@@ -147,39 +133,9 @@ describe("startChannelApprovalHandlerBootstrap", () => {
});
createChannelApprovalHandlerFromCapability.mockReturnValue(runtimePromise);
const cleanup = await startChannelApprovalHandlerBootstrap({
plugin: {
id: "slack",
meta: { label: "Slack" },
approvalCapability: {
nativeRuntime: {
availability: {
isConfigured: vi.fn().mockReturnValue(true),
shouldHandle: vi.fn().mockReturnValue(true),
},
presentation: {
buildPendingPayload: vi.fn(),
buildResolvedResult: vi.fn(),
buildExpiredResult: vi.fn(),
},
transport: {
prepareTarget: vi.fn(),
deliverPending: vi.fn(),
},
},
},
} as never,
cfg: {} as never,
accountId: "default",
channelRuntime,
});
const cleanup = await startTestBootstrap({ channelRuntime });
const lease = channelRuntime.runtimeContexts.register({
channelId: "slack",
accountId: "default",
capability: "approval.native",
context: { app: { ok: true } },
});
const lease = registerApprovalContext(channelRuntime);
await flushTransitions();
const start = vi.fn().mockResolvedValue(undefined);
@@ -211,47 +167,12 @@ describe("startChannelApprovalHandlerBootstrap", () => {
stop: stopSecond,
});
const cleanup = await startChannelApprovalHandlerBootstrap({
plugin: {
id: "slack",
meta: { label: "Slack" },
approvalCapability: {
nativeRuntime: {
availability: {
isConfigured: vi.fn().mockReturnValue(true),
shouldHandle: vi.fn().mockReturnValue(true),
},
presentation: {
buildPendingPayload: vi.fn(),
buildResolvedResult: vi.fn(),
buildExpiredResult: vi.fn(),
},
transport: {
prepareTarget: vi.fn(),
deliverPending: vi.fn(),
},
},
},
} as never,
cfg: {} as never,
accountId: "default",
channelRuntime,
});
const cleanup = await startTestBootstrap({ channelRuntime });
const firstLease = channelRuntime.runtimeContexts.register({
channelId: "slack",
accountId: "default",
capability: "approval.native",
context: { app: { ok: "first" } },
});
const firstLease = registerApprovalContext(channelRuntime, { ok: "first" });
await flushTransitions();
const secondLease = channelRuntime.runtimeContexts.register({
channelId: "slack",
accountId: "default",
capability: "approval.native",
context: { app: { ok: "second" } },
});
const secondLease = registerApprovalContext(channelRuntime, { ok: "second" });
await flushTransitions();
expect(createChannelApprovalHandlerFromCapability).toHaveBeenCalledTimes(2);
@@ -287,40 +208,9 @@ describe("startChannelApprovalHandlerBootstrap", () => {
.mockResolvedValueOnce({ start, stop })
.mockResolvedValueOnce({ start, stop });
const cleanup = await startChannelApprovalHandlerBootstrap({
plugin: {
id: "slack",
meta: { label: "Slack" },
approvalCapability: {
nativeRuntime: {
availability: {
isConfigured: vi.fn().mockReturnValue(true),
shouldHandle: vi.fn().mockReturnValue(true),
},
presentation: {
buildPendingPayload: vi.fn(),
buildResolvedResult: vi.fn(),
buildExpiredResult: vi.fn(),
},
transport: {
prepareTarget: vi.fn(),
deliverPending: vi.fn(),
},
},
},
} as never,
cfg: {} as never,
accountId: "default",
channelRuntime,
logger: logger as never,
});
const cleanup = await startTestBootstrap({ channelRuntime, logger });
channelRuntime.runtimeContexts.register({
channelId: "slack",
accountId: "default",
capability: "approval.native",
context: { app: { ok: true } },
});
registerApprovalContext(channelRuntime);
await flushTransitions();
expect(start).toHaveBeenCalledTimes(1);
@@ -349,48 +239,13 @@ describe("startChannelApprovalHandlerBootstrap", () => {
.mockResolvedValueOnce({ start: secondStart, stop: secondStop })
.mockResolvedValueOnce({ start: secondStart, stop: secondStop });
const cleanup = await startChannelApprovalHandlerBootstrap({
plugin: {
id: "slack",
meta: { label: "Slack" },
approvalCapability: {
nativeRuntime: {
availability: {
isConfigured: vi.fn().mockReturnValue(true),
shouldHandle: vi.fn().mockReturnValue(true),
},
presentation: {
buildPendingPayload: vi.fn(),
buildResolvedResult: vi.fn(),
buildExpiredResult: vi.fn(),
},
transport: {
prepareTarget: vi.fn(),
deliverPending: vi.fn(),
},
},
},
} as never,
cfg: {} as never,
accountId: "default",
channelRuntime,
});
const cleanup = await startTestBootstrap({ channelRuntime });
channelRuntime.runtimeContexts.register({
channelId: "slack",
accountId: "default",
capability: "approval.native",
context: { app: { ok: "first" } },
});
registerApprovalContext(channelRuntime, { ok: "first" });
await flushTransitions();
expect(firstStart).toHaveBeenCalledTimes(1);
channelRuntime.runtimeContexts.register({
channelId: "slack",
accountId: "default",
capability: "approval.native",
context: { app: { ok: "second" } },
});
registerApprovalContext(channelRuntime, { ok: "second" });
await flushTransitions();
expect(secondStart).toHaveBeenCalledTimes(1);

Some files were not shown because too many files have changed in this diff Show More