Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
2140 lines
72 KiB
TypeScript
2140 lines
72 KiB
TypeScript
// Imessage tests cover monitor.last route plugin behavior.
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import type { GetReplyOptions, MsgContext } from "openclaw/plugin-sdk/reply-runtime";
|
|
import type { waitForTransportReady } from "openclaw/plugin-sdk/transport-ready-runtime";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { createIMessageRpcClient } from "./client.js";
|
|
import { monitorIMessageProvider } from "./monitor.js";
|
|
import {
|
|
advanceIMessageRecoveryCursor,
|
|
loadIMessageRecoveryCursor,
|
|
} from "./monitor/recovery-cursor.js";
|
|
import {
|
|
clearCachedIMessagePrivateApiStatus,
|
|
setCachedIMessagePrivateApiStatus,
|
|
} from "./private-api-status.js";
|
|
import { installIMessageStateRuntimeForTest } from "./test-support/runtime.js";
|
|
|
|
type DispatchInboundMessageParams = {
|
|
ctx: MsgContext;
|
|
replyOptions?: GetReplyOptions;
|
|
};
|
|
|
|
const waitForTransportReadyMock = vi.hoisted(() =>
|
|
vi.fn<typeof waitForTransportReady>(async () => {}),
|
|
);
|
|
const createIMessageRpcClientMock = vi.hoisted(() => vi.fn<typeof createIMessageRpcClient>());
|
|
const readChannelAllowFromStoreMock = vi.hoisted(() => vi.fn(async () => [] as string[]));
|
|
const recordInboundSessionMock = vi.hoisted(() => vi.fn(async (_params: unknown) => {}));
|
|
const dispatchInboundMessageMock = vi.hoisted(() =>
|
|
vi.fn(
|
|
async (_params: DispatchInboundMessageParams) =>
|
|
({ queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } }) as const,
|
|
),
|
|
);
|
|
const debouncerControl = vi.hoisted(() => ({
|
|
holdEntries: false,
|
|
entries: [] as unknown[],
|
|
flush: undefined as undefined | (() => Promise<void>),
|
|
flushEach: undefined as undefined | (() => Promise<void>),
|
|
reset() {
|
|
this.holdEntries = false;
|
|
this.entries = [];
|
|
this.flush = undefined;
|
|
this.flushEach = undefined;
|
|
},
|
|
}));
|
|
const createChannelInboundDebouncerMock = vi.hoisted(() =>
|
|
vi.fn((opts: { onFlush: (entries: unknown[]) => Promise<void> }) => ({
|
|
debouncer: {
|
|
enqueue: async (entry: unknown) => {
|
|
if (!debouncerControl.holdEntries) {
|
|
await opts.onFlush([entry]);
|
|
return;
|
|
}
|
|
debouncerControl.entries.push(entry);
|
|
debouncerControl.flush = async () => {
|
|
const entries = debouncerControl.entries.splice(0);
|
|
await opts.onFlush(entries);
|
|
};
|
|
// Flush each collected entry as its own single-entry bucket, modeling
|
|
// the real non-debounced path (shouldDebounceTextInbound is mocked to
|
|
// false here) where every row dispatches individually.
|
|
debouncerControl.flushEach = async () => {
|
|
const entries = debouncerControl.entries.splice(0);
|
|
for (const queued of entries) {
|
|
await opts.onFlush([queued]);
|
|
}
|
|
};
|
|
},
|
|
},
|
|
})),
|
|
);
|
|
|
|
vi.mock("openclaw/plugin-sdk/transport-ready-runtime", () => ({
|
|
waitForTransportReady: waitForTransportReadyMock,
|
|
}));
|
|
|
|
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
|
|
return {
|
|
...actual,
|
|
readChannelAllowFromStore: readChannelAllowFromStoreMock,
|
|
recordInboundSession: recordInboundSessionMock,
|
|
upsertChannelPairingRequest: vi.fn(),
|
|
};
|
|
});
|
|
|
|
vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>();
|
|
return {
|
|
...actual,
|
|
createChannelInboundDebouncer: createChannelInboundDebouncerMock,
|
|
shouldDebounceTextInbound: vi.fn(() => false),
|
|
};
|
|
});
|
|
|
|
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
|
|
return {
|
|
...actual,
|
|
dispatchInboundMessage: dispatchInboundMessageMock,
|
|
};
|
|
});
|
|
|
|
vi.mock("./client.js", () => ({
|
|
createIMessageRpcClient: createIMessageRpcClientMock,
|
|
}));
|
|
|
|
vi.mock("./monitor/abort-handler.js", () => ({
|
|
attachIMessageMonitorAbortHandler: vi.fn(() => () => {}),
|
|
}));
|
|
|
|
describe("iMessage monitor last-route updates", () => {
|
|
const tempDirs: string[] = [];
|
|
|
|
beforeEach(() => {
|
|
installIMessageStateRuntimeForTest();
|
|
waitForTransportReadyMock.mockReset().mockResolvedValue(undefined);
|
|
createIMessageRpcClientMock.mockReset();
|
|
readChannelAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
|
recordInboundSessionMock.mockClear();
|
|
dispatchInboundMessageMock.mockClear();
|
|
createChannelInboundDebouncerMock.mockClear();
|
|
debouncerControl.reset();
|
|
clearCachedIMessagePrivateApiStatus();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.unstubAllEnvs();
|
|
for (const dir of tempDirs.splice(0)) {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("keeps native typing alive when tool activity arrives before reply text", async () => {
|
|
setCachedIMessagePrivateApiStatus("imsg", {
|
|
available: true,
|
|
v2Ready: true,
|
|
selectors: {},
|
|
rpcMethods: ["watch.subscribe", "send", "typing"],
|
|
});
|
|
dispatchInboundMessageMock.mockImplementationOnce(async (params) => {
|
|
expect(params.replyOptions?.suppressDefaultToolProgressMessages).toBe(true);
|
|
expect(params.replyOptions?.allowProgressCallbacksWhenSourceDeliverySuppressed).toBe(true);
|
|
let active = false;
|
|
let runComplete = false;
|
|
let dispatchIdle = false;
|
|
const stopIfSettled = () => {
|
|
if (active && runComplete && dispatchIdle) {
|
|
active = false;
|
|
params.replyOptions?.onTypingCleanup?.();
|
|
}
|
|
};
|
|
const typingController = {
|
|
onReplyStart: async () => {
|
|
await params.replyOptions?.onReplyStart?.();
|
|
},
|
|
startTypingLoop: async () => {
|
|
active = true;
|
|
await params.replyOptions?.onReplyStart?.();
|
|
},
|
|
startTypingOnText: async () => {},
|
|
refreshTypingTtl: () => {},
|
|
isActive: () => active,
|
|
markRunComplete: () => {
|
|
runComplete = true;
|
|
stopIfSettled();
|
|
},
|
|
markDispatchIdle: () => {
|
|
dispatchIdle = true;
|
|
stopIfSettled();
|
|
},
|
|
cleanup: () => {
|
|
active = false;
|
|
params.replyOptions?.onTypingCleanup?.();
|
|
},
|
|
};
|
|
params.replyOptions?.onTypingController?.(typingController);
|
|
await params.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
|
|
typingController.markRunComplete();
|
|
typingController.markDispatchIdle();
|
|
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } } as const;
|
|
});
|
|
|
|
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
|
|
const client = {
|
|
request: vi.fn(async (method: string) => {
|
|
if (method === "watch.subscribe") {
|
|
return { subscription: 1 };
|
|
}
|
|
if (method === "typing") {
|
|
return { ok: true };
|
|
}
|
|
throw new Error(`unexpected imsg method ${method}`);
|
|
}),
|
|
waitForClose: vi.fn(async () => {
|
|
onNotification?.({
|
|
method: "message",
|
|
params: {
|
|
message: {
|
|
id: 7,
|
|
chat_id: 123,
|
|
sender: "+15550001111",
|
|
is_from_me: false,
|
|
text: "run a long script",
|
|
is_group: false,
|
|
created_at: new Date().toISOString(),
|
|
},
|
|
},
|
|
});
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
}),
|
|
stop: vi.fn(async () => {}),
|
|
};
|
|
createIMessageRpcClientMock.mockImplementation(async (params) => {
|
|
if (!params?.onNotification) {
|
|
throw new Error("expected iMessage notification handler");
|
|
}
|
|
onNotification = params.onNotification;
|
|
return client as never;
|
|
});
|
|
|
|
await monitorIMessageProvider({
|
|
config: {
|
|
channels: {
|
|
imessage: {
|
|
coalesceSameSenderDms: true,
|
|
dmPolicy: "allowlist",
|
|
allowFrom: ["+15550001111"],
|
|
sendReadReceipts: false,
|
|
},
|
|
},
|
|
messages: { inbound: { debounceMs: 0 } },
|
|
session: { mainKey: "main" },
|
|
} as never,
|
|
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
expect(client.request).toHaveBeenCalledWith(
|
|
"typing",
|
|
expect.objectContaining({ typing: true }),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(client.request).toHaveBeenCalledWith(
|
|
"typing",
|
|
expect.objectContaining({ typing: false }),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
});
|
|
|
|
it("keeps direct progress options when imsg lacks native typing support", async () => {
|
|
setCachedIMessagePrivateApiStatus("imsg", {
|
|
available: true,
|
|
v2Ready: true,
|
|
selectors: {},
|
|
rpcMethods: ["watch.subscribe", "send", "read"],
|
|
});
|
|
dispatchInboundMessageMock.mockImplementationOnce(async (params) => {
|
|
expect(params.replyOptions?.suppressDefaultToolProgressMessages).toBe(true);
|
|
expect(params.replyOptions?.allowProgressCallbacksWhenSourceDeliverySuppressed).toBe(true);
|
|
expect(params.replyOptions?.onToolStart).toBeUndefined();
|
|
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } } as const;
|
|
});
|
|
|
|
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
|
|
const client = {
|
|
request: vi.fn(async (method: string) => {
|
|
if (method === "watch.subscribe") {
|
|
return { subscription: 1 };
|
|
}
|
|
if (method === "typing") {
|
|
throw new Error("typing should not start without native typing support");
|
|
}
|
|
throw new Error(`unexpected imsg method ${method}`);
|
|
}),
|
|
waitForClose: vi.fn(async () => {
|
|
onNotification?.({
|
|
method: "message",
|
|
params: {
|
|
message: {
|
|
id: 13,
|
|
chat_id: 123,
|
|
sender: "+15550001111",
|
|
is_from_me: false,
|
|
text: "run a long script without native typing",
|
|
is_group: false,
|
|
created_at: new Date().toISOString(),
|
|
},
|
|
},
|
|
});
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
}),
|
|
stop: vi.fn(async () => {}),
|
|
};
|
|
createIMessageRpcClientMock.mockImplementation(async (params) => {
|
|
if (!params?.onNotification) {
|
|
throw new Error("expected iMessage notification handler");
|
|
}
|
|
onNotification = params.onNotification;
|
|
return client as never;
|
|
});
|
|
|
|
await monitorIMessageProvider({
|
|
config: {
|
|
channels: {
|
|
imessage: {
|
|
dmPolicy: "allowlist",
|
|
allowFrom: ["+15550001111"],
|
|
sendReadReceipts: false,
|
|
},
|
|
},
|
|
messages: { inbound: { debounceMs: 0 } },
|
|
session: { mainKey: "main" },
|
|
} as never,
|
|
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
expect(client.request).not.toHaveBeenCalledWith(
|
|
"typing",
|
|
expect.objectContaining({ typing: true }),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("starts direct typing before dispatching the inbound turn", async () => {
|
|
setCachedIMessagePrivateApiStatus("imsg", {
|
|
available: true,
|
|
v2Ready: true,
|
|
selectors: {},
|
|
rpcMethods: ["watch.subscribe", "send", "typing"],
|
|
});
|
|
|
|
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
|
|
const earlyTypingClient = {
|
|
request: vi.fn(async (method: string) => {
|
|
if (method === "typing") {
|
|
return { ok: true };
|
|
}
|
|
throw new Error(`unexpected imsg typing-client method ${method}`);
|
|
}),
|
|
stop: vi.fn(async () => {}),
|
|
};
|
|
const watchClient = {
|
|
request: vi.fn(async (method: string) => {
|
|
if (method === "watch.subscribe") {
|
|
return { subscription: 1 };
|
|
}
|
|
if (method === "typing") {
|
|
return { ok: true };
|
|
}
|
|
throw new Error(`unexpected imsg watch-client method ${method}`);
|
|
}),
|
|
waitForClose: vi.fn(async () => {
|
|
onNotification?.({
|
|
method: "message",
|
|
params: {
|
|
message: {
|
|
id: 12,
|
|
chat_id: 123,
|
|
sender: "+15550001111",
|
|
is_from_me: false,
|
|
text: "respond after a slow context build",
|
|
is_group: false,
|
|
created_at: new Date().toISOString(),
|
|
},
|
|
},
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(earlyTypingClient.request).toHaveBeenCalledWith(
|
|
"typing",
|
|
expect.objectContaining({ typing: true, to: "+15550001111" }),
|
|
expect.any(Object),
|
|
);
|
|
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
}),
|
|
stop: vi.fn(async () => {}),
|
|
};
|
|
createIMessageRpcClientMock.mockImplementation(async (params) => {
|
|
if (params?.onNotification) {
|
|
onNotification = params.onNotification;
|
|
return watchClient as never;
|
|
}
|
|
return earlyTypingClient as never;
|
|
});
|
|
dispatchInboundMessageMock.mockImplementationOnce(async () => {
|
|
expect(earlyTypingClient.request).toHaveBeenCalledWith(
|
|
"typing",
|
|
expect.objectContaining({ typing: true, to: "+15550001111" }),
|
|
expect.any(Object),
|
|
);
|
|
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } } as const;
|
|
});
|
|
|
|
await monitorIMessageProvider({
|
|
config: {
|
|
channels: {
|
|
imessage: {
|
|
dmPolicy: "allowlist",
|
|
allowFrom: ["+15550001111"],
|
|
sendReadReceipts: false,
|
|
},
|
|
},
|
|
messages: { inbound: { debounceMs: 0 } },
|
|
session: { mainKey: "main" },
|
|
} as never,
|
|
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
|
|
});
|
|
|
|
expect(watchClient.request).not.toHaveBeenCalledWith(
|
|
"typing",
|
|
expect.objectContaining({ typing: true }),
|
|
expect.anything(),
|
|
);
|
|
await vi.waitFor(() => {
|
|
expect(earlyTypingClient.request).toHaveBeenCalledWith(
|
|
"typing",
|
|
expect.objectContaining({ typing: false, to: "+15550001111" }),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
});
|
|
|
|
it.each(["never", "message", "thinking"] as const)(
|
|
"does not start direct tool typing when typingMode is %s",
|
|
async (typingMode) => {
|
|
setCachedIMessagePrivateApiStatus("imsg", {
|
|
available: true,
|
|
v2Ready: true,
|
|
selectors: {},
|
|
rpcMethods: ["watch.subscribe", "send", "typing"],
|
|
});
|
|
dispatchInboundMessageMock.mockImplementationOnce(async (params) => {
|
|
expect(params.replyOptions?.suppressDefaultToolProgressMessages).toBeUndefined();
|
|
expect(
|
|
params.replyOptions?.allowProgressCallbacksWhenSourceDeliverySuppressed,
|
|
).toBeUndefined();
|
|
expect(params.replyOptions?.onToolStart).toBeUndefined();
|
|
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } } as const;
|
|
});
|
|
|
|
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
|
|
const client = {
|
|
request: vi.fn(async (method: string) => {
|
|
if (method === "watch.subscribe") {
|
|
return { subscription: 1 };
|
|
}
|
|
if (method === "typing") {
|
|
throw new Error("typing should not start from tool activity");
|
|
}
|
|
throw new Error(`unexpected imsg method ${method}`);
|
|
}),
|
|
waitForClose: vi.fn(async () => {
|
|
onNotification?.({
|
|
method: "message",
|
|
params: {
|
|
message: {
|
|
id: 8,
|
|
chat_id: 123,
|
|
sender: "+15550001111",
|
|
is_from_me: false,
|
|
text: "run a long script",
|
|
is_group: false,
|
|
created_at: new Date().toISOString(),
|
|
},
|
|
},
|
|
});
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
}),
|
|
stop: vi.fn(async () => {}),
|
|
};
|
|
createIMessageRpcClientMock.mockImplementation(async (params) => {
|
|
if (!params?.onNotification) {
|
|
throw new Error("expected iMessage notification handler");
|
|
}
|
|
onNotification = params.onNotification;
|
|
return client as never;
|
|
});
|
|
|
|
await monitorIMessageProvider({
|
|
config: {
|
|
channels: {
|
|
imessage: {
|
|
dmPolicy: "allowlist",
|
|
allowFrom: ["+15550001111"],
|
|
sendReadReceipts: false,
|
|
},
|
|
},
|
|
messages: { inbound: { debounceMs: 0 } },
|
|
session: { mainKey: "main", typingMode },
|
|
} as never,
|
|
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
expect(client.request).not.toHaveBeenCalledWith(
|
|
"typing",
|
|
expect.objectContaining({ typing: true }),
|
|
expect.anything(),
|
|
);
|
|
},
|
|
);
|
|
|
|
it("does not start direct tool typing when sendPolicy denies source delivery", async () => {
|
|
setCachedIMessagePrivateApiStatus("imsg", {
|
|
available: true,
|
|
v2Ready: true,
|
|
selectors: {},
|
|
rpcMethods: ["watch.subscribe", "send", "typing"],
|
|
});
|
|
dispatchInboundMessageMock.mockImplementationOnce(async (params) => {
|
|
expect(params.replyOptions?.suppressDefaultToolProgressMessages).toBeUndefined();
|
|
expect(
|
|
params.replyOptions?.allowProgressCallbacksWhenSourceDeliverySuppressed,
|
|
).toBeUndefined();
|
|
expect(params.replyOptions?.onToolStart).toBeUndefined();
|
|
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } } as const;
|
|
});
|
|
|
|
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
|
|
const client = {
|
|
request: vi.fn(async (method: string) => {
|
|
if (method === "watch.subscribe") {
|
|
return { subscription: 1 };
|
|
}
|
|
if (method === "typing") {
|
|
throw new Error("typing should not start under sendPolicy deny");
|
|
}
|
|
throw new Error(`unexpected imsg method ${method}`);
|
|
}),
|
|
waitForClose: vi.fn(async () => {
|
|
onNotification?.({
|
|
method: "message",
|
|
params: {
|
|
message: {
|
|
id: 9,
|
|
chat_id: 123,
|
|
sender: "+15550001111",
|
|
is_from_me: false,
|
|
text: "run a long script",
|
|
is_group: false,
|
|
created_at: new Date().toISOString(),
|
|
},
|
|
},
|
|
});
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
}),
|
|
stop: vi.fn(async () => {}),
|
|
};
|
|
createIMessageRpcClientMock.mockImplementation(async (params) => {
|
|
if (!params?.onNotification) {
|
|
throw new Error("expected iMessage notification handler");
|
|
}
|
|
onNotification = params.onNotification;
|
|
return client as never;
|
|
});
|
|
|
|
await monitorIMessageProvider({
|
|
config: {
|
|
channels: {
|
|
imessage: {
|
|
coalesceSameSenderDms: true,
|
|
dmPolicy: "allowlist",
|
|
allowFrom: ["+15550001111"],
|
|
sendReadReceipts: false,
|
|
},
|
|
},
|
|
messages: { inbound: { debounceMs: 0 } },
|
|
session: { mainKey: "main", sendPolicy: { default: "deny" } },
|
|
} as never,
|
|
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
expect(client.request).not.toHaveBeenCalledWith(
|
|
"typing",
|
|
expect.objectContaining({ typing: true }),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("does not wait for read receipts before dispatching the inbound turn", async () => {
|
|
setCachedIMessagePrivateApiStatus("imsg", {
|
|
available: true,
|
|
v2Ready: true,
|
|
selectors: {},
|
|
rpcMethods: ["watch.subscribe", "read"],
|
|
});
|
|
|
|
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
|
|
const readClient = {
|
|
request: vi.fn((method: string) => {
|
|
if (method === "read") {
|
|
return new Promise(() => {});
|
|
}
|
|
return Promise.reject(new Error(`unexpected imsg read-client method ${method}`));
|
|
}),
|
|
stop: vi.fn(async () => {}),
|
|
};
|
|
const watchClient = {
|
|
request: vi.fn((method: string) => {
|
|
if (method === "watch.subscribe") {
|
|
return Promise.resolve({ subscription: 1 });
|
|
}
|
|
return Promise.reject(new Error(`unexpected imsg watch-client method ${method}`));
|
|
}),
|
|
waitForClose: vi.fn(async () => {
|
|
onNotification?.({
|
|
method: "message",
|
|
params: {
|
|
message: {
|
|
id: 11,
|
|
chat_id: 123,
|
|
sender: "+15550001111",
|
|
is_from_me: false,
|
|
text: "respond without waiting for read receipt",
|
|
is_group: false,
|
|
created_at: new Date().toISOString(),
|
|
},
|
|
},
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
}),
|
|
stop: vi.fn(async () => {}),
|
|
};
|
|
createIMessageRpcClientMock.mockImplementation(async (params) => {
|
|
if (params?.onNotification) {
|
|
onNotification = params.onNotification;
|
|
return watchClient as never;
|
|
}
|
|
return readClient as never;
|
|
});
|
|
|
|
await monitorIMessageProvider({
|
|
config: {
|
|
channels: {
|
|
imessage: {
|
|
dmPolicy: "allowlist",
|
|
allowFrom: ["+15550001111"],
|
|
},
|
|
},
|
|
messages: { inbound: { debounceMs: 0 } },
|
|
session: { mainKey: "main" },
|
|
} as never,
|
|
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
|
|
});
|
|
|
|
expect(readClient.request).toHaveBeenCalledWith(
|
|
"read",
|
|
expect.objectContaining({ to: "+15550001111" }),
|
|
expect.any(Object),
|
|
);
|
|
expect(watchClient.request).not.toHaveBeenCalledWith(
|
|
"read",
|
|
expect.anything(),
|
|
expect.anything(),
|
|
);
|
|
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
label: "flat true",
|
|
imessagePatch: { blockStreaming: true },
|
|
expectedDisable: false,
|
|
},
|
|
{
|
|
label: "flat false",
|
|
imessagePatch: { blockStreaming: false },
|
|
expectedDisable: true,
|
|
},
|
|
{
|
|
label: "nested true",
|
|
imessagePatch: { streaming: { block: { enabled: true } } },
|
|
expectedDisable: false,
|
|
},
|
|
{
|
|
label: "nested false",
|
|
imessagePatch: { streaming: { block: { enabled: false } } },
|
|
expectedDisable: true,
|
|
},
|
|
{ label: "unset", imessagePatch: {}, expectedDisable: undefined },
|
|
] as const)(
|
|
"passes iMessage block streaming config ($label) through to reply dispatch",
|
|
async ({ imessagePatch, expectedDisable }) => {
|
|
dispatchInboundMessageMock.mockImplementationOnce(async (params) => {
|
|
expect(params.replyOptions?.disableBlockStreaming).toBe(expectedDisable);
|
|
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } } as const;
|
|
});
|
|
|
|
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
|
|
const client = {
|
|
request: vi.fn(async (method: string) => {
|
|
if (method === "watch.subscribe") {
|
|
return { subscription: 1 };
|
|
}
|
|
throw new Error(`unexpected imsg method ${method}`);
|
|
}),
|
|
waitForClose: vi.fn(async () => {
|
|
onNotification?.({
|
|
method: "message",
|
|
params: {
|
|
message: {
|
|
id: 10,
|
|
chat_id: 123,
|
|
sender: "+15550001111",
|
|
is_from_me: false,
|
|
text: "stream blocks before the final",
|
|
is_group: false,
|
|
created_at: new Date().toISOString(),
|
|
},
|
|
},
|
|
});
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
}),
|
|
stop: vi.fn(async () => {}),
|
|
};
|
|
createIMessageRpcClientMock.mockImplementation(async (params) => {
|
|
if (!params?.onNotification) {
|
|
throw new Error("expected iMessage notification handler");
|
|
}
|
|
onNotification = params.onNotification;
|
|
return client as never;
|
|
});
|
|
|
|
await monitorIMessageProvider({
|
|
config: {
|
|
channels: {
|
|
imessage: {
|
|
dmPolicy: "allowlist",
|
|
allowFrom: ["+15550001111"],
|
|
sendReadReceipts: false,
|
|
...imessagePatch,
|
|
},
|
|
},
|
|
messages: { inbound: { debounceMs: 0 } },
|
|
session: { mainKey: "main" },
|
|
} as never,
|
|
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{
|
|
label: "flat false overrides channel nested true",
|
|
channelBlockEnabled: true,
|
|
accountBlockStreaming: false,
|
|
expectedDisable: true,
|
|
},
|
|
{
|
|
label: "flat true overrides channel nested false",
|
|
channelBlockEnabled: false,
|
|
accountBlockStreaming: true,
|
|
expectedDisable: false,
|
|
},
|
|
] as const)(
|
|
"preserves account-level block streaming opt-outs when inheriting channel streaming ($label)",
|
|
async ({ channelBlockEnabled, accountBlockStreaming, expectedDisable }) => {
|
|
dispatchInboundMessageMock.mockImplementationOnce(async (params) => {
|
|
expect(params.replyOptions?.disableBlockStreaming).toBe(expectedDisable);
|
|
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } } as const;
|
|
});
|
|
|
|
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
|
|
const client = {
|
|
request: vi.fn(async (method: string) => {
|
|
if (method === "watch.subscribe") {
|
|
return { subscription: 1 };
|
|
}
|
|
throw new Error(`unexpected imsg method ${method}`);
|
|
}),
|
|
waitForClose: vi.fn(async () => {
|
|
onNotification?.({
|
|
method: "message",
|
|
params: {
|
|
message: {
|
|
id: 11,
|
|
chat_id: 123,
|
|
sender: "+15550001111",
|
|
is_from_me: false,
|
|
text: "stream blocks before the final",
|
|
is_group: false,
|
|
created_at: new Date().toISOString(),
|
|
},
|
|
},
|
|
});
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
}),
|
|
stop: vi.fn(async () => {}),
|
|
};
|
|
createIMessageRpcClientMock.mockImplementation(async (params) => {
|
|
if (!params?.onNotification) {
|
|
throw new Error("expected iMessage notification handler");
|
|
}
|
|
onNotification = params.onNotification;
|
|
return client as never;
|
|
});
|
|
|
|
await monitorIMessageProvider({
|
|
accountId: "personal",
|
|
config: {
|
|
channels: {
|
|
imessage: {
|
|
dmPolicy: "allowlist",
|
|
allowFrom: ["+15550001111"],
|
|
sendReadReceipts: false,
|
|
streaming: { block: { enabled: channelBlockEnabled } },
|
|
accounts: {
|
|
personal: {
|
|
blockStreaming: accountBlockStreaming,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
messages: { inbound: { debounceMs: 0 } },
|
|
session: { mainKey: "main" },
|
|
} as never,
|
|
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{
|
|
label: "chunkMode",
|
|
accountStreaming: { chunkMode: "length" },
|
|
},
|
|
{
|
|
label: "block coalesce",
|
|
accountStreaming: { block: { coalesce: { idleMs: 1 } } },
|
|
},
|
|
] as const)(
|
|
"preserves channel-level nested block streaming when an account overrides $label",
|
|
async ({ accountStreaming }) => {
|
|
dispatchInboundMessageMock.mockImplementationOnce(async (params) => {
|
|
expect(params.replyOptions?.disableBlockStreaming).toBe(false);
|
|
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } } as const;
|
|
});
|
|
|
|
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
|
|
const client = {
|
|
request: vi.fn(async (method: string) => {
|
|
if (method === "watch.subscribe") {
|
|
return { subscription: 1 };
|
|
}
|
|
throw new Error(`unexpected imsg method ${method}`);
|
|
}),
|
|
waitForClose: vi.fn(async () => {
|
|
onNotification?.({
|
|
method: "message",
|
|
params: {
|
|
message: {
|
|
id: 11,
|
|
chat_id: 123,
|
|
sender: "+15550001111",
|
|
is_from_me: false,
|
|
text: "stream blocks before the final",
|
|
is_group: false,
|
|
created_at: new Date().toISOString(),
|
|
},
|
|
},
|
|
});
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
}),
|
|
stop: vi.fn(async () => {}),
|
|
};
|
|
createIMessageRpcClientMock.mockImplementation(async (params) => {
|
|
if (!params?.onNotification) {
|
|
throw new Error("expected iMessage notification handler");
|
|
}
|
|
onNotification = params.onNotification;
|
|
return client as never;
|
|
});
|
|
|
|
await monitorIMessageProvider({
|
|
accountId: "personal",
|
|
config: {
|
|
channels: {
|
|
imessage: {
|
|
dmPolicy: "allowlist",
|
|
allowFrom: ["+15550001111"],
|
|
sendReadReceipts: false,
|
|
streaming: { block: { enabled: true } },
|
|
accounts: {
|
|
personal: {
|
|
streaming: accountStreaming,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
messages: { inbound: { debounceMs: 0 } },
|
|
session: { mainKey: "main" },
|
|
} as never,
|
|
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
},
|
|
);
|
|
|
|
it("keeps per-channel-peer direct-message last-route writes on the isolated session", async () => {
|
|
const runtimeErrorMock = vi.fn();
|
|
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
|
|
const client = {
|
|
request: vi.fn(async () => ({ subscription: 1 })),
|
|
waitForClose: vi.fn(async () => {
|
|
onNotification?.({
|
|
method: "message",
|
|
params: {
|
|
message: {
|
|
id: 1,
|
|
chat_id: 123,
|
|
sender: "+15550001111",
|
|
is_from_me: false,
|
|
text: "hello from imessage",
|
|
is_group: false,
|
|
created_at: new Date().toISOString(),
|
|
},
|
|
},
|
|
});
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
}),
|
|
stop: vi.fn(async () => {}),
|
|
};
|
|
createIMessageRpcClientMock.mockImplementation(async (params) => {
|
|
if (!params?.onNotification) {
|
|
throw new Error("expected iMessage notification handler");
|
|
}
|
|
onNotification = params.onNotification;
|
|
return client as never;
|
|
});
|
|
|
|
await monitorIMessageProvider({
|
|
config: {
|
|
channels: { imessage: { dmPolicy: "allowlist", allowFrom: ["+15550001111"] } },
|
|
messages: { inbound: { debounceMs: 0 } },
|
|
session: { dmScope: "per-channel-peer", mainKey: "main" },
|
|
} as never,
|
|
runtime: { error: runtimeErrorMock, exit: vi.fn(), log: vi.fn() },
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
expect(readChannelAllowFromStoreMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
expect(runtimeErrorMock).not.toHaveBeenCalled();
|
|
await vi.waitFor(() => {
|
|
expect(recordInboundSessionMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
const recordParams = recordInboundSessionMock.mock.calls.at(0)?.[0] as
|
|
| {
|
|
sessionKey?: string;
|
|
updateLastRoute?: {
|
|
channel?: string;
|
|
mainDmOwnerPin?: unknown;
|
|
sessionKey?: string;
|
|
to?: string;
|
|
};
|
|
}
|
|
| undefined;
|
|
expect(recordParams?.sessionKey).toBe("agent:main:imessage:direct:+15550001111");
|
|
expect(recordParams?.updateLastRoute?.sessionKey).toBe(recordParams?.sessionKey);
|
|
expect(recordParams?.updateLastRoute?.sessionKey).not.toBe("agent:main:main");
|
|
expect(recordParams?.updateLastRoute?.channel).toBe("imessage");
|
|
expect(recordParams?.updateLastRoute?.to).toBe("imessage:+15550001111");
|
|
expect(recordParams?.updateLastRoute?.mainDmOwnerPin).toBeUndefined();
|
|
});
|
|
|
|
it("suppresses stale backlog rows but dispatches fresh live rows", async () => {
|
|
// Dates are relative to real now so the age fence sees the intended ages
|
|
// (the live debouncer also flushes on a real 0ms timer here).
|
|
const staleCreatedAt = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
|
const freshCreatedAt = new Date().toISOString();
|
|
|
|
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
|
|
const client = {
|
|
request: vi.fn(async () => ({ subscription: 1 })),
|
|
waitForClose: vi.fn(async () => {
|
|
// Stale backlog row (old send date) Apple delivered after a recovery —
|
|
// must be suppressed by the age fence.
|
|
onNotification?.({
|
|
method: "message",
|
|
params: {
|
|
message: {
|
|
id: 2023,
|
|
guid: "OLD-GUID-2023",
|
|
chat_id: 123,
|
|
sender: "+15550001111",
|
|
is_from_me: false,
|
|
text: "old backlog row",
|
|
is_group: false,
|
|
created_at: staleCreatedAt,
|
|
},
|
|
},
|
|
});
|
|
// Fresh live row — must dispatch.
|
|
onNotification?.({
|
|
method: "message",
|
|
params: {
|
|
message: {
|
|
id: 3001,
|
|
guid: "LIVE-GUID-2026",
|
|
chat_id: 123,
|
|
sender: "+15550001111",
|
|
is_from_me: false,
|
|
text: "current row",
|
|
is_group: false,
|
|
created_at: freshCreatedAt,
|
|
},
|
|
},
|
|
});
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
}),
|
|
stop: vi.fn(async () => {}),
|
|
};
|
|
createIMessageRpcClientMock.mockImplementation(async (params) => {
|
|
if (!params?.onNotification) {
|
|
throw new Error("expected iMessage notification handler");
|
|
}
|
|
onNotification = params.onNotification;
|
|
return client as never;
|
|
});
|
|
|
|
await monitorIMessageProvider({
|
|
config: {
|
|
channels: {
|
|
imessage: {
|
|
// Unreadable dbPath => no startup rowid watermark, so this test
|
|
// isolates the age-fence behavior on the live path.
|
|
dbPath: path.join(os.tmpdir(), `openclaw-missing-chat-${Date.now()}.db`),
|
|
dmPolicy: "allowlist",
|
|
allowFrom: ["+15550001111"],
|
|
},
|
|
},
|
|
messages: { inbound: { debounceMs: 0 } },
|
|
session: { mainKey: "main" },
|
|
} as never,
|
|
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
|
|
});
|
|
|
|
// No readable db => watch.subscribe carries no since_rowid; the age fence
|
|
// suppresses stale backlog on the live path instead.
|
|
expect(client.request).toHaveBeenCalledWith(
|
|
"watch.subscribe",
|
|
{ attachments: false, include_reactions: true },
|
|
{ timeoutMs: 10_000 },
|
|
);
|
|
// Only the fresh row dispatches; the stale backlog row is suppressed.
|
|
await vi.waitFor(() => {
|
|
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
it("passes the startup rowid watermark as since_rowid when chat.db is readable", async () => {
|
|
// Regression guard: the watermark is captured before the transport-ready
|
|
// probe so messages that land during the startup window are not skipped by
|
|
// imsg's self-fence at subscribe time.
|
|
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-imsg-startup-rowid-"));
|
|
tempDirs.push(stateDir);
|
|
const dbPath = path.join(stateDir, "chat.db");
|
|
const { DatabaseSync } = await import("node:sqlite");
|
|
const database = new DatabaseSync(dbPath);
|
|
try {
|
|
database.exec("CREATE TABLE message (text TEXT);");
|
|
database.prepare("INSERT INTO message(rowid, text) VALUES (?, ?)").run(5000, "watermark");
|
|
} finally {
|
|
database.close();
|
|
}
|
|
const client = {
|
|
request: vi.fn(async () => ({ subscription: 1 })),
|
|
waitForClose: vi.fn(async () => {}),
|
|
stop: vi.fn(async () => {}),
|
|
};
|
|
createIMessageRpcClientMock.mockImplementation(async () => client as never);
|
|
|
|
await monitorIMessageProvider({
|
|
config: {
|
|
channels: { imessage: { dbPath, dmPolicy: "allowlist", allowFrom: ["+15550001111"] } },
|
|
messages: { inbound: { debounceMs: 0 } },
|
|
session: { mainKey: "main" },
|
|
} as never,
|
|
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
|
|
});
|
|
|
|
expect(client.request).toHaveBeenCalledWith(
|
|
"watch.subscribe",
|
|
{ attachments: false, include_reactions: true, since_rowid: 5000 },
|
|
{ timeoutMs: 10_000 },
|
|
);
|
|
});
|
|
|
|
it("recovers over a remote cliPath: replays from the cursor even without a local chat.db boundary", async () => {
|
|
advanceIMessageRecoveryCursor("default", 4990);
|
|
const client = {
|
|
request: vi.fn(async () => ({ subscription: 1 })),
|
|
waitForClose: vi.fn(async () => {}),
|
|
stop: vi.fn(async () => {}),
|
|
};
|
|
createIMessageRpcClientMock.mockImplementation(async () => client as never);
|
|
|
|
await monitorIMessageProvider({
|
|
config: {
|
|
channels: {
|
|
imessage: {
|
|
// remoteHost set => no local chat.db boundary; recovery must still
|
|
// drive since_rowid from the persisted cursor over the RPC client.
|
|
remoteHost: "user@gateway-host",
|
|
dmPolicy: "allowlist",
|
|
allowFrom: ["+15550001111"],
|
|
},
|
|
},
|
|
messages: { inbound: { debounceMs: 0 } },
|
|
session: { mainKey: "main" },
|
|
} as never,
|
|
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
|
|
});
|
|
|
|
expect(client.request).toHaveBeenCalledWith(
|
|
"watch.subscribe",
|
|
{ attachments: false, include_reactions: true, since_rowid: 4990 },
|
|
{ timeoutMs: 10_000 },
|
|
);
|
|
});
|
|
|
|
it("preserves enabled legacy catchup as the startup replay path", async () => {
|
|
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-imsg-catchup-window-"));
|
|
tempDirs.push(stateDir);
|
|
const dbPath = path.join(stateDir, "chat.db");
|
|
const { DatabaseSync } = await import("node:sqlite");
|
|
const database = new DatabaseSync(dbPath);
|
|
try {
|
|
database.exec("CREATE TABLE message (text TEXT);");
|
|
database.prepare("INSERT INTO message(rowid, text) VALUES (?, ?)").run(5000, "boundary");
|
|
} finally {
|
|
database.close();
|
|
}
|
|
const client = {
|
|
request: vi.fn(async (method: string) => {
|
|
if (method === "watch.subscribe") {
|
|
return { subscription: 1 };
|
|
}
|
|
if (method === "chats.list") {
|
|
return { chats: [] };
|
|
}
|
|
throw new Error(`unexpected request ${method}`);
|
|
}),
|
|
waitForClose: vi.fn(async () => {}),
|
|
stop: vi.fn(async () => {}),
|
|
};
|
|
createIMessageRpcClientMock.mockImplementation(async () => client as never);
|
|
|
|
await monitorIMessageProvider({
|
|
config: {
|
|
channels: {
|
|
imessage: {
|
|
dbPath,
|
|
catchup: { enabled: true, perRunLimit: 25, maxAgeMinutes: 60 },
|
|
dmPolicy: "allowlist",
|
|
allowFrom: ["+15550001111"],
|
|
},
|
|
},
|
|
messages: { inbound: { debounceMs: 0 } },
|
|
session: { mainKey: "main" },
|
|
} as never,
|
|
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
|
|
});
|
|
|
|
expect(client.request).toHaveBeenCalledWith(
|
|
"watch.subscribe",
|
|
{ attachments: false, include_reactions: true },
|
|
{ timeoutMs: 10_000 },
|
|
);
|
|
expect(client.request).toHaveBeenCalledWith(
|
|
"chats.list",
|
|
{ limit: 200 },
|
|
{ timeoutMs: 30_000 },
|
|
);
|
|
});
|
|
|
|
it("recovers downtime messages: replays from the cursor and delivers replay rows older than the live fence", async () => {
|
|
advanceIMessageRecoveryCursor("default", 4990);
|
|
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-imsg-recovery-"));
|
|
tempDirs.push(stateDir);
|
|
const dbPath = path.join(stateDir, "chat.db");
|
|
const { DatabaseSync } = await import("node:sqlite");
|
|
const database = new DatabaseSync(dbPath);
|
|
try {
|
|
database.exec("CREATE TABLE message (text TEXT);");
|
|
database.prepare("INSERT INTO message(rowid, text) VALUES (?, ?)").run(5000, "boundary");
|
|
} finally {
|
|
database.close();
|
|
}
|
|
// 30 min old: inside the 2h recovery window, outside the 15min live fence.
|
|
const thirtyMinAgo = new Date(Date.now() - 30 * 60 * 1000).toISOString();
|
|
|
|
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
|
|
const client = {
|
|
request: vi.fn(async () => ({ subscription: 1 })),
|
|
waitForClose: vi.fn(async () => {
|
|
// Recovery replay row (rowid <= boundary 5000): missed during downtime,
|
|
// delivered despite being 30min old.
|
|
onNotification?.({
|
|
method: "message",
|
|
params: {
|
|
message: {
|
|
id: 4995,
|
|
guid: "RECOVERY-GUID-4995",
|
|
chat_id: 123,
|
|
sender: "+15550001111",
|
|
is_from_me: false,
|
|
text: "missed during downtime",
|
|
is_group: false,
|
|
created_at: thirtyMinAgo,
|
|
},
|
|
},
|
|
});
|
|
// Live row (rowid > boundary) with the same old date: this is the
|
|
// #89237 Push-flush backlog shape, suppressed at the live fence.
|
|
onNotification?.({
|
|
method: "message",
|
|
params: {
|
|
message: {
|
|
id: 5001,
|
|
guid: "LIVE-OLD-GUID-5001",
|
|
chat_id: 123,
|
|
sender: "+15550001111",
|
|
is_from_me: false,
|
|
text: "live backlog bomb",
|
|
is_group: false,
|
|
created_at: thirtyMinAgo,
|
|
},
|
|
},
|
|
});
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
}),
|
|
stop: vi.fn(async () => {}),
|
|
};
|
|
createIMessageRpcClientMock.mockImplementation(async (params) => {
|
|
if (!params?.onNotification) {
|
|
throw new Error("expected iMessage notification handler");
|
|
}
|
|
onNotification = params.onNotification;
|
|
return client as never;
|
|
});
|
|
|
|
await monitorIMessageProvider({
|
|
config: {
|
|
channels: { imessage: { dbPath, dmPolicy: "allowlist", allowFrom: ["+15550001111"] } },
|
|
messages: { inbound: { debounceMs: 0 } },
|
|
session: { mainKey: "main" },
|
|
} as never,
|
|
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
|
|
});
|
|
|
|
// since_rowid replays from the persisted cursor, not the boundary.
|
|
expect(client.request).toHaveBeenCalledWith(
|
|
"watch.subscribe",
|
|
{ attachments: false, include_reactions: true, since_rowid: 4990 },
|
|
{ timeoutMs: 10_000 },
|
|
);
|
|
// The recovery replay row dispatches; the live old row is suppressed.
|
|
await vi.waitFor(() => {
|
|
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
it("does not treat startup-boundary rows as recovery replay without a prior cursor", async () => {
|
|
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-imsg-first-run-boundary-"));
|
|
tempDirs.push(stateDir);
|
|
const dbPath = path.join(stateDir, "chat.db");
|
|
const { DatabaseSync } = await import("node:sqlite");
|
|
const database = new DatabaseSync(dbPath);
|
|
try {
|
|
database.exec("CREATE TABLE message (text TEXT);");
|
|
database.prepare("INSERT INTO message(rowid, text) VALUES (?, ?)").run(5000, "boundary");
|
|
} finally {
|
|
database.close();
|
|
}
|
|
const thirtyMinAgo = new Date(Date.now() - 30 * 60 * 1000).toISOString();
|
|
|
|
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
|
|
const client = {
|
|
request: vi.fn(async () => ({ subscription: 1 })),
|
|
waitForClose: vi.fn(async () => {
|
|
onNotification?.({
|
|
method: "message",
|
|
params: {
|
|
message: {
|
|
id: 4995,
|
|
guid: "FIRST-RUN-HISTORY-GUID-4995",
|
|
chat_id: 123,
|
|
sender: "+15550001111",
|
|
is_from_me: false,
|
|
text: "already existed before first monitor start",
|
|
is_group: false,
|
|
created_at: thirtyMinAgo,
|
|
},
|
|
},
|
|
});
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
}),
|
|
stop: vi.fn(async () => {}),
|
|
};
|
|
createIMessageRpcClientMock.mockImplementation(async (params) => {
|
|
if (!params?.onNotification) {
|
|
throw new Error("expected iMessage notification handler");
|
|
}
|
|
onNotification = params.onNotification;
|
|
return client as never;
|
|
});
|
|
|
|
await monitorIMessageProvider({
|
|
config: {
|
|
channels: { imessage: { dbPath, dmPolicy: "allowlist", allowFrom: ["+15550001111"] } },
|
|
messages: { inbound: { debounceMs: 0 } },
|
|
session: { mainKey: "main" },
|
|
} as never,
|
|
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
|
|
});
|
|
|
|
expect(client.request).toHaveBeenCalledWith(
|
|
"watch.subscribe",
|
|
{ attachments: false, include_reactions: true, since_rowid: 5000 },
|
|
{ timeoutMs: 10_000 },
|
|
);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
expect(dispatchInboundMessageMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("records a suppressed live row so a later replay of the same row is deduped, not delivered", async () => {
|
|
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-imsg-suppress-record-"));
|
|
tempDirs.push(stateDir);
|
|
const dbPath = path.join(stateDir, "chat.db");
|
|
const { DatabaseSync } = await import("node:sqlite");
|
|
const database = new DatabaseSync(dbPath);
|
|
try {
|
|
database.exec("CREATE TABLE message (text TEXT);");
|
|
database.prepare("INSERT INTO message(rowid, text) VALUES (?, ?)").run(5000, "boundary");
|
|
} finally {
|
|
database.close();
|
|
}
|
|
const thirtyMinAgo = new Date(Date.now() - 30 * 60 * 1000).toISOString();
|
|
|
|
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
|
|
const client = {
|
|
request: vi.fn(async () => ({ subscription: 1 })),
|
|
waitForClose: vi.fn(async () => {
|
|
// Live row (rowid > boundary), 30min old -> suppressed by the live fence
|
|
// AND recorded in the dedupe.
|
|
onNotification?.({
|
|
method: "message",
|
|
params: {
|
|
message: {
|
|
id: 5001,
|
|
guid: "SUPPRESSED-GUID",
|
|
chat_id: 123,
|
|
sender: "+15550001111",
|
|
is_from_me: false,
|
|
text: "stale live backlog",
|
|
is_group: false,
|
|
created_at: thirtyMinAgo,
|
|
},
|
|
},
|
|
});
|
|
// Same GUID re-emitted fresh (as a restart replay would): must be
|
|
// dropped as a duplicate, not delivered under the recovery window.
|
|
onNotification?.({
|
|
method: "message",
|
|
params: {
|
|
message: {
|
|
id: 5001,
|
|
guid: "SUPPRESSED-GUID",
|
|
chat_id: 123,
|
|
sender: "+15550001111",
|
|
is_from_me: false,
|
|
text: "stale live backlog",
|
|
is_group: false,
|
|
created_at: new Date().toISOString(),
|
|
},
|
|
},
|
|
});
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
}),
|
|
stop: vi.fn(async () => {}),
|
|
};
|
|
createIMessageRpcClientMock.mockImplementation(async (params) => {
|
|
if (!params?.onNotification) {
|
|
throw new Error("expected iMessage notification handler");
|
|
}
|
|
onNotification = params.onNotification;
|
|
return client as never;
|
|
});
|
|
|
|
await monitorIMessageProvider({
|
|
config: {
|
|
channels: { imessage: { dbPath, dmPolicy: "allowlist", allowFrom: ["+15550001111"] } },
|
|
messages: { inbound: { debounceMs: 0 } },
|
|
session: { mainKey: "main" },
|
|
} as never,
|
|
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
|
|
});
|
|
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
expect(dispatchInboundMessageMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not advance the recovery cursor past a failed replay row", async () => {
|
|
advanceIMessageRecoveryCursor("default", 4990);
|
|
debouncerControl.holdEntries = true;
|
|
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-imsg-recovery-failed-"));
|
|
tempDirs.push(stateDir);
|
|
const dbPath = path.join(stateDir, "chat.db");
|
|
const { DatabaseSync } = await import("node:sqlite");
|
|
const database = new DatabaseSync(dbPath);
|
|
try {
|
|
database.exec("CREATE TABLE message (text TEXT);");
|
|
database.prepare("INSERT INTO message(rowid, text) VALUES (?, ?)").run(5000, "boundary");
|
|
} finally {
|
|
database.close();
|
|
}
|
|
const thirtyMinAgo = new Date(Date.now() - 30 * 60 * 1000).toISOString();
|
|
dispatchInboundMessageMock
|
|
.mockRejectedValueOnce(new Error("dispatch failed"))
|
|
.mockResolvedValue({ queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } });
|
|
|
|
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
|
|
const client = {
|
|
request: vi.fn(async () => ({ subscription: 1 })),
|
|
waitForClose: vi.fn(async () => {
|
|
for (const id of [4995, 4996]) {
|
|
onNotification?.({
|
|
method: "message",
|
|
params: {
|
|
message: {
|
|
id,
|
|
guid: `FAILED-REPLAY-GUID-${id}`,
|
|
chat_id: 123,
|
|
sender: "+15550001111",
|
|
is_from_me: false,
|
|
text: `missed during downtime ${id}`,
|
|
is_group: false,
|
|
created_at: thirtyMinAgo,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
}),
|
|
stop: vi.fn(async () => {}),
|
|
};
|
|
createIMessageRpcClientMock.mockImplementation(async (params) => {
|
|
if (!params?.onNotification) {
|
|
throw new Error("expected iMessage notification handler");
|
|
}
|
|
onNotification = params.onNotification;
|
|
return client as never;
|
|
});
|
|
|
|
await monitorIMessageProvider({
|
|
config: {
|
|
channels: { imessage: { dbPath, dmPolicy: "allowlist", allowFrom: ["+15550001111"] } },
|
|
messages: { inbound: { debounceMs: 0 } },
|
|
session: { mainKey: "main" },
|
|
} as never,
|
|
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
|
|
});
|
|
|
|
expect(client.request).toHaveBeenCalledWith(
|
|
"watch.subscribe",
|
|
{ attachments: false, include_reactions: true, since_rowid: 4990 },
|
|
{ timeoutMs: 10_000 },
|
|
);
|
|
await vi.waitFor(() => {
|
|
expect(debouncerControl.entries).toHaveLength(2);
|
|
});
|
|
await debouncerControl.flushEach?.();
|
|
await vi.waitFor(() => {
|
|
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
expect(loadIMessageRecoveryCursor("default")).toBe(4994);
|
|
});
|
|
|
|
it("advances the recovery cursor after lower pending replay rows complete", async () => {
|
|
advanceIMessageRecoveryCursor("default", 4990);
|
|
debouncerControl.holdEntries = true;
|
|
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-imsg-recovery-ordered-"));
|
|
tempDirs.push(stateDir);
|
|
const dbPath = path.join(stateDir, "chat.db");
|
|
const { DatabaseSync } = await import("node:sqlite");
|
|
const database = new DatabaseSync(dbPath);
|
|
try {
|
|
database.exec("CREATE TABLE message (text TEXT);");
|
|
database.prepare("INSERT INTO message(rowid, text) VALUES (?, ?)").run(5000, "boundary");
|
|
} finally {
|
|
database.close();
|
|
}
|
|
const thirtyMinAgo = new Date(Date.now() - 30 * 60 * 1000).toISOString();
|
|
|
|
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
|
|
const client = {
|
|
request: vi.fn(async () => ({ subscription: 1 })),
|
|
waitForClose: vi.fn(async () => {
|
|
for (const id of [4995, 4996]) {
|
|
onNotification?.({
|
|
method: "message",
|
|
params: {
|
|
message: {
|
|
id,
|
|
guid: `OUT-OF-ORDER-REPLAY-GUID-${id}`,
|
|
chat_id: 123,
|
|
sender: "+15550001111",
|
|
is_from_me: false,
|
|
text: `missed during downtime ${id}`,
|
|
is_group: false,
|
|
created_at: thirtyMinAgo,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
}),
|
|
stop: vi.fn(async () => {}),
|
|
};
|
|
createIMessageRpcClientMock.mockImplementation(async (params) => {
|
|
if (!params?.onNotification) {
|
|
throw new Error("expected iMessage notification handler");
|
|
}
|
|
onNotification = params.onNotification;
|
|
return client as never;
|
|
});
|
|
|
|
await monitorIMessageProvider({
|
|
config: {
|
|
channels: { imessage: { dbPath, dmPolicy: "allowlist", allowFrom: ["+15550001111"] } },
|
|
messages: { inbound: { debounceMs: 0 } },
|
|
session: { mainKey: "main" },
|
|
} as never,
|
|
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
expect(debouncerControl.entries).toHaveLength(2);
|
|
});
|
|
debouncerControl.entries.reverse();
|
|
await debouncerControl.flushEach?.();
|
|
await vi.waitFor(() => {
|
|
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
expect(loadIMessageRecoveryCursor("default")).toBe(4996);
|
|
});
|
|
|
|
it("repairs anchorless group watch payloads before routing or cursor updates", async () => {
|
|
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-imsg-anchor-repair-"));
|
|
tempDirs.push(stateDir);
|
|
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
|
|
|
|
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
|
|
const client = {
|
|
request: vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
|
if (method === "watch.subscribe") {
|
|
return { subscription: 1 };
|
|
}
|
|
if (method === "chats.list") {
|
|
return { chats: [{ id: 349 }] };
|
|
}
|
|
if (method === "messages.history") {
|
|
expect(params?.chat_id).toBe(349);
|
|
return {
|
|
messages: [
|
|
{
|
|
id: 9500,
|
|
guid: "ANCHORLESS-GROUP-GUID",
|
|
chat_id: 349,
|
|
chat_guid: "iMessage;+;chat349",
|
|
chat_identifier: "chat349",
|
|
chat_name: "Project group",
|
|
participants: ["+15550001111", "+15550002222"],
|
|
is_group: true,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
throw new Error(`unexpected imsg method ${method}`);
|
|
}),
|
|
waitForClose: vi.fn(async () => {
|
|
onNotification?.({
|
|
method: "message",
|
|
params: {
|
|
message: {
|
|
id: 9500,
|
|
guid: "ANCHORLESS-GROUP-GUID",
|
|
chat_id: 0,
|
|
sender: "+15550001111",
|
|
is_from_me: false,
|
|
text: "@openclaw check this https://example.com",
|
|
is_group: false,
|
|
chat_guid: "",
|
|
chat_identifier: "",
|
|
chat_name: "",
|
|
participants: null,
|
|
created_at: new Date().toISOString(),
|
|
},
|
|
},
|
|
});
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
}),
|
|
stop: vi.fn(async () => {}),
|
|
};
|
|
createIMessageRpcClientMock.mockImplementation(async (params) => {
|
|
if (!params?.onNotification) {
|
|
throw new Error("expected iMessage notification handler");
|
|
}
|
|
onNotification = params.onNotification;
|
|
return client as never;
|
|
});
|
|
|
|
await monitorIMessageProvider({
|
|
config: {
|
|
channels: {
|
|
imessage: {
|
|
groupPolicy: "open",
|
|
groups: { "*": { requireMention: true } },
|
|
},
|
|
},
|
|
messages: {
|
|
groupChat: { mentionPatterns: ["@openclaw"] },
|
|
inbound: { debounceMs: 0 },
|
|
},
|
|
session: { mainKey: "main" },
|
|
} as never,
|
|
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
const dispatchParams = dispatchInboundMessageMock.mock.calls.at(0)?.[0];
|
|
expect(dispatchParams?.ctx.To).toBe("chat_id:349");
|
|
expect(dispatchParams?.ctx.From).toBe("imessage:group:349");
|
|
expect(dispatchParams?.ctx.ChatType).toBe("group");
|
|
expect(dispatchParams?.ctx.SessionKey).toBe("agent:main:imessage:group:349");
|
|
expect(dispatchParams?.ctx.To).not.toBe("imessage:+15550001111");
|
|
});
|
|
|
|
it("merges a command row with the following URL balloon row", async () => {
|
|
// Apple's command+URL composition can arrive as a command row followed by a
|
|
// URL-preview balloon row. The opt-in coalescer keeps the pair as one agent
|
|
// turn and uses balloon metadata to avoid collapsing ordinary rows.
|
|
debouncerControl.holdEntries = true;
|
|
|
|
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
|
|
const client = {
|
|
request: vi.fn(async (method: string) => {
|
|
if (method === "watch.subscribe") {
|
|
return { subscription: 1 };
|
|
}
|
|
throw new Error(`unexpected imsg method ${method}`);
|
|
}),
|
|
waitForClose: vi.fn(async () => {
|
|
// Fresh dates relative to now so the stale-backlog age fence lets the
|
|
// live rows through to the debouncer.
|
|
for (const row of [
|
|
{
|
|
id: 91,
|
|
guid: "LIVE-GUID-91",
|
|
text: "summarize",
|
|
created_at: new Date(Date.now() - 2000).toISOString(),
|
|
},
|
|
{
|
|
id: 92,
|
|
guid: "LIVE-GUID-92",
|
|
text: "https://example.com/article",
|
|
balloon_bundle_id: "com.apple.messages.URLBalloonProvider",
|
|
created_at: new Date(Date.now() - 1000).toISOString(),
|
|
},
|
|
]) {
|
|
onNotification?.({
|
|
method: "message",
|
|
params: {
|
|
message: {
|
|
...row,
|
|
chat_id: 123,
|
|
sender: "+15550001111",
|
|
is_from_me: false,
|
|
is_group: false,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
await vi.waitFor(() => {
|
|
expect(debouncerControl.flush).toBeDefined();
|
|
});
|
|
await debouncerControl.flush?.();
|
|
await Promise.resolve();
|
|
}),
|
|
stop: vi.fn(async () => {}),
|
|
};
|
|
createIMessageRpcClientMock.mockImplementation(async (params) => {
|
|
if (!params?.onNotification) {
|
|
throw new Error("expected iMessage notification handler");
|
|
}
|
|
onNotification = params.onNotification;
|
|
return client as never;
|
|
});
|
|
|
|
await monitorIMessageProvider({
|
|
config: {
|
|
channels: {
|
|
imessage: {
|
|
coalesceSameSenderDms: true,
|
|
dmPolicy: "allowlist",
|
|
allowFrom: ["+15550001111"],
|
|
sendReadReceipts: false,
|
|
},
|
|
},
|
|
session: { mainKey: "main" },
|
|
} as never,
|
|
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
|
|
});
|
|
|
|
const debouncerOptions = createChannelInboundDebouncerMock.mock.calls.at(-1)?.[0] as
|
|
| { debounceMsOverride?: number }
|
|
| undefined;
|
|
expect(debouncerOptions?.debounceMsOverride).toBe(7000);
|
|
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
|
|
const mergedBody = dispatchInboundMessageMock.mock.calls[0]?.[0].ctx.Body ?? "";
|
|
expect(mergedBody).toContain("summarize");
|
|
expect(mergedBody).toContain("https://example.com/article");
|
|
});
|
|
|
|
it("keeps ordinary buffered DMs separate after balloon metadata is observed", async () => {
|
|
debouncerControl.holdEntries = true;
|
|
|
|
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
|
|
const client = {
|
|
request: vi.fn(async (method: string) => {
|
|
if (method === "watch.subscribe") {
|
|
return { subscription: 1 };
|
|
}
|
|
throw new Error(`unexpected imsg method ${method}`);
|
|
}),
|
|
waitForClose: vi.fn(async () => {
|
|
for (const row of [
|
|
{
|
|
id: 101,
|
|
guid: "LIVE-GUID-101",
|
|
text: "handwriting",
|
|
balloon_bundle_id: "com.apple.messages.HandwritingProvider",
|
|
created_at: new Date(Date.now() - 3000).toISOString(),
|
|
},
|
|
{
|
|
id: 102,
|
|
guid: "LIVE-GUID-102",
|
|
text: "first thought",
|
|
created_at: new Date(Date.now() - 2000).toISOString(),
|
|
},
|
|
{
|
|
id: 103,
|
|
guid: "LIVE-GUID-103",
|
|
text: "second thought",
|
|
created_at: new Date(Date.now() - 1000).toISOString(),
|
|
},
|
|
]) {
|
|
onNotification?.({
|
|
method: "message",
|
|
params: {
|
|
message: {
|
|
...row,
|
|
chat_id: 123,
|
|
sender: "+15550001111",
|
|
is_from_me: false,
|
|
is_group: false,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
await vi.waitFor(() => {
|
|
expect(debouncerControl.flush).toBeDefined();
|
|
});
|
|
await debouncerControl.flush?.();
|
|
await Promise.resolve();
|
|
}),
|
|
stop: vi.fn(async () => {}),
|
|
};
|
|
createIMessageRpcClientMock.mockImplementation(async (params) => {
|
|
if (!params?.onNotification) {
|
|
throw new Error("expected iMessage notification handler");
|
|
}
|
|
onNotification = params.onNotification;
|
|
return client as never;
|
|
});
|
|
|
|
await monitorIMessageProvider({
|
|
config: {
|
|
channels: {
|
|
imessage: {
|
|
coalesceSameSenderDms: true,
|
|
dmPolicy: "allowlist",
|
|
allowFrom: ["+15550001111"],
|
|
sendReadReceipts: false,
|
|
},
|
|
},
|
|
session: { mainKey: "main" },
|
|
} as never,
|
|
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
|
|
});
|
|
|
|
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(3);
|
|
const bodies = dispatchInboundMessageMock.mock.calls.map((call) => call[0].ctx.Body ?? "");
|
|
expect(bodies.some((body) => body.includes("handwriting"))).toBe(true);
|
|
expect(bodies.some((body) => body.includes("first thought"))).toBe(true);
|
|
expect(bodies.some((body) => body.includes("second thought"))).toBe(true);
|
|
});
|
|
|
|
it("uses stale balloon rows as metadata support without dispatching them", async () => {
|
|
debouncerControl.holdEntries = true;
|
|
|
|
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
|
|
const client = {
|
|
request: vi.fn(async (method: string) => {
|
|
if (method === "watch.subscribe") {
|
|
return { subscription: 1 };
|
|
}
|
|
throw new Error(`unexpected imsg method ${method}`);
|
|
}),
|
|
waitForClose: vi.fn(async () => {
|
|
for (const row of [
|
|
{
|
|
id: 201,
|
|
guid: "STALE-BALLOON-GUID-201",
|
|
text: "old handwriting",
|
|
balloon_bundle_id: "com.apple.messages.HandwritingProvider",
|
|
created_at: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
|
|
},
|
|
{
|
|
id: 202,
|
|
guid: "LIVE-GUID-202",
|
|
text: "first fresh thought",
|
|
created_at: new Date(Date.now() - 2000).toISOString(),
|
|
},
|
|
{
|
|
id: 203,
|
|
guid: "LIVE-GUID-203",
|
|
text: "second fresh thought",
|
|
created_at: new Date(Date.now() - 1000).toISOString(),
|
|
},
|
|
]) {
|
|
onNotification?.({
|
|
method: "message",
|
|
params: {
|
|
message: {
|
|
...row,
|
|
chat_id: 123,
|
|
sender: "+15550001111",
|
|
is_from_me: false,
|
|
is_group: false,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
await vi.waitFor(() => {
|
|
expect(debouncerControl.flush).toBeDefined();
|
|
});
|
|
await debouncerControl.flush?.();
|
|
await Promise.resolve();
|
|
}),
|
|
stop: vi.fn(async () => {}),
|
|
};
|
|
createIMessageRpcClientMock.mockImplementation(async (params) => {
|
|
if (!params?.onNotification) {
|
|
throw new Error("expected iMessage notification handler");
|
|
}
|
|
onNotification = params.onNotification;
|
|
return client as never;
|
|
});
|
|
|
|
await monitorIMessageProvider({
|
|
config: {
|
|
channels: {
|
|
imessage: {
|
|
coalesceSameSenderDms: true,
|
|
dbPath: path.join(os.tmpdir(), `openclaw-missing-chat-${Date.now()}.db`),
|
|
dmPolicy: "allowlist",
|
|
allowFrom: ["+15550001111"],
|
|
sendReadReceipts: false,
|
|
},
|
|
},
|
|
session: { mainKey: "main" },
|
|
} as never,
|
|
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
|
|
});
|
|
|
|
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(2);
|
|
const bodies = dispatchInboundMessageMock.mock.calls.map((call) => call[0].ctx.Body ?? "");
|
|
expect(bodies.some((body) => body.includes("old handwriting"))).toBe(false);
|
|
expect(bodies.some((body) => body.includes("first fresh thought"))).toBe(true);
|
|
expect(bodies.some((body) => body.includes("second fresh thought"))).toBe(true);
|
|
});
|
|
|
|
it("does not merge unrelated buffered rows into a following URL split-send", async () => {
|
|
debouncerControl.holdEntries = true;
|
|
|
|
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
|
|
const client = {
|
|
request: vi.fn(async (method: string) => {
|
|
if (method === "watch.subscribe") {
|
|
return { subscription: 1 };
|
|
}
|
|
throw new Error(`unexpected imsg method ${method}`);
|
|
}),
|
|
waitForClose: vi.fn(async () => {
|
|
for (const row of [
|
|
{
|
|
id: 111,
|
|
guid: "LIVE-GUID-111",
|
|
text: "unrelated thought",
|
|
created_at: new Date(Date.now() - 3000).toISOString(),
|
|
},
|
|
{
|
|
id: 112,
|
|
guid: "LIVE-GUID-112",
|
|
text: "summarize",
|
|
created_at: new Date(Date.now() - 2000).toISOString(),
|
|
},
|
|
{
|
|
id: 113,
|
|
guid: "LIVE-GUID-113",
|
|
text: "https://example.com/article",
|
|
balloon_bundle_id: "com.apple.messages.URLBalloonProvider",
|
|
created_at: new Date(Date.now() - 1000).toISOString(),
|
|
},
|
|
]) {
|
|
onNotification?.({
|
|
method: "message",
|
|
params: {
|
|
message: {
|
|
...row,
|
|
chat_id: 123,
|
|
sender: "+15550001111",
|
|
is_from_me: false,
|
|
is_group: false,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
await vi.waitFor(() => {
|
|
expect(debouncerControl.flush).toBeDefined();
|
|
});
|
|
await debouncerControl.flush?.();
|
|
await Promise.resolve();
|
|
}),
|
|
stop: vi.fn(async () => {}),
|
|
};
|
|
createIMessageRpcClientMock.mockImplementation(async (params) => {
|
|
if (!params?.onNotification) {
|
|
throw new Error("expected iMessage notification handler");
|
|
}
|
|
onNotification = params.onNotification;
|
|
return client as never;
|
|
});
|
|
|
|
await monitorIMessageProvider({
|
|
config: {
|
|
channels: {
|
|
imessage: {
|
|
coalesceSameSenderDms: true,
|
|
dmPolicy: "allowlist",
|
|
allowFrom: ["+15550001111"],
|
|
sendReadReceipts: false,
|
|
},
|
|
},
|
|
session: { mainKey: "main" },
|
|
} as never,
|
|
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
|
|
});
|
|
|
|
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(2);
|
|
const bodies = dispatchInboundMessageMock.mock.calls.map((call) => call[0].ctx.Body ?? "");
|
|
expect(bodies[0]).toContain("unrelated thought");
|
|
expect(bodies[0]).not.toContain("summarize");
|
|
expect(bodies[1]).toContain("summarize");
|
|
expect(bodies[1]).toContain("https://example.com/article");
|
|
expect(bodies[1]).not.toContain("unrelated thought");
|
|
});
|
|
|
|
it("does not merge unrelated buffered rows into an already-complete URL balloon message", async () => {
|
|
debouncerControl.holdEntries = true;
|
|
|
|
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
|
|
const client = {
|
|
request: vi.fn(async (method: string) => {
|
|
if (method === "watch.subscribe") {
|
|
return { subscription: 1 };
|
|
}
|
|
throw new Error(`unexpected imsg method ${method}`);
|
|
}),
|
|
waitForClose: vi.fn(async () => {
|
|
for (const row of [
|
|
{
|
|
id: 211,
|
|
guid: "LIVE-GUID-211",
|
|
text: "unrelated thought",
|
|
created_at: new Date(Date.now() - 2000).toISOString(),
|
|
},
|
|
{
|
|
id: 212,
|
|
guid: "LIVE-GUID-212",
|
|
text: "summarize https://example.com/article",
|
|
balloon_bundle_id: "com.apple.messages.URLBalloonProvider",
|
|
created_at: new Date(Date.now() - 1000).toISOString(),
|
|
},
|
|
]) {
|
|
onNotification?.({
|
|
method: "message",
|
|
params: {
|
|
message: {
|
|
...row,
|
|
chat_id: 123,
|
|
sender: "+15550001111",
|
|
is_from_me: false,
|
|
is_group: false,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
await vi.waitFor(() => {
|
|
expect(debouncerControl.flush).toBeDefined();
|
|
});
|
|
await debouncerControl.flush?.();
|
|
await Promise.resolve();
|
|
}),
|
|
stop: vi.fn(async () => {}),
|
|
};
|
|
createIMessageRpcClientMock.mockImplementation(async (params) => {
|
|
if (!params?.onNotification) {
|
|
throw new Error("expected iMessage notification handler");
|
|
}
|
|
onNotification = params.onNotification;
|
|
return client as never;
|
|
});
|
|
|
|
await monitorIMessageProvider({
|
|
config: {
|
|
channels: {
|
|
imessage: {
|
|
coalesceSameSenderDms: true,
|
|
dmPolicy: "allowlist",
|
|
allowFrom: ["+15550001111"],
|
|
sendReadReceipts: false,
|
|
},
|
|
},
|
|
session: { mainKey: "main" },
|
|
} as never,
|
|
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
|
|
});
|
|
|
|
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(2);
|
|
const bodies = dispatchInboundMessageMock.mock.calls.map((call) => call[0].ctx.Body ?? "");
|
|
expect(bodies[0]).toContain("unrelated thought");
|
|
expect(bodies[0]).not.toContain("summarize");
|
|
expect(bodies[1]).toContain("summarize");
|
|
expect(bodies[1]).toContain("https://example.com/article");
|
|
expect(bodies[1]).not.toContain("unrelated thought");
|
|
});
|
|
|
|
it("respects explicit iMessage inbound debounce timing", async () => {
|
|
const client = {
|
|
request: vi.fn(async (method: string) => {
|
|
if (method === "watch.subscribe") {
|
|
return { subscription: 1 };
|
|
}
|
|
throw new Error(`unexpected imsg method ${method}`);
|
|
}),
|
|
waitForClose: vi.fn(async () => {}),
|
|
stop: vi.fn(async () => {}),
|
|
};
|
|
createIMessageRpcClientMock.mockImplementation(async () => client as never);
|
|
|
|
await monitorIMessageProvider({
|
|
config: {
|
|
channels: {
|
|
imessage: {
|
|
coalesceSameSenderDms: true,
|
|
dmPolicy: "allowlist",
|
|
allowFrom: ["+15550001111"],
|
|
sendReadReceipts: false,
|
|
},
|
|
},
|
|
messages: { inbound: { byChannel: { imessage: 0 } } },
|
|
session: { mainKey: "main" },
|
|
} as never,
|
|
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
|
|
});
|
|
|
|
const debouncerOptions = createChannelInboundDebouncerMock.mock.calls.at(-1)?.[0] as
|
|
| { debounceMsOverride?: number }
|
|
| undefined;
|
|
expect(debouncerOptions?.debounceMsOverride).toBeUndefined();
|
|
});
|
|
});
|