Files
openclaw/src/telegram/thread-bindings.test.ts
Vincent Koc 4ca84acf24 fix(runtime): duplicate messages, share singleton state across bundled chunks (#43683)
* Tests: add fresh module import helper

* Process: share command queue runtime state

* Agents: share embedded run runtime state

* Reply: share followup queue runtime state

* Reply: share followup drain callback state

* Reply: share queued message dedupe state

* Reply: share inbound dedupe state

* Tests: cover shared command queue runtime state

* Tests: cover shared embedded run runtime state

* Tests: cover shared followup queue runtime state

* Tests: cover shared inbound dedupe state

* Tests: cover shared Slack thread participation state

* Slack: share sent thread participation state

* Tests: document fresh import helper

* Telegram: share draft stream runtime state

* Tests: cover shared Telegram draft stream state

* Telegram: share sent message cache state

* Tests: cover shared Telegram sent message cache

* Telegram: share thread binding runtime state

* Tests: cover shared Telegram thread binding state

* Tests: avoid duplicate shared queue reset

* refactor(runtime): centralize global singleton access

* refactor(runtime): preserve undefined global singleton values

* test(runtime): cover undefined global singleton values

---------

Co-authored-by: Nimrod Gutman <nimrod.gutman@gmail.com>
2026-03-12 14:59:27 -04:00

215 lines
6.8 KiB
TypeScript

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { importFreshModule } from "../../test/helpers/import-fresh.js";
import { resolveStateDir } from "../config/paths.js";
import { getSessionBindingService } from "../infra/outbound/session-binding-service.js";
import {
__testing,
createTelegramThreadBindingManager,
setTelegramThreadBindingIdleTimeoutBySessionKey,
setTelegramThreadBindingMaxAgeBySessionKey,
} from "./thread-bindings.js";
describe("telegram thread bindings", () => {
let stateDirOverride: string | undefined;
beforeEach(() => {
__testing.resetTelegramThreadBindingsForTests();
});
afterEach(() => {
vi.useRealTimers();
if (stateDirOverride) {
delete process.env.OPENCLAW_STATE_DIR;
fs.rmSync(stateDirOverride, { recursive: true, force: true });
stateDirOverride = undefined;
}
});
it("registers a telegram binding adapter and binds current conversations", async () => {
const manager = createTelegramThreadBindingManager({
accountId: "work",
persist: false,
enableSweeper: false,
idleTimeoutMs: 30_000,
maxAgeMs: 0,
});
const bound = await getSessionBindingService().bind({
targetSessionKey: "agent:main:subagent:child-1",
targetKind: "subagent",
conversation: {
channel: "telegram",
accountId: "work",
conversationId: "-100200300:topic:77",
},
placement: "current",
metadata: {
boundBy: "user-1",
},
});
expect(bound.conversation.channel).toBe("telegram");
expect(bound.conversation.accountId).toBe("work");
expect(bound.conversation.conversationId).toBe("-100200300:topic:77");
expect(bound.targetSessionKey).toBe("agent:main:subagent:child-1");
expect(manager.getByConversationId("-100200300:topic:77")?.boundBy).toBe("user-1");
});
it("does not support child placement", async () => {
createTelegramThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
});
await expect(
getSessionBindingService().bind({
targetSessionKey: "agent:main:subagent:child-1",
targetKind: "subagent",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "-100200300:topic:77",
},
placement: "child",
}),
).rejects.toMatchObject({
code: "BINDING_CAPABILITY_UNSUPPORTED",
});
});
it("shares binding state across distinct module instances", async () => {
const bindingsA = await importFreshModule<typeof import("./thread-bindings.js")>(
import.meta.url,
"./thread-bindings.js?scope=shared-a",
);
const bindingsB = await importFreshModule<typeof import("./thread-bindings.js")>(
import.meta.url,
"./thread-bindings.js?scope=shared-b",
);
bindingsA.__testing.resetTelegramThreadBindingsForTests();
try {
const managerA = bindingsA.createTelegramThreadBindingManager({
accountId: "shared-runtime",
persist: false,
enableSweeper: false,
});
const managerB = bindingsB.createTelegramThreadBindingManager({
accountId: "shared-runtime",
persist: false,
enableSweeper: false,
});
expect(managerB).toBe(managerA);
await getSessionBindingService().bind({
targetSessionKey: "agent:main:subagent:child-shared",
targetKind: "subagent",
conversation: {
channel: "telegram",
accountId: "shared-runtime",
conversationId: "-100200300:topic:44",
},
placement: "current",
});
expect(
bindingsB
.getTelegramThreadBindingManager("shared-runtime")
?.getByConversationId("-100200300:topic:44")?.targetSessionKey,
).toBe("agent:main:subagent:child-shared");
} finally {
bindingsA.__testing.resetTelegramThreadBindingsForTests();
}
});
it("updates lifecycle windows by session key", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-06T10:00:00.000Z"));
const manager = createTelegramThreadBindingManager({
accountId: "work",
persist: false,
enableSweeper: false,
});
await getSessionBindingService().bind({
targetSessionKey: "agent:main:subagent:child-1",
targetKind: "subagent",
conversation: {
channel: "telegram",
accountId: "work",
conversationId: "1234",
},
});
const original = manager.listBySessionKey("agent:main:subagent:child-1")[0];
expect(original).toBeDefined();
const idleUpdated = setTelegramThreadBindingIdleTimeoutBySessionKey({
accountId: "work",
targetSessionKey: "agent:main:subagent:child-1",
idleTimeoutMs: 2 * 60 * 60 * 1000,
});
vi.setSystemTime(new Date("2026-03-06T12:00:00.000Z"));
const maxAgeUpdated = setTelegramThreadBindingMaxAgeBySessionKey({
accountId: "work",
targetSessionKey: "agent:main:subagent:child-1",
maxAgeMs: 6 * 60 * 60 * 1000,
});
expect(idleUpdated).toHaveLength(1);
expect(idleUpdated[0]?.idleTimeoutMs).toBe(2 * 60 * 60 * 1000);
expect(maxAgeUpdated).toHaveLength(1);
expect(maxAgeUpdated[0]?.maxAgeMs).toBe(6 * 60 * 60 * 1000);
expect(maxAgeUpdated[0]?.boundAt).toBe(original?.boundAt);
expect(maxAgeUpdated[0]?.lastActivityAt).toBe(Date.parse("2026-03-06T12:00:00.000Z"));
expect(manager.listBySessionKey("agent:main:subagent:child-1")[0]?.maxAgeMs).toBe(
6 * 60 * 60 * 1000,
);
});
it("does not persist lifecycle updates when manager persistence is disabled", async () => {
stateDirOverride = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-telegram-bindings-"));
process.env.OPENCLAW_STATE_DIR = stateDirOverride;
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-06T10:00:00.000Z"));
createTelegramThreadBindingManager({
accountId: "no-persist",
persist: false,
enableSweeper: false,
});
await getSessionBindingService().bind({
targetSessionKey: "agent:main:subagent:child-2",
targetKind: "subagent",
conversation: {
channel: "telegram",
accountId: "no-persist",
conversationId: "-100200300:topic:88",
},
});
setTelegramThreadBindingIdleTimeoutBySessionKey({
accountId: "no-persist",
targetSessionKey: "agent:main:subagent:child-2",
idleTimeoutMs: 60 * 60 * 1000,
});
setTelegramThreadBindingMaxAgeBySessionKey({
accountId: "no-persist",
targetSessionKey: "agent:main:subagent:child-2",
maxAgeMs: 2 * 60 * 60 * 1000,
});
const statePath = path.join(
resolveStateDir(process.env, os.homedir),
"telegram",
"thread-bindings-no-persist.json",
);
expect(fs.existsSync(statePath)).toBe(false);
});
});