Files
openclaw/src/auto-reply/reply/commands-session-lifecycle.test.ts
Harold Hunt d58dafae88 feat(telegram/acp): Topic Binding, Pin Binding Message, Fix Spawn Param Parsing (#36683)
* fix(acp): normalize unicode flags and Telegram topic binding

* feat(telegram/acp): restore topic-bound ACP and session bindings

* fix(acpx): clarify permission-denied guidance

* feat(telegram/acp): pin spawn bind notice in topics

* docs(telegram): document ACP topic thread binding behavior

* refactor(reply): share Telegram conversation-id resolver

* fix(telegram/acp): preserve bound session routing semantics

* fix(telegram): respect binding persistence and expiry reporting

* refactor(telegram): simplify binding lifecycle persistence

* fix(telegram): bind acp spawns in direct messages

* fix: document telegram ACP topic binding changelog (#36683) (thanks @huntharo)

---------

Co-authored-by: Onur <2453968+osolmaz@users.noreply.github.com>
2026-03-06 02:17:50 +01:00

333 lines
11 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js";
const hoisted = vi.hoisted(() => {
const getThreadBindingManagerMock = vi.fn();
const setThreadBindingIdleTimeoutBySessionKeyMock = vi.fn();
const setThreadBindingMaxAgeBySessionKeyMock = vi.fn();
const setTelegramThreadBindingIdleTimeoutBySessionKeyMock = vi.fn();
const setTelegramThreadBindingMaxAgeBySessionKeyMock = vi.fn();
const sessionBindingResolveByConversationMock = vi.fn();
return {
getThreadBindingManagerMock,
setThreadBindingIdleTimeoutBySessionKeyMock,
setThreadBindingMaxAgeBySessionKeyMock,
setTelegramThreadBindingIdleTimeoutBySessionKeyMock,
setTelegramThreadBindingMaxAgeBySessionKeyMock,
sessionBindingResolveByConversationMock,
};
});
vi.mock("../../discord/monitor/thread-bindings.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../discord/monitor/thread-bindings.js")>();
return {
...actual,
getThreadBindingManager: hoisted.getThreadBindingManagerMock,
setThreadBindingIdleTimeoutBySessionKey: hoisted.setThreadBindingIdleTimeoutBySessionKeyMock,
setThreadBindingMaxAgeBySessionKey: hoisted.setThreadBindingMaxAgeBySessionKeyMock,
};
});
vi.mock("../../telegram/thread-bindings.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../telegram/thread-bindings.js")>();
return {
...actual,
setTelegramThreadBindingIdleTimeoutBySessionKey:
hoisted.setTelegramThreadBindingIdleTimeoutBySessionKeyMock,
setTelegramThreadBindingMaxAgeBySessionKey:
hoisted.setTelegramThreadBindingMaxAgeBySessionKeyMock,
};
});
vi.mock("../../infra/outbound/session-binding-service.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../infra/outbound/session-binding-service.js")>();
return {
...actual,
getSessionBindingService: () => ({
bind: vi.fn(),
getCapabilities: vi.fn(),
listBySession: vi.fn(),
resolveByConversation: (ref: unknown) => hoisted.sessionBindingResolveByConversationMock(ref),
touch: vi.fn(),
unbind: vi.fn(),
}),
};
});
const { handleSessionCommand } = await import("./commands-session.js");
const { buildCommandTestParams } = await import("./commands.test-harness.js");
const baseCfg = {
session: { mainKey: "main", scope: "per-sender" },
} satisfies OpenClawConfig;
type FakeBinding = {
accountId: string;
channelId: string;
threadId: string;
targetKind: "subagent" | "acp";
targetSessionKey: string;
agentId: string;
boundBy: string;
boundAt: number;
lastActivityAt: number;
idleTimeoutMs?: number;
maxAgeMs?: number;
};
function createDiscordCommandParams(commandBody: string, overrides?: Record<string, unknown>) {
return buildCommandTestParams(commandBody, baseCfg, {
Provider: "discord",
Surface: "discord",
OriginatingChannel: "discord",
OriginatingTo: "channel:thread-1",
AccountId: "default",
MessageThreadId: "thread-1",
...overrides,
});
}
function createTelegramCommandParams(commandBody: string, overrides?: Record<string, unknown>) {
return buildCommandTestParams(commandBody, baseCfg, {
Provider: "telegram",
Surface: "telegram",
OriginatingChannel: "telegram",
OriginatingTo: "-100200300:topic:77",
AccountId: "default",
MessageThreadId: "77",
...overrides,
});
}
function createFakeBinding(overrides: Partial<FakeBinding> = {}): FakeBinding {
const now = Date.now();
return {
accountId: "default",
channelId: "parent-1",
threadId: "thread-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child",
agentId: "main",
boundBy: "user-1",
boundAt: now,
lastActivityAt: now,
...overrides,
};
}
function createTelegramBinding(overrides?: Partial<SessionBindingRecord>): SessionBindingRecord {
return {
bindingId: "default:-100200300:topic:77",
targetSessionKey: "agent:main:subagent:child",
targetKind: "subagent",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "-100200300:topic:77",
},
status: "active",
boundAt: Date.now(),
metadata: {
boundBy: "user-1",
lastActivityAt: Date.now(),
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
},
...overrides,
};
}
function createFakeThreadBindingManager(binding: FakeBinding | null) {
return {
getByThreadId: vi.fn((_threadId: string) => binding),
getIdleTimeoutMs: vi.fn(() => 24 * 60 * 60 * 1000),
getMaxAgeMs: vi.fn(() => 0),
};
}
describe("/session idle and /session max-age", () => {
beforeEach(() => {
hoisted.getThreadBindingManagerMock.mockReset();
hoisted.setThreadBindingIdleTimeoutBySessionKeyMock.mockReset();
hoisted.setThreadBindingMaxAgeBySessionKeyMock.mockReset();
hoisted.setTelegramThreadBindingIdleTimeoutBySessionKeyMock.mockReset();
hoisted.setTelegramThreadBindingMaxAgeBySessionKeyMock.mockReset();
hoisted.sessionBindingResolveByConversationMock.mockReset().mockReturnValue(null);
vi.useRealTimers();
});
it("sets idle timeout for the focused Discord session", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
const binding = createFakeBinding();
hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding));
hoisted.setThreadBindingIdleTimeoutBySessionKeyMock.mockReturnValue([
{
...binding,
lastActivityAt: Date.now(),
idleTimeoutMs: 2 * 60 * 60 * 1000,
},
]);
const result = await handleSessionCommand(createDiscordCommandParams("/session idle 2h"), true);
const text = result?.reply?.text ?? "";
expect(hoisted.setThreadBindingIdleTimeoutBySessionKeyMock).toHaveBeenCalledWith({
targetSessionKey: "agent:main:subagent:child",
accountId: "default",
idleTimeoutMs: 2 * 60 * 60 * 1000,
});
expect(text).toContain("Idle timeout set to 2h");
expect(text).toContain("2026-02-20T02:00:00.000Z");
});
it("shows active idle timeout when no value is provided", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
const binding = createFakeBinding({
idleTimeoutMs: 2 * 60 * 60 * 1000,
lastActivityAt: Date.now(),
});
hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding));
const result = await handleSessionCommand(createDiscordCommandParams("/session idle"), true);
expect(result?.reply?.text).toContain("Idle timeout active (2h");
expect(result?.reply?.text).toContain("2026-02-20T02:00:00.000Z");
});
it("sets max age for the focused Discord session", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
const binding = createFakeBinding();
hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding));
hoisted.setThreadBindingMaxAgeBySessionKeyMock.mockReturnValue([
{
...binding,
boundAt: Date.now(),
maxAgeMs: 3 * 60 * 60 * 1000,
},
]);
const result = await handleSessionCommand(
createDiscordCommandParams("/session max-age 3h"),
true,
);
const text = result?.reply?.text ?? "";
expect(hoisted.setThreadBindingMaxAgeBySessionKeyMock).toHaveBeenCalledWith({
targetSessionKey: "agent:main:subagent:child",
accountId: "default",
maxAgeMs: 3 * 60 * 60 * 1000,
});
expect(text).toContain("Max age set to 3h");
expect(text).toContain("2026-02-20T03:00:00.000Z");
});
it("sets idle timeout for focused Telegram conversations", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(createTelegramBinding());
hoisted.setTelegramThreadBindingIdleTimeoutBySessionKeyMock.mockReturnValue([
{
targetSessionKey: "agent:main:subagent:child",
boundAt: Date.now(),
lastActivityAt: Date.now(),
idleTimeoutMs: 2 * 60 * 60 * 1000,
},
]);
const result = await handleSessionCommand(
createTelegramCommandParams("/session idle 2h"),
true,
);
const text = result?.reply?.text ?? "";
expect(hoisted.setTelegramThreadBindingIdleTimeoutBySessionKeyMock).toHaveBeenCalledWith({
targetSessionKey: "agent:main:subagent:child",
accountId: "default",
idleTimeoutMs: 2 * 60 * 60 * 1000,
});
expect(text).toContain("Idle timeout set to 2h");
expect(text).toContain("2026-02-20T02:00:00.000Z");
});
it("reports Telegram max-age expiry from the original bind time", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
const boundAt = Date.parse("2026-02-19T22:00:00.000Z");
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
createTelegramBinding({ boundAt }),
);
hoisted.setTelegramThreadBindingMaxAgeBySessionKeyMock.mockReturnValue([
{
targetSessionKey: "agent:main:subagent:child",
boundAt,
lastActivityAt: Date.now(),
maxAgeMs: 3 * 60 * 60 * 1000,
},
]);
const result = await handleSessionCommand(
createTelegramCommandParams("/session max-age 3h"),
true,
);
const text = result?.reply?.text ?? "";
expect(hoisted.setTelegramThreadBindingMaxAgeBySessionKeyMock).toHaveBeenCalledWith({
targetSessionKey: "agent:main:subagent:child",
accountId: "default",
maxAgeMs: 3 * 60 * 60 * 1000,
});
expect(text).toContain("Max age set to 3h");
expect(text).toContain("2026-02-20T01:00:00.000Z");
});
it("disables max age when set to off", async () => {
const binding = createFakeBinding({ maxAgeMs: 2 * 60 * 60 * 1000 });
hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding));
hoisted.setThreadBindingMaxAgeBySessionKeyMock.mockReturnValue([{ ...binding, maxAgeMs: 0 }]);
const result = await handleSessionCommand(
createDiscordCommandParams("/session max-age off"),
true,
);
expect(hoisted.setThreadBindingMaxAgeBySessionKeyMock).toHaveBeenCalledWith({
targetSessionKey: "agent:main:subagent:child",
accountId: "default",
maxAgeMs: 0,
});
expect(result?.reply?.text).toContain("Max age disabled");
});
it("is unavailable outside discord and telegram", async () => {
const params = buildCommandTestParams("/session idle 2h", baseCfg);
const result = await handleSessionCommand(params, true);
expect(result?.reply?.text).toContain(
"currently available for Discord and Telegram bound sessions",
);
});
it("requires binding owner for lifecycle updates", async () => {
const binding = createFakeBinding({ boundBy: "owner-1" });
hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding));
const result = await handleSessionCommand(
createDiscordCommandParams("/session idle 2h", {
SenderId: "other-user",
}),
true,
);
expect(hoisted.setThreadBindingIdleTimeoutBySessionKeyMock).not.toHaveBeenCalled();
expect(result?.reply?.text).toContain("Only owner-1 can update session lifecycle settings");
});
});