chore: fork @llblab/[email protected].0 as @evandrodevbr/oh-my-pi-telegram
Fork of upstream llblab/[email protected].0 (SHA afe09c5) repackaged as a drop-in extension for can1357/oh-my-pi (omp >= 16.5.2). Changes vs upstream: - Rename package: @llblab/pi-telegram -> @evandrodevbr/oh-my-pi-telegram - Bump version: 0.22.0 -> 0.22.0-evandro.1 - Update repository/homepage/bugs URLs to evandrodevbr - Add omp field in package.json (displayName + compatibleWith) - README: prepend 'Oh My Pi (omp) Compatibility' section with install commands, verified-components table, upstream lineage, and rationale - CHANGELOG: add '0.22.0-evandro.1' entry under Unreleased - docs/: rename @llblab/pi-telegram imports to @evandrodevbr/oh-my-pi-telegram - tests/public-api.test.ts: rename package import paths Gates re-run on the fork: - npm run typecheck -> tsc --noEmit (success) - npm test -> 1229 pass / 0 fail / 1 platform-only skip - npm run audit -> found 0 vulnerabilities - npm run pack:check -> evandrodevbr-oh-my-pi-telegram-0.22.0-evandro.1.tgz Verified: - omp install --link ./pi-telegram -> Linked OK - omp plugin list -> @evandrodevbr/[email protected].0-evandro.1 Upstream lineage: badlogic/pi-telegram -> llblab/pi-telegram (0.22.0) -> evandrodevbr/oh-my-pi-telegram (this fork)
This commit is contained in:
commit
d1d04001d0
155 files changed
+107848
No files matched your search
@@ -0,0 +1,546 @@
|
||||
/**
|
||||
* Telegram activity API domain regressions
|
||||
* Zones: pi agent lifecycle, extension API, operational delivery
|
||||
* Mirrors lib/activity.ts and protects registration, normalization, source identity, coalescing, delivery contexts, and shutdown fencing
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
clearTelegramActivityHandlers,
|
||||
createTelegramActivityBridgeRuntime,
|
||||
createTelegramActivityDispatcher,
|
||||
createTelegramActivityRuntime,
|
||||
registerTelegramActivityHandler,
|
||||
type TelegramActivityEvent,
|
||||
} from "../lib/activity.ts";
|
||||
import {
|
||||
bindTelegramDeliveryRuntime,
|
||||
clearTelegramDeliveryRuntime,
|
||||
type TelegramDeliveryRuntime,
|
||||
} from "../lib/delivery.ts";
|
||||
|
||||
function waitForActivityDispatch(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function createEvent(
|
||||
type: "agent-start" | "agent-end" | "agent-settled",
|
||||
sequence = 1,
|
||||
): TelegramActivityEvent {
|
||||
return {
|
||||
type,
|
||||
activityId: "activity-one",
|
||||
sequence,
|
||||
source: "local",
|
||||
timestamp: sequence,
|
||||
};
|
||||
}
|
||||
|
||||
test.afterEach(() => {
|
||||
clearTelegramActivityHandlers();
|
||||
clearTelegramDeliveryRuntime();
|
||||
});
|
||||
|
||||
test("Activity registry orders stable ids and rejects duplicates", async () => {
|
||||
const events: string[] = [];
|
||||
registerTelegramActivityHandler({
|
||||
id: "second",
|
||||
order: 10,
|
||||
handle: () => {
|
||||
events.push("second");
|
||||
},
|
||||
});
|
||||
const disposeFirst = registerTelegramActivityHandler({
|
||||
id: "first",
|
||||
order: 0,
|
||||
handle: () => {
|
||||
events.push("first");
|
||||
},
|
||||
});
|
||||
assert.throws(
|
||||
() =>
|
||||
registerTelegramActivityHandler({
|
||||
id: "first",
|
||||
handle: () => {},
|
||||
}),
|
||||
/already registered/,
|
||||
);
|
||||
const dispatcher = createTelegramActivityDispatcher();
|
||||
dispatcher.dispatch(createEvent("agent-start"));
|
||||
await waitForActivityDispatch();
|
||||
assert.deepEqual(events, ["first", "second"]);
|
||||
disposeFirst();
|
||||
dispatcher.dispatch(createEvent("agent-end", 2));
|
||||
await waitForActivityDispatch();
|
||||
assert.deepEqual(events, ["first", "second", "second"]);
|
||||
});
|
||||
|
||||
test("Activity dispatcher is non-blocking and isolates handler failures", async () => {
|
||||
let release: (() => void) | undefined;
|
||||
const events: string[] = [];
|
||||
const failures: string[] = [];
|
||||
registerTelegramActivityHandler({
|
||||
id: "slow",
|
||||
handle: async (event) => {
|
||||
events.push(`${event.type}:start`);
|
||||
await new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
events.push(`${event.type}:end`);
|
||||
},
|
||||
});
|
||||
registerTelegramActivityHandler({
|
||||
id: "broken",
|
||||
handle: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
});
|
||||
const dispatcher = createTelegramActivityDispatcher({
|
||||
recordFailure: (id, event) => failures.push(`${id}:${event.type}`),
|
||||
});
|
||||
dispatcher.dispatch(createEvent("agent-start"));
|
||||
await waitForActivityDispatch();
|
||||
assert.deepEqual(events, ["agent-start:start"]);
|
||||
assert.deepEqual(failures, ["broken:agent-start"]);
|
||||
release?.();
|
||||
await waitForActivityDispatch();
|
||||
assert.deepEqual(events, ["agent-start:start", "agent-start:end"]);
|
||||
});
|
||||
|
||||
test("Activity dispatcher coalesces adjacent deltas but preserves boundaries", async () => {
|
||||
const received: TelegramActivityEvent[] = [];
|
||||
registerTelegramActivityHandler({
|
||||
id: "capture",
|
||||
handle: (event) => {
|
||||
received.push(event);
|
||||
},
|
||||
});
|
||||
const dispatcher = createTelegramActivityDispatcher();
|
||||
dispatcher.dispatch({
|
||||
type: "assistant-text-delta",
|
||||
activityId: "activity-one",
|
||||
sequence: 1,
|
||||
source: "telegram",
|
||||
timestamp: 1,
|
||||
contentIndex: 0,
|
||||
delta: "hel",
|
||||
});
|
||||
dispatcher.dispatch({
|
||||
type: "assistant-text-delta",
|
||||
activityId: "activity-one",
|
||||
sequence: 2,
|
||||
source: "telegram",
|
||||
timestamp: 2,
|
||||
contentIndex: 0,
|
||||
delta: "lo",
|
||||
});
|
||||
dispatcher.dispatch({
|
||||
...createEvent("agent-end", 3),
|
||||
activityId: "activity-one",
|
||||
source: "telegram",
|
||||
});
|
||||
await waitForActivityDispatch();
|
||||
assert.equal(received.length, 2);
|
||||
assert.equal(received[0]?.type, "assistant-text-delta");
|
||||
if (received[0]?.type === "assistant-text-delta") {
|
||||
assert.equal(received[0].delta, "hello");
|
||||
assert.equal(received[0].sequence, 2);
|
||||
}
|
||||
assert.equal(received[1]?.type, "agent-end");
|
||||
});
|
||||
|
||||
test("Activity normalizer classifies source and assistant segment placement", async () => {
|
||||
const received: TelegramActivityEvent[] = [];
|
||||
registerTelegramActivityHandler({
|
||||
id: "capture",
|
||||
handle: (event) => {
|
||||
received.push(event);
|
||||
},
|
||||
});
|
||||
const dispatcher = createTelegramActivityDispatcher();
|
||||
const runtime = createTelegramActivityRuntime({
|
||||
generation: "generation-one",
|
||||
dispatcher,
|
||||
now: () => 10,
|
||||
});
|
||||
runtime.recordInputSource("extension");
|
||||
runtime.onAgentStart({ chatId: 42, threadId: 7 });
|
||||
runtime.onAssistantEvent({
|
||||
type: "text_end",
|
||||
contentIndex: 0,
|
||||
content: "I will inspect this.",
|
||||
});
|
||||
runtime.onAssistantEvent({ type: "toolcall_start", contentIndex: 1 });
|
||||
runtime.onAssistantEvent({
|
||||
type: "thinking_delta",
|
||||
contentIndex: 2,
|
||||
delta: "reason",
|
||||
});
|
||||
runtime.onAssistantEvent({
|
||||
type: "thinking_end",
|
||||
contentIndex: 2,
|
||||
content: "reasoning complete",
|
||||
});
|
||||
runtime.onAssistantEvent({
|
||||
type: "text_end",
|
||||
contentIndex: 3,
|
||||
content: "Final answer.",
|
||||
});
|
||||
runtime.onAssistantEvent({ type: "done" });
|
||||
runtime.onAgentEnd();
|
||||
runtime.onAgentSettled();
|
||||
await waitForActivityDispatch();
|
||||
assert.equal(received[0]?.source, "telegram");
|
||||
assert.deepEqual(received[0]?.target, { chatId: 42, threadId: 7 });
|
||||
assert.equal(Object.isFrozen(received[0]?.target), true);
|
||||
assert.deepEqual(
|
||||
received.map((event) => event.type),
|
||||
[
|
||||
"agent-start",
|
||||
"assistant-segment",
|
||||
"reasoning-delta",
|
||||
"reasoning-end",
|
||||
"assistant-segment",
|
||||
"agent-end",
|
||||
"agent-settled",
|
||||
],
|
||||
);
|
||||
const segments = received.filter(
|
||||
(event): event is Extract<TelegramActivityEvent, { type: "assistant-segment" }> =>
|
||||
event.type === "assistant-segment",
|
||||
);
|
||||
assert.deepEqual(
|
||||
segments.map((event) => event.placement),
|
||||
["intermediate", "final"],
|
||||
);
|
||||
assert.equal(new Set(received.map((event) => event.activityId)).size, 1);
|
||||
});
|
||||
|
||||
test("Activity normalizer distinguishes local, autonomous, and unknown activities", async () => {
|
||||
const sources: string[] = [];
|
||||
registerTelegramActivityHandler({
|
||||
id: "capture",
|
||||
handle: (event) => {
|
||||
if (event.type === "agent-start") sources.push(event.source);
|
||||
},
|
||||
});
|
||||
const dispatcher = createTelegramActivityDispatcher();
|
||||
const runtime = createTelegramActivityRuntime({
|
||||
generation: "generation-one",
|
||||
dispatcher,
|
||||
});
|
||||
runtime.recordInputSource("interactive");
|
||||
runtime.onAgentStart();
|
||||
runtime.onAgentSettled();
|
||||
runtime.recordInputSource("extension");
|
||||
runtime.onAgentStart();
|
||||
runtime.onAgentSettled();
|
||||
runtime.onAgentStart();
|
||||
runtime.onAgentSettled();
|
||||
await waitForActivityDispatch();
|
||||
assert.deepEqual(sources, ["local", "autonomous", "unknown"]);
|
||||
});
|
||||
|
||||
test("Activity normalizer abandons standalone compaction before the next run", async () => {
|
||||
const received: TelegramActivityEvent[] = [];
|
||||
registerTelegramActivityHandler({
|
||||
id: "capture",
|
||||
handle: (event) => {
|
||||
received.push(event);
|
||||
},
|
||||
});
|
||||
const dispatcher = createTelegramActivityDispatcher();
|
||||
const runtime = createTelegramActivityRuntime({
|
||||
generation: "generation-one",
|
||||
dispatcher,
|
||||
});
|
||||
|
||||
runtime.onCompactionStart("manual");
|
||||
runtime.recordInputSource("interactive");
|
||||
runtime.onAgentStart();
|
||||
runtime.onCompactionEnd("manual");
|
||||
await waitForActivityDispatch();
|
||||
|
||||
assert.deepEqual(
|
||||
received.map((event) => event.type),
|
||||
["compaction-start", "agent-start"],
|
||||
);
|
||||
assert.notEqual(received[0]?.activityId, received[1]?.activityId);
|
||||
assert.equal(received[1]?.sequence, 1);
|
||||
assert.equal(received[1]?.source, "local");
|
||||
});
|
||||
|
||||
test("Activity normalizer ignores late completion after compaction abandonment", async () => {
|
||||
const received: TelegramActivityEvent[] = [];
|
||||
registerTelegramActivityHandler({
|
||||
id: "capture",
|
||||
handle: (event) => {
|
||||
received.push(event);
|
||||
},
|
||||
});
|
||||
const dispatcher = createTelegramActivityDispatcher();
|
||||
const runtime = createTelegramActivityRuntime({
|
||||
generation: "generation-one",
|
||||
dispatcher,
|
||||
});
|
||||
|
||||
runtime.onCompactionStart("threshold");
|
||||
runtime.onCompactionAbandoned();
|
||||
runtime.onCompactionEnd("threshold");
|
||||
runtime.onAgentStart();
|
||||
await waitForActivityDispatch();
|
||||
|
||||
assert.deepEqual(
|
||||
received.map((event) => event.type),
|
||||
["compaction-start", "agent-start"],
|
||||
);
|
||||
assert.notEqual(received[0]?.activityId, received[1]?.activityId);
|
||||
});
|
||||
|
||||
test("Activity bridge creates a fresh dispatcher after session replacement", async () => {
|
||||
const received: TelegramActivityEvent[] = [];
|
||||
registerTelegramActivityHandler({
|
||||
id: "capture",
|
||||
handle: (event) => {
|
||||
received.push(event);
|
||||
},
|
||||
});
|
||||
const runtime = createTelegramActivityBridgeRuntime({
|
||||
generation: "bridge-generation",
|
||||
});
|
||||
|
||||
runtime.onSessionStart?.();
|
||||
runtime.onAgentStart();
|
||||
await waitForActivityDispatch();
|
||||
runtime.onSessionShutdown();
|
||||
|
||||
runtime.onSessionStart?.();
|
||||
runtime.onAgentStart();
|
||||
await waitForActivityDispatch();
|
||||
|
||||
assert.deepEqual(
|
||||
received.map((event) => event.type),
|
||||
["agent-start", "agent-start"],
|
||||
);
|
||||
assert.notEqual(received[0]?.activityId, received[1]?.activityId);
|
||||
assert.match(received[0]?.activityId ?? "", /bridge-generation:1:/);
|
||||
assert.match(received[1]?.activityId ?? "", /bridge-generation:2:/);
|
||||
});
|
||||
|
||||
test("In-flight Activity handlers cannot deliver through a replacement session", async () => {
|
||||
const deliveryCalls: string[] = [];
|
||||
let releaseHandler: (() => void) | undefined;
|
||||
let markHandlerStarted: (() => void) | undefined;
|
||||
const handlerGate = new Promise<void>((resolve) => {
|
||||
releaseHandler = resolve;
|
||||
});
|
||||
const handlerStarted = new Promise<void>((resolve) => {
|
||||
markHandlerStarted = resolve;
|
||||
});
|
||||
registerTelegramActivityHandler({
|
||||
id: "blocked",
|
||||
async handle(_event, ctx) {
|
||||
markHandlerStarted?.();
|
||||
await handlerGate;
|
||||
const result = await ctx.send({ text: "stale work" });
|
||||
assert.equal(result.ok, false);
|
||||
if (!result.ok) assert.equal(result.reason, "runtime-unavailable");
|
||||
},
|
||||
});
|
||||
const activityRuntime = createTelegramActivityBridgeRuntime({
|
||||
generation: "bridge-generation",
|
||||
});
|
||||
activityRuntime.onSessionStart?.();
|
||||
activityRuntime.onAgentStart();
|
||||
await handlerStarted;
|
||||
|
||||
activityRuntime.onSessionShutdown();
|
||||
bindTelegramDeliveryRuntime({
|
||||
generation: "replacement-delivery",
|
||||
shutdown() {},
|
||||
async sendView() {
|
||||
deliveryCalls.push("send");
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
target: { chatId: 42 },
|
||||
messageIds: [1],
|
||||
generation: "replacement-delivery",
|
||||
},
|
||||
};
|
||||
},
|
||||
async editView(handle) {
|
||||
deliveryCalls.push("edit");
|
||||
return { ok: true, value: handle };
|
||||
},
|
||||
async deleteView() {
|
||||
deliveryCalls.push("delete");
|
||||
return { ok: true, value: undefined };
|
||||
},
|
||||
async sendChatAction() {
|
||||
deliveryCalls.push("action");
|
||||
return { ok: true, value: undefined };
|
||||
},
|
||||
});
|
||||
activityRuntime.onSessionStart?.();
|
||||
releaseHandler?.();
|
||||
await waitForActivityDispatch();
|
||||
await waitForActivityDispatch();
|
||||
assert.deepEqual(deliveryCalls, []);
|
||||
});
|
||||
|
||||
test("Activity context delegates to delivery with source-specific default scope", async () => {
|
||||
const scopes: string[] = [];
|
||||
const deliveryRuntime: TelegramDeliveryRuntime = {
|
||||
generation: "delivery-one",
|
||||
shutdown() {},
|
||||
async sendView(_view, options) {
|
||||
scopes.push(
|
||||
options.scope.kind === "target"
|
||||
? `target:${options.scope.target.threadId ?? "root"}`
|
||||
: options.scope.kind,
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
target: { chatId: 42 },
|
||||
messageIds: [1],
|
||||
generation: "delivery-one",
|
||||
},
|
||||
};
|
||||
},
|
||||
async editView(handle) {
|
||||
return { ok: true, value: handle };
|
||||
},
|
||||
async deleteView() {
|
||||
return { ok: true, value: undefined };
|
||||
},
|
||||
async sendChatAction(_action, scope) {
|
||||
scopes.push(
|
||||
scope.kind === "target"
|
||||
? `target:${scope.target.threadId ?? "root"}`
|
||||
: scope.kind,
|
||||
);
|
||||
return { ok: true, value: undefined };
|
||||
},
|
||||
};
|
||||
bindTelegramDeliveryRuntime(deliveryRuntime);
|
||||
registerTelegramActivityHandler({
|
||||
id: "delivery",
|
||||
handle: async (event, ctx) => {
|
||||
if (event.type !== "agent-start") return;
|
||||
await ctx.send({ text: "Working" });
|
||||
await ctx.chatAction("typing");
|
||||
if (event.source === "autonomous") {
|
||||
await ctx.send(
|
||||
{ text: "Aggregate activity" },
|
||||
{ scope: { kind: "aggregate" } },
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
const dispatcher = createTelegramActivityDispatcher();
|
||||
dispatcher.dispatch({
|
||||
...createEvent("agent-start"),
|
||||
source: "telegram",
|
||||
target: Object.freeze({ chatId: 42, threadId: 7 }),
|
||||
});
|
||||
dispatcher.dispatch({
|
||||
...createEvent("agent-start", 2),
|
||||
activityId: "activity-two",
|
||||
source: "autonomous",
|
||||
});
|
||||
await waitForActivityDispatch();
|
||||
await waitForActivityDispatch();
|
||||
assert.deepEqual(scopes, [
|
||||
"target:7",
|
||||
"target:7",
|
||||
"instance",
|
||||
"instance",
|
||||
"aggregate",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Delayed Telegram activity keeps the originating immutable target", async () => {
|
||||
const targets: Array<{ chatId: number; threadId?: number }> = [];
|
||||
let releaseFirst: (() => void) | undefined;
|
||||
const firstGate = new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
bindTelegramDeliveryRuntime({
|
||||
generation: "delivery-one",
|
||||
shutdown() {},
|
||||
async sendView(_view, options) {
|
||||
assert.equal(options.scope.kind, "target");
|
||||
if (options.scope.kind === "target") {
|
||||
targets.push({ ...options.scope.target });
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
target: { chatId: 42 },
|
||||
messageIds: [1],
|
||||
generation: "delivery-one",
|
||||
},
|
||||
};
|
||||
},
|
||||
async editView(handle) {
|
||||
return { ok: true, value: handle };
|
||||
},
|
||||
async deleteView() {
|
||||
return { ok: true, value: undefined };
|
||||
},
|
||||
async sendChatAction() {
|
||||
return { ok: true, value: undefined };
|
||||
},
|
||||
});
|
||||
registerTelegramActivityHandler({
|
||||
id: "delayed-target",
|
||||
handle: async (event, ctx) => {
|
||||
if (event.activityId === "activity-one") await firstGate;
|
||||
await ctx.send({ text: event.activityId });
|
||||
},
|
||||
});
|
||||
const dispatcher = createTelegramActivityDispatcher();
|
||||
dispatcher.dispatch({
|
||||
...createEvent("agent-start"),
|
||||
source: "telegram",
|
||||
target: Object.freeze({ chatId: 42, threadId: 7 }),
|
||||
});
|
||||
dispatcher.dispatch({
|
||||
...createEvent("agent-start", 2),
|
||||
activityId: "activity-two",
|
||||
source: "telegram",
|
||||
target: Object.freeze({ chatId: 42, threadId: 8 }),
|
||||
});
|
||||
await waitForActivityDispatch();
|
||||
assert.deepEqual(targets, []);
|
||||
releaseFirst?.();
|
||||
await waitForActivityDispatch();
|
||||
await waitForActivityDispatch();
|
||||
assert.deepEqual(targets, [
|
||||
{ chatId: 42, threadId: 7 },
|
||||
{ chatId: 42, threadId: 8 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("Activity shutdown fences queued handlers and clears normalization state", async () => {
|
||||
const received: string[] = [];
|
||||
registerTelegramActivityHandler({
|
||||
id: "capture",
|
||||
handle: (event) => {
|
||||
received.push(event.type);
|
||||
},
|
||||
});
|
||||
const dispatcher = createTelegramActivityDispatcher();
|
||||
const runtime = createTelegramActivityRuntime({
|
||||
generation: "generation-one",
|
||||
dispatcher,
|
||||
});
|
||||
runtime.onAgentStart();
|
||||
runtime.onSessionShutdown();
|
||||
runtime.onAgentEnd();
|
||||
await waitForActivityDispatch();
|
||||
assert.deepEqual(received, []);
|
||||
});
|
||||
@@ -0,0 +1,573 @@
|
||||
/**
|
||||
* Regression tests for Telegram binding composition
|
||||
* Covers lifecycle binding delegation across composed runtimes
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
registerTelegramCommandsAndTools,
|
||||
registerTelegramLifecycleRuntimeHooks,
|
||||
} from "../lib/bindings.ts";
|
||||
import * as Runtime from "../lib/runtime.ts";
|
||||
import type { ExtensionAPI, ExtensionContext } from "../lib/pi.ts";
|
||||
|
||||
type RegisteredBindingHandler = (
|
||||
event: unknown,
|
||||
ctx: ExtensionContext,
|
||||
) => Promise<unknown> | unknown;
|
||||
|
||||
type RegisteredBindingTool = {
|
||||
name?: string;
|
||||
execute: (
|
||||
toolCallId: string,
|
||||
params: Record<string, string>,
|
||||
) => Promise<unknown>;
|
||||
};
|
||||
|
||||
function createBindingApiHarness() {
|
||||
const handlers = new Map<string, RegisteredBindingHandler>();
|
||||
const tools = new Map<string, RegisteredBindingTool>();
|
||||
const commands = new Map<string, unknown>();
|
||||
const api = {
|
||||
on: (event: string, handler: RegisteredBindingHandler) => {
|
||||
handlers.set(event, handler);
|
||||
},
|
||||
registerTool: (definition: RegisteredBindingTool) => {
|
||||
if (definition.name) tools.set(definition.name, definition);
|
||||
},
|
||||
registerCommand: (name: string, definition: unknown) => {
|
||||
commands.set(name, definition);
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
return { api, handlers, tools, commands };
|
||||
}
|
||||
|
||||
function getRequiredBindingHandler(
|
||||
handlers: Map<string, RegisteredBindingHandler>,
|
||||
name: string,
|
||||
): RegisteredBindingHandler {
|
||||
const handler = handlers.get(name);
|
||||
assert.ok(handler, `Expected binding handler ${name}`);
|
||||
return handler;
|
||||
}
|
||||
|
||||
test("Command binding does not expose a thread rename tool", () => {
|
||||
const harness = createBindingApiHarness();
|
||||
registerTelegramCommandsAndTools({
|
||||
pi: harness.api,
|
||||
configStore: {
|
||||
get: () => ({}),
|
||||
getAllowedUserId: () => 840585,
|
||||
getOutboundHandlers: () => [],
|
||||
hasBotToken: () => true,
|
||||
load: async () => {},
|
||||
persist: async () => {},
|
||||
set: () => {},
|
||||
},
|
||||
setup: { start: () => true, finish: () => {} },
|
||||
activeTurnRuntime: { get: () => undefined },
|
||||
lockedPollingRuntime: {
|
||||
start: async () => ({ ok: true }),
|
||||
stop: async () => undefined,
|
||||
},
|
||||
getStatusLines: () => [],
|
||||
buttonActionStore: { register: () => "button-action" },
|
||||
sendMarkdownReply: async () => 1,
|
||||
callMultipart: async () => ({ ok: true }),
|
||||
getDefaultChatId: () => 840585,
|
||||
canSendDirect: () => true,
|
||||
updateStatus: () => {},
|
||||
recordRuntimeEvent: () => {},
|
||||
} as unknown as Parameters<typeof registerTelegramCommandsAndTools>[0]);
|
||||
assert.equal(harness.tools.has("telegram_rename_thread"), false);
|
||||
});
|
||||
|
||||
test("Command binding rejects a missing profile without stopping active polling", async () => {
|
||||
const harness = createBindingApiHarness();
|
||||
const events: string[] = [];
|
||||
let activeProfileName: string | undefined = "active";
|
||||
registerTelegramCommandsAndTools({
|
||||
pi: harness.api,
|
||||
configStore: {
|
||||
get: () => ({ botToken: "active-token" }),
|
||||
getStoredConfig: () => ({
|
||||
profiles: { active: { botToken: "active-token" } },
|
||||
}),
|
||||
getActiveProfileName: () => activeProfileName,
|
||||
activateProfile: (profileName?: string) => {
|
||||
events.push(`activate:${profileName ?? "default"}`);
|
||||
activeProfileName = profileName;
|
||||
return true;
|
||||
},
|
||||
getAllowedUserId: () => 840585,
|
||||
getOutboundHandlers: () => [],
|
||||
hasBotToken: () => true,
|
||||
load: async () => {
|
||||
events.push("load");
|
||||
},
|
||||
persist: async () => {},
|
||||
set: () => {},
|
||||
},
|
||||
setup: { start: () => true, finish: () => {} },
|
||||
activeTurnRuntime: { get: () => undefined },
|
||||
lockedPollingRuntime: {
|
||||
start: async () => {
|
||||
events.push("start");
|
||||
return { ok: true };
|
||||
},
|
||||
stop: async () => {
|
||||
events.push("stop");
|
||||
},
|
||||
},
|
||||
getStatusLines: () => [],
|
||||
buttonActionStore: { register: () => "button-action" },
|
||||
sendMarkdownReply: async () => 1,
|
||||
callMultipart: async () => ({ ok: true }),
|
||||
getDefaultChatId: () => 840585,
|
||||
canSendDirect: () => true,
|
||||
updateStatus: () => {
|
||||
events.push("status");
|
||||
},
|
||||
recordRuntimeEvent: () => {},
|
||||
} as unknown as Parameters<typeof registerTelegramCommandsAndTools>[0]);
|
||||
const connect = harness.commands.get("telegram-connect") as {
|
||||
handler: (args: string, ctx: ExtensionContext) => Promise<void>;
|
||||
};
|
||||
const notifications: string[] = [];
|
||||
await connect.handler("missing", {
|
||||
cwd: "/repo",
|
||||
ui: {
|
||||
notify: (message: string) => {
|
||||
notifications.push(message);
|
||||
},
|
||||
},
|
||||
} as unknown as ExtensionContext);
|
||||
assert.equal(activeProfileName, "active");
|
||||
assert.deepEqual(events, ["load", "status"]);
|
||||
assert.deepEqual(notifications, ['Profile "missing" not found.']);
|
||||
});
|
||||
|
||||
test("Named profile connect completes old teardown before activating new identity", async () => {
|
||||
const harness = createBindingApiHarness();
|
||||
const events: string[] = [];
|
||||
let activeProfileName: string | undefined = "active";
|
||||
let stopCompleted = false;
|
||||
registerTelegramCommandsAndTools({
|
||||
pi: harness.api,
|
||||
configStore: {
|
||||
get: () => ({ botToken: `${activeProfileName}-token` }),
|
||||
getStoredConfig: () => ({
|
||||
profiles: {
|
||||
active: { botToken: "active-token" },
|
||||
work: { botToken: "work-token" },
|
||||
},
|
||||
}),
|
||||
getActiveProfileName: () => activeProfileName,
|
||||
activateProfile: (profileName?: string) => {
|
||||
assert.equal(stopCompleted, true);
|
||||
events.push(`activate:${profileName ?? "default"}`);
|
||||
activeProfileName = profileName;
|
||||
return true;
|
||||
},
|
||||
getAllowedUserId: () => 840585,
|
||||
getOutboundHandlers: () => [],
|
||||
hasBotToken: () => true,
|
||||
load: async () => events.push("load"),
|
||||
persist: async () => {},
|
||||
set: () => {},
|
||||
},
|
||||
setup: { start: () => true, finish: () => {} },
|
||||
activeTurnRuntime: { get: () => undefined },
|
||||
lockedPollingRuntime: {
|
||||
start: async () => {
|
||||
events.push(`start:${activeProfileName}`);
|
||||
return { ok: true };
|
||||
},
|
||||
stop: async () => {
|
||||
events.push(`stop:${activeProfileName}`);
|
||||
await Promise.resolve();
|
||||
stopCompleted = true;
|
||||
},
|
||||
},
|
||||
getStatusLines: () => [],
|
||||
buttonActionStore: { register: () => "button-action" },
|
||||
sendMarkdownReply: async () => 1,
|
||||
callMultipart: async () => ({ ok: true }),
|
||||
getDefaultChatId: () => 840585,
|
||||
canSendDirect: () => true,
|
||||
updateStatus: () => events.push("status"),
|
||||
recordRuntimeEvent: () => {},
|
||||
} as unknown as Parameters<typeof registerTelegramCommandsAndTools>[0]);
|
||||
|
||||
const connect = harness.commands.get("telegram-connect") as {
|
||||
handler: (args: string, ctx: ExtensionContext) => Promise<void>;
|
||||
};
|
||||
await connect.handler("work", {
|
||||
cwd: "/repo",
|
||||
ui: { notify: () => undefined },
|
||||
} as unknown as ExtensionContext);
|
||||
|
||||
assert.equal(activeProfileName, "work");
|
||||
assert.deepEqual(events, [
|
||||
"load",
|
||||
"stop:active",
|
||||
"activate:work",
|
||||
"start:work",
|
||||
"status",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Named profile setup cancellation preserves the active runtime", async () => {
|
||||
const harness = createBindingApiHarness();
|
||||
const events: string[] = [];
|
||||
let activeProfileName: string | undefined = "active";
|
||||
registerTelegramCommandsAndTools({
|
||||
pi: harness.api,
|
||||
configStore: {
|
||||
get: () => ({ botToken: "active-token" }),
|
||||
getStoredConfig: () => ({
|
||||
profiles: { active: { botToken: "active-token" } },
|
||||
}),
|
||||
getActiveProfileName: () => activeProfileName,
|
||||
activateProfile: (profileName?: string) => {
|
||||
events.push(`activate:${profileName ?? "default"}`);
|
||||
activeProfileName = profileName;
|
||||
return true;
|
||||
},
|
||||
getAllowedUserId: () => 840585,
|
||||
getOutboundHandlers: () => [],
|
||||
hasBotToken: () => true,
|
||||
load: async () => undefined,
|
||||
persist: async () => {
|
||||
events.push("persist");
|
||||
},
|
||||
set: () => {
|
||||
events.push("set");
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
start: () => {
|
||||
events.push("guard-start");
|
||||
return true;
|
||||
},
|
||||
finish: () => {
|
||||
events.push("guard-finish");
|
||||
},
|
||||
},
|
||||
activeTurnRuntime: { get: () => undefined },
|
||||
lockedPollingRuntime: {
|
||||
start: async () => {
|
||||
events.push("start");
|
||||
return { ok: true };
|
||||
},
|
||||
stop: async () => {
|
||||
events.push("stop");
|
||||
},
|
||||
},
|
||||
getStatusLines: () => [],
|
||||
buttonActionStore: { register: () => "button-action" },
|
||||
sendMarkdownReply: async () => 1,
|
||||
callMultipart: async () => ({ ok: true }),
|
||||
getDefaultChatId: () => 840585,
|
||||
canSendDirect: () => true,
|
||||
updateStatus: () => {
|
||||
events.push("status");
|
||||
},
|
||||
recordRuntimeEvent: () => {},
|
||||
} as unknown as Parameters<typeof registerTelegramCommandsAndTools>[0]);
|
||||
const setupCommand = harness.commands.get("telegram-setup") as {
|
||||
handler: (args: string, ctx: ExtensionContext) => Promise<void>;
|
||||
};
|
||||
const notifications: string[] = [];
|
||||
await setupCommand.handler("newprofile", {
|
||||
cwd: "/repo",
|
||||
hasUI: true,
|
||||
ui: {
|
||||
input: async () => undefined,
|
||||
editor: async () => undefined,
|
||||
notify: (message: string) => {
|
||||
notifications.push(message);
|
||||
},
|
||||
},
|
||||
} as unknown as ExtensionContext);
|
||||
assert.equal(activeProfileName, "active");
|
||||
assert.deepEqual(events, ["guard-start", "guard-finish"]);
|
||||
assert.deepEqual(notifications, []);
|
||||
});
|
||||
|
||||
test("Lifecycle binding delegates shutdown to composed session runtime", async () => {
|
||||
const events: string[] = [];
|
||||
const harness = createBindingApiHarness();
|
||||
const deps = {
|
||||
pi: harness.api,
|
||||
activityRuntime: {
|
||||
recordInputSource: () => {},
|
||||
onAgentStart: () => {},
|
||||
onAssistantEvent: () => {},
|
||||
onToolStart: () => {},
|
||||
onToolUpdate: () => {},
|
||||
onToolEnd: () => {},
|
||||
onCompactionStart: () => {},
|
||||
onCompactionEnd: () => {},
|
||||
onCompactionAbandoned: () => {},
|
||||
onAgentEnd: () => {},
|
||||
onAgentSettled: () => {},
|
||||
onSessionShutdown: () => {},
|
||||
},
|
||||
sessionLifecycleRuntime: {
|
||||
onSessionStart: async () => {
|
||||
events.push("session-start");
|
||||
},
|
||||
onSessionShutdown: async () => {
|
||||
events.push("composed-shutdown");
|
||||
},
|
||||
onModelSelect: () => {
|
||||
events.push("model-select");
|
||||
},
|
||||
},
|
||||
configStore: { get: () => ({}), getOutboundHandlers: () => [] },
|
||||
abort: { setHandler: () => {}, clearHandler: () => {} },
|
||||
typing: { stop: () => {}, waitForIdle: async () => {} },
|
||||
progress: {
|
||||
start: () => ({ active: true, chatId: 1, text: "", updatedAtMs: 0 }),
|
||||
update: () => undefined,
|
||||
stop: () => undefined,
|
||||
get: () => undefined,
|
||||
},
|
||||
lifecycle: {
|
||||
resetActiveToolExecutions: () => {},
|
||||
clearDispatchPending: () => {},
|
||||
hasDispatchPending: () => false,
|
||||
setFoldQueuedPromptsIntoHistory: () => {},
|
||||
shouldFoldQueuedPromptsIntoHistory: () => false,
|
||||
getActiveToolExecutions: () => 0,
|
||||
setActiveToolExecutions: () => {},
|
||||
setCompactionInProgress: () => {},
|
||||
},
|
||||
activeTurnRuntime: {
|
||||
clear: () => {},
|
||||
has: () => false,
|
||||
set: () => {},
|
||||
get: () => undefined,
|
||||
},
|
||||
telegramQueueStore: {
|
||||
getQueuedItems: () => [],
|
||||
setQueuedItems: () => {},
|
||||
},
|
||||
modelSwitchController: {
|
||||
clearPendingSwitch: () => {},
|
||||
triggerPendingAbort: () => {},
|
||||
},
|
||||
previewRuntime: {
|
||||
resetState: () => undefined,
|
||||
clear: () => {},
|
||||
setPendingText: () => {},
|
||||
onMessageStart: async () => {},
|
||||
onMessageUpdate: async () => {},
|
||||
},
|
||||
promptDispatchRuntime: {
|
||||
startTypingLoop: () => events.push("typing:start"),
|
||||
},
|
||||
deferredQueueDispatchRuntime: { request: () => {} },
|
||||
lockOwnershipGuard: { ownsContext: () => false },
|
||||
buttonActionStore: { register: () => "button-action" },
|
||||
callMultipart: async () => ({ ok: true }),
|
||||
sendChatAction: async () => ({ ok: true }),
|
||||
sendRecordVoiceAction: async () => ({ ok: true }),
|
||||
sendMarkdownReply: async () => ({ ok: true }),
|
||||
sendTextReply: async () => ({ ok: true }),
|
||||
editInteractiveMessage: async () => undefined,
|
||||
deleteMessage: async () => undefined,
|
||||
dispatchNextQueuedTelegramTurn: () => {},
|
||||
answerGuestQuery: async () => ({ ok: true }),
|
||||
sendGuestReply: async () => ({ ok: true }),
|
||||
finalizeMarkdownPreview: async () => undefined,
|
||||
proactivePushChatIdGetter: () => undefined,
|
||||
isProactivePushEnabled: () => false,
|
||||
canSendAgentActivity: () => false,
|
||||
updateStatus: () => {},
|
||||
recordRuntimeEvent: () => {},
|
||||
} as unknown as Parameters<typeof registerTelegramLifecycleRuntimeHooks>[0];
|
||||
|
||||
registerTelegramLifecycleRuntimeHooks(deps);
|
||||
await getRequiredBindingHandler(harness.handlers, "session_before_compact")(
|
||||
{ type: "session_before_compact" },
|
||||
{} as ExtensionContext,
|
||||
);
|
||||
await getRequiredBindingHandler(harness.handlers, "session_shutdown")(
|
||||
{},
|
||||
{} as ExtensionContext,
|
||||
);
|
||||
|
||||
assert.deepEqual(events, ["composed-shutdown"]);
|
||||
});
|
||||
|
||||
test("Lifecycle binding routes native typing, previews, and normalized activity", async () => {
|
||||
const events: string[] = [];
|
||||
const harness = createBindingApiHarness();
|
||||
const runtime = Runtime.createTelegramBridgeRuntime();
|
||||
let activeTurn = false;
|
||||
const deps = {
|
||||
pi: harness.api,
|
||||
activityRuntime: {
|
||||
recordInputSource: (source: string) =>
|
||||
events.push(`activity:input:${source}`),
|
||||
onAgentStart: (target?: { chatId: number; threadId?: number }) =>
|
||||
events.push(
|
||||
`activity:agent-start:${target?.threadId ?? target?.chatId ?? "none"}`,
|
||||
),
|
||||
onAssistantEvent: (event: { type: string }) =>
|
||||
events.push(`activity:assistant:${event.type}`),
|
||||
onToolStart: (event: { toolName: string }) =>
|
||||
events.push(`activity:tool-start:${event.toolName}`),
|
||||
onToolUpdate: (event: { toolName: string }) =>
|
||||
events.push(`activity:tool-update:${event.toolName}`),
|
||||
onToolEnd: (event: { toolName: string }) =>
|
||||
events.push(`activity:tool-end:${event.toolName}`),
|
||||
onCompactionStart: (reason: string) =>
|
||||
events.push(`activity:compact-start:${reason}`),
|
||||
onCompactionEnd: (reason: string) =>
|
||||
events.push(`activity:compact-end:${reason}`),
|
||||
onCompactionAbandoned: () =>
|
||||
events.push("activity:compact-abandoned"),
|
||||
onAgentEnd: () => events.push("activity:agent-end"),
|
||||
onAgentSettled: () => events.push("activity:agent-settled"),
|
||||
onSessionShutdown: () => events.push("activity:shutdown"),
|
||||
},
|
||||
sessionLifecycleRuntime: {
|
||||
onSessionStart: async () => {},
|
||||
onSessionShutdown: async () => {},
|
||||
onModelSelect: () => {},
|
||||
},
|
||||
configStore: {
|
||||
get: () => ({}),
|
||||
getOutboundHandlers: () => [],
|
||||
hasBotToken: () => true,
|
||||
},
|
||||
abort: runtime.abort,
|
||||
typing: runtime.typing,
|
||||
lifecycle: runtime.lifecycle,
|
||||
activeTurnRuntime: {
|
||||
clear: () => {},
|
||||
has: () => activeTurn,
|
||||
set: () => {},
|
||||
get: () =>
|
||||
activeTurn
|
||||
? { chatId: 42, target: { chatId: 42, threadId: 9 } }
|
||||
: undefined,
|
||||
},
|
||||
telegramQueueStore: { getQueuedItems: () => [], setQueuedItems: () => {} },
|
||||
modelSwitchController: {
|
||||
clearPendingSwitch: () => {},
|
||||
triggerPendingAbort: () => {},
|
||||
},
|
||||
previewRuntime: {
|
||||
resetState: () => undefined,
|
||||
clear: () => {},
|
||||
setPendingText: () => {},
|
||||
onMessageStart: async () => events.push("preview:start"),
|
||||
onMessageUpdate: async () => events.push("preview:update"),
|
||||
},
|
||||
promptDispatchRuntime: {
|
||||
startTypingLoop: (
|
||||
_ctx: ExtensionContext,
|
||||
chatId?: number,
|
||||
options?: { target?: { threadId?: number } },
|
||||
) =>
|
||||
events.push(
|
||||
`typing:${chatId ?? "none"}:${options?.target?.threadId ?? "all"}`,
|
||||
),
|
||||
},
|
||||
deferredQueueDispatchRuntime: { request: () => {} },
|
||||
lockOwnershipGuard: { ownsContext: () => false },
|
||||
buttonActionStore: { register: () => "button-action" },
|
||||
callMultipart: async () => ({ ok: true }),
|
||||
sendChatAction: async () => ({ ok: true }),
|
||||
sendRecordVoiceAction: async () => ({ ok: true }),
|
||||
sendMarkdownReply: async (
|
||||
_chatId: number,
|
||||
_replyTo: number | undefined,
|
||||
text: string,
|
||||
) => {
|
||||
events.push(`send:${text}`);
|
||||
return 77;
|
||||
},
|
||||
sendTextReply: async () => ({ ok: true }),
|
||||
editInteractiveMessage: async () => events.push("edit"),
|
||||
deleteMessage: async () => undefined,
|
||||
dispatchNextQueuedTelegramTurn: () => {},
|
||||
answerGuestQuery: async () => ({ ok: true }),
|
||||
sendGuestReply: async () => ({ ok: true }),
|
||||
finalizeMarkdownPreview: async () => undefined,
|
||||
proactivePushChatIdGetter: () => 42,
|
||||
proactivePushTargetGetter: () => ({ chatId: 42, threadId: 8 }),
|
||||
isProactivePushEnabled: () => false,
|
||||
canSendAgentActivity: () => true,
|
||||
updateStatus: () => {},
|
||||
recordRuntimeEvent: () => {},
|
||||
} as unknown as Parameters<typeof registerTelegramLifecycleRuntimeHooks>[0];
|
||||
registerTelegramLifecycleRuntimeHooks(deps);
|
||||
|
||||
await getRequiredBindingHandler(harness.handlers, "agent_start")(
|
||||
{ type: "agent_start" },
|
||||
{ abort: () => undefined } as ExtensionContext,
|
||||
);
|
||||
activeTurn = true;
|
||||
await getRequiredBindingHandler(harness.handlers, "message_start")(
|
||||
{ message: {} },
|
||||
{} as ExtensionContext,
|
||||
);
|
||||
await getRequiredBindingHandler(harness.handlers, "message_update")(
|
||||
{
|
||||
message: {},
|
||||
assistantMessageEvent: {
|
||||
type: "thinking_delta",
|
||||
contentIndex: 0,
|
||||
delta: "ponder <edge>",
|
||||
},
|
||||
},
|
||||
{} as ExtensionContext,
|
||||
);
|
||||
await getRequiredBindingHandler(harness.handlers, "tool_execution_start")(
|
||||
{
|
||||
type: "tool_execution_start",
|
||||
toolCallId: "1",
|
||||
toolName: "read",
|
||||
args: { path: "README.md" },
|
||||
},
|
||||
{} as ExtensionContext,
|
||||
);
|
||||
await getRequiredBindingHandler(harness.handlers, "session_before_compact")(
|
||||
{ type: "session_before_compact" },
|
||||
{} as ExtensionContext,
|
||||
);
|
||||
await getRequiredBindingHandler(harness.handlers, "session_compact")(
|
||||
{ type: "session_compact" },
|
||||
{} as ExtensionContext,
|
||||
);
|
||||
activeTurn = false;
|
||||
await getRequiredBindingHandler(harness.handlers, "session_before_compact")(
|
||||
{ type: "session_before_compact" },
|
||||
{} as ExtensionContext,
|
||||
);
|
||||
|
||||
assert.deepEqual(events, [
|
||||
"activity:agent-start:none",
|
||||
"typing:42:8",
|
||||
"typing:42:9",
|
||||
"preview:start",
|
||||
"typing:42:9",
|
||||
"activity:assistant:thinking_delta",
|
||||
"typing:42:9",
|
||||
"preview:update",
|
||||
"typing:42:9",
|
||||
"activity:tool-start:read",
|
||||
"activity:compact-start:unknown",
|
||||
"typing:42:9",
|
||||
"activity:compact-end:unknown",
|
||||
"activity:compact-start:unknown",
|
||||
"typing:42:8",
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* Regression tests for Telegram bus-aware API runtime
|
||||
* Verifies follower outbound API calls route through the local bus while leaders use direct transport
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { createTelegramBusAwareApiRuntime } from "../lib/bus-api.ts";
|
||||
import type { TelegramBridgeApiRuntime } from "../lib/telegram-api.ts";
|
||||
|
||||
function createDirectRuntime(calls: unknown[]): TelegramBridgeApiRuntime {
|
||||
return {
|
||||
call: async <TResponse>(method: string, body: Record<string, unknown>) => {
|
||||
calls.push({ kind: "call", method, body });
|
||||
return { ok: true } as TResponse;
|
||||
},
|
||||
callMultipart: async <TResponse>(
|
||||
method: string,
|
||||
fields: Record<string, string>,
|
||||
fileField: string,
|
||||
filePath: string,
|
||||
fileName: string,
|
||||
) => {
|
||||
calls.push({
|
||||
kind: "multipart",
|
||||
method,
|
||||
fields,
|
||||
fileField,
|
||||
filePath,
|
||||
fileName,
|
||||
});
|
||||
return { ok: true } as TResponse;
|
||||
},
|
||||
downloadFile: async (fileId, suggestedName) => {
|
||||
calls.push({ kind: "download", fileId, suggestedName });
|
||||
return "/tmp/file";
|
||||
},
|
||||
deleteWebhook: async () => {
|
||||
calls.push({ kind: "delete-webhook" });
|
||||
return true;
|
||||
},
|
||||
getUpdates: async (body) => {
|
||||
calls.push({ kind: "get-updates", body });
|
||||
return [];
|
||||
},
|
||||
setMyCommands: async (commands) => {
|
||||
calls.push({ kind: "commands", commands });
|
||||
return true;
|
||||
},
|
||||
sendChatAction: async (chatId, action, options) => {
|
||||
calls.push({ kind: "chat-action", chatId, action, options });
|
||||
return true;
|
||||
},
|
||||
sendTypingAction: async (chatId, options) => {
|
||||
calls.push({ kind: "typing", chatId, options });
|
||||
return true;
|
||||
},
|
||||
sendRecordVoiceAction: async (chatId, options) => {
|
||||
calls.push({ kind: "record-voice", chatId, options });
|
||||
return true;
|
||||
},
|
||||
sendMessageDraft: async (chatId, draftId, text, options) => {
|
||||
calls.push({ kind: "draft", chatId, draftId, text, options });
|
||||
return true;
|
||||
},
|
||||
sendMessage: async (body) => {
|
||||
calls.push({ kind: "message", body });
|
||||
return { message_id: 1 };
|
||||
},
|
||||
sendRichMessage: async (body) => {
|
||||
calls.push({ kind: "rich", body });
|
||||
return { message_id: 2 };
|
||||
},
|
||||
sendRichMessageDraft: async (body) => {
|
||||
calls.push({ kind: "rich-draft", body });
|
||||
return true;
|
||||
},
|
||||
editMessageText: async (body) => {
|
||||
calls.push({ kind: "edit", body });
|
||||
return "edited";
|
||||
},
|
||||
answerCallbackQuery: async (callbackQueryId, text) => {
|
||||
calls.push({ kind: "answer-callback", callbackQueryId, text });
|
||||
},
|
||||
answerGuestQuery: async (guestQueryId, text) => {
|
||||
calls.push({ kind: "answer-guest", guestQueryId, text });
|
||||
},
|
||||
deleteMessage: async (chatId, messageId) => {
|
||||
calls.push({ kind: "delete", chatId, messageId });
|
||||
},
|
||||
prepareTempDir: async () => 0,
|
||||
};
|
||||
}
|
||||
|
||||
test("Bus-aware API runtime uses direct transport while this instance owns Telegram", async () => {
|
||||
const directCalls: unknown[] = [];
|
||||
const busCalls: unknown[] = [];
|
||||
const runtime = createTelegramBusAwareApiRuntime({
|
||||
directRuntime: createDirectRuntime(directCalls),
|
||||
ownsDirect: () => true,
|
||||
callFollowerApi: async (method, args) => {
|
||||
busCalls.push({ method, args });
|
||||
return { message_id: 99 };
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
await runtime.sendRichMessage({
|
||||
chat_id: 1,
|
||||
rich_message: { markdown: "hi" },
|
||||
}),
|
||||
{
|
||||
message_id: 2,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(directCalls, [
|
||||
{ kind: "rich", body: { chat_id: 1, rich_message: { markdown: "hi" } } },
|
||||
]);
|
||||
assert.deepEqual(busCalls, []);
|
||||
});
|
||||
|
||||
test("Bus-aware polling methods fail closed without direct ownership", async () => {
|
||||
const directCalls: unknown[] = [];
|
||||
const busCalls: unknown[] = [];
|
||||
const runtime = createTelegramBusAwareApiRuntime({
|
||||
directRuntime: createDirectRuntime(directCalls),
|
||||
ownsDirect: () => false,
|
||||
callFollowerApi: async (method, args) => {
|
||||
busCalls.push({ method, args });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
runtime.deleteWebhook(),
|
||||
/deleteWebhook requires direct transport ownership/,
|
||||
);
|
||||
await assert.rejects(
|
||||
runtime.getUpdates({ offset: 1 }),
|
||||
/getUpdates requires direct transport ownership/,
|
||||
);
|
||||
assert.deepEqual(directCalls, []);
|
||||
assert.deepEqual(busCalls, []);
|
||||
});
|
||||
|
||||
test("Bus-aware API runtime routes follower outbound calls through the leader", async () => {
|
||||
const directCalls: unknown[] = [];
|
||||
const busCalls: unknown[] = [];
|
||||
const runtime = createTelegramBusAwareApiRuntime({
|
||||
directRuntime: createDirectRuntime(directCalls),
|
||||
ownsDirect: () => false,
|
||||
callFollowerApi: async (method, args) => {
|
||||
busCalls.push({ method, args });
|
||||
if (args[0] === "sendRichMessage") return { message_id: 77 };
|
||||
if (method === "downloadFile") return "/tmp/leader-photo.png";
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
await runtime.sendRichMessage({
|
||||
chat_id: 1,
|
||||
rich_message: { markdown: "hi" },
|
||||
}),
|
||||
{
|
||||
message_id: 77,
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
await runtime.sendChatAction(1, "typing", { message_thread_id: 5 }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await runtime.call("sendChatAction", { chat_id: 1, action: "typing" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await runtime.sendMessageDraft(1, 2, "draft", { message_thread_id: 5 }),
|
||||
true,
|
||||
);
|
||||
await runtime.deleteMessage(1, 9);
|
||||
await runtime.answerCallbackQuery("cb1", "Done");
|
||||
await runtime.answerGuestQuery("guest1", "Hello", { parseMode: "Markdown" });
|
||||
await runtime.answerGuestQuery("guest2", undefined, {
|
||||
result: {
|
||||
type: "photo",
|
||||
id: "photo-1",
|
||||
photo_file_id: "cached-photo",
|
||||
caption: "Result",
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
await runtime.downloadFile("file1", "photo.png"),
|
||||
"/tmp/leader-photo.png",
|
||||
);
|
||||
|
||||
assert.deepEqual(directCalls, []);
|
||||
assert.deepEqual(busCalls, [
|
||||
{
|
||||
method: "call",
|
||||
args: [
|
||||
"sendRichMessage",
|
||||
{ chat_id: 1, rich_message: { markdown: "hi" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: "call",
|
||||
args: [
|
||||
"sendChatAction",
|
||||
{ chat_id: 1, action: "typing", message_thread_id: 5 },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: "call",
|
||||
args: ["sendChatAction", { chat_id: 1, action: "typing" }, undefined],
|
||||
},
|
||||
{
|
||||
method: "call",
|
||||
args: [
|
||||
"sendMessageDraft",
|
||||
{ chat_id: 1, draft_id: 2, text: "draft", message_thread_id: 5 },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: "call",
|
||||
args: ["deleteMessage", { chat_id: 1, message_id: 9 }],
|
||||
},
|
||||
{
|
||||
method: "call",
|
||||
args: ["answerCallbackQuery", { callback_query_id: "cb1", text: "Done" }],
|
||||
},
|
||||
{
|
||||
method: "call",
|
||||
args: [
|
||||
"answerGuestQuery",
|
||||
{
|
||||
guest_query_id: "guest1",
|
||||
result: {
|
||||
type: "article",
|
||||
id: "1",
|
||||
title: "Response",
|
||||
input_message_content: {
|
||||
message_text: "Hello",
|
||||
parse_mode: "Markdown",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: "call",
|
||||
args: [
|
||||
"answerGuestQuery",
|
||||
{
|
||||
guest_query_id: "guest2",
|
||||
result: {
|
||||
type: "photo",
|
||||
id: "photo-1",
|
||||
photo_file_id: "cached-photo",
|
||||
caption: "Result",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: "downloadFile",
|
||||
args: ["file1", "photo.png"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("Bus-aware API runtime applies follower default thread to scoped actions", async () => {
|
||||
const busCalls: unknown[] = [];
|
||||
const runtime = createTelegramBusAwareApiRuntime({
|
||||
directRuntime: createDirectRuntime([]),
|
||||
ownsDirect: () => false,
|
||||
getDefaultTarget: () => ({ chatId: 1, threadId: 5 }),
|
||||
callFollowerApi: async (method, args) => {
|
||||
busCalls.push({ method, args });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.sendTypingAction(1);
|
||||
await runtime.sendRecordVoiceAction(1);
|
||||
await runtime.sendMessageDraft(1, 2, "draft");
|
||||
await runtime.sendTypingAction(2);
|
||||
|
||||
assert.deepEqual(busCalls, [
|
||||
{
|
||||
method: "call",
|
||||
args: [
|
||||
"sendChatAction",
|
||||
{ chat_id: 1, action: "typing", message_thread_id: 5 },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: "call",
|
||||
args: [
|
||||
"sendChatAction",
|
||||
{ chat_id: 1, action: "record_voice", message_thread_id: 5 },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: "call",
|
||||
args: [
|
||||
"sendMessageDraft",
|
||||
{ chat_id: 1, draft_id: 2, text: "draft", message_thread_id: 5 },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: "call",
|
||||
args: ["sendChatAction", { chat_id: 2, action: "typing" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("Bus-aware API runtime routes follower multipart uploads through the leader", async () => {
|
||||
const busCalls: unknown[] = [];
|
||||
const runtime = createTelegramBusAwareApiRuntime({
|
||||
directRuntime: createDirectRuntime([]),
|
||||
ownsDirect: () => false,
|
||||
callFollowerApi: async (method, args) => {
|
||||
busCalls.push({ method, args });
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
await runtime.callMultipart(
|
||||
"sendDocument",
|
||||
{ chat_id: "1" },
|
||||
"document",
|
||||
"/tmp/a.txt",
|
||||
"a.txt",
|
||||
),
|
||||
{ ok: true },
|
||||
);
|
||||
assert.deepEqual(busCalls, [
|
||||
{
|
||||
method: "callMultipart",
|
||||
args: [
|
||||
"sendDocument",
|
||||
{ chat_id: "1" },
|
||||
"document",
|
||||
"/tmp/a.txt",
|
||||
"a.txt",
|
||||
undefined,
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+1526
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* Command-template regression tests
|
||||
* Covers shell-free splitting, executable expansion, defaults, inline placeholder resolution, and composition expansion
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildCommandTemplateInvocation,
|
||||
execCommandTemplate,
|
||||
expandCommandTemplateConfigs,
|
||||
getCommandTemplateRiskLabels,
|
||||
getCommandTemplateWarnings,
|
||||
shouldRunCommandTemplateNode,
|
||||
splitCommandTemplate,
|
||||
} from "../lib/command-templates.ts";
|
||||
|
||||
test("Command templates split shell-like words without invoking a shell", () => {
|
||||
assert.deepEqual(
|
||||
splitCommandTemplate("tool 'literal words' --name hello\\ world"),
|
||||
["tool", "literal words", "--name", "hello world"],
|
||||
);
|
||||
});
|
||||
|
||||
test("Command templates accept shorthand string configs", () => {
|
||||
const invocation = buildCommandTemplateInvocation(
|
||||
"./tts --text {text} --lang {lang=ru}",
|
||||
{ text: "hello world" },
|
||||
"/work",
|
||||
);
|
||||
assert.deepEqual(invocation, {
|
||||
command: "/work/tts",
|
||||
args: ["--text", "hello world", "--lang", "ru"],
|
||||
});
|
||||
});
|
||||
|
||||
test("Command template arrays inherit only top-level args and defaults", () => {
|
||||
const steps = expandCommandTemplateConfigs({
|
||||
template: [
|
||||
"tts --text {text} --lang {lang} --out {mp3}",
|
||||
{
|
||||
template: "ffmpeg -i {mp3} {ogg} {codec}",
|
||||
defaults: { codec: "opus" },
|
||||
timeout: 123,
|
||||
},
|
||||
],
|
||||
args: ["text", "lang", "mp3", "ogg"],
|
||||
defaults: { lang: "en" },
|
||||
output: "ogg",
|
||||
timeout: 999,
|
||||
});
|
||||
assert.deepEqual(steps, [
|
||||
{
|
||||
template: "tts --text {text} --lang {lang} --out {mp3}",
|
||||
args: ["text", "lang", "mp3", "ogg"],
|
||||
defaults: { lang: "en" },
|
||||
retry: undefined,
|
||||
},
|
||||
{
|
||||
template: "ffmpeg -i {mp3} {ogg} {codec}",
|
||||
args: ["text", "lang", "mp3", "ogg"],
|
||||
defaults: { lang: "en", codec: "opus" },
|
||||
timeout: 123,
|
||||
retry: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("Template composition expansion preserves retry and failure scope on step objects", () => {
|
||||
const steps = expandCommandTemplateConfigs({
|
||||
template: [
|
||||
"scan --path {dir}",
|
||||
{
|
||||
template: "lint --strict {dir}",
|
||||
retry: 3,
|
||||
failure: "root",
|
||||
},
|
||||
{
|
||||
template: "deploy {dir}",
|
||||
failure: "root",
|
||||
timeout: 60000,
|
||||
},
|
||||
],
|
||||
args: ["dir"],
|
||||
defaults: { dir: "./src" },
|
||||
});
|
||||
assert.deepEqual(steps, [
|
||||
{
|
||||
template: "scan --path {dir}",
|
||||
args: ["dir"],
|
||||
defaults: { dir: "./src" },
|
||||
retry: undefined,
|
||||
},
|
||||
{
|
||||
template: "lint --strict {dir}",
|
||||
args: ["dir"],
|
||||
defaults: { dir: "./src" },
|
||||
retry: 3,
|
||||
failure: "root",
|
||||
},
|
||||
{
|
||||
template: "deploy {dir}",
|
||||
args: ["dir"],
|
||||
defaults: { dir: "./src" },
|
||||
failure: "root",
|
||||
timeout: 60000,
|
||||
retry: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("Command template repeat expands numbered defaults", () => {
|
||||
const steps = expandCommandTemplateConfigs({
|
||||
repeat: 3,
|
||||
template:
|
||||
"render page{_(index+1)}.html prev=page{_(prev+1)}.html next=page{_(next+1)}.html raw={index}/{repeat}",
|
||||
});
|
||||
assert.equal(steps.length, 3);
|
||||
assert.deepEqual(
|
||||
{
|
||||
index: steps[0].defaults?.index,
|
||||
next: steps[0].defaults?.next,
|
||||
prev: steps[0].defaults?.prev,
|
||||
repeat: steps[0].defaults?.repeat,
|
||||
_index: steps[0].defaults?._index,
|
||||
__index: steps[0].defaults?.__index,
|
||||
_prev: steps[0].defaults?._prev,
|
||||
_next: steps[0].defaults?._next,
|
||||
},
|
||||
{
|
||||
index: "0",
|
||||
next: "1",
|
||||
prev: "2",
|
||||
repeat: "3",
|
||||
_index: "00",
|
||||
__index: "000",
|
||||
_prev: "02",
|
||||
_next: "01",
|
||||
},
|
||||
);
|
||||
const invocation = buildCommandTemplateInvocation(steps[0], {}, "/work");
|
||||
assert.deepEqual(invocation.args, [
|
||||
"page01.html",
|
||||
"prev=page03.html",
|
||||
"next=page02.html",
|
||||
"raw=0/3",
|
||||
]);
|
||||
assert.deepEqual(buildCommandTemplateInvocation(steps[2], {}, "/work").args, [
|
||||
"page03.html",
|
||||
"prev=page02.html",
|
||||
"next=page01.html",
|
||||
"raw=2/3",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Command templates detect high-risk trusted executable shapes", () => {
|
||||
const config = {
|
||||
template: [
|
||||
"bash -c {script}",
|
||||
"node -e {code}",
|
||||
"rm -rf {work_dir}",
|
||||
"npm publish",
|
||||
],
|
||||
};
|
||||
const warnings = getCommandTemplateWarnings(config);
|
||||
assert.equal(warnings.length, 3);
|
||||
assert.match(warnings[0], /bash/);
|
||||
assert.match(warnings[1], /eval/);
|
||||
assert.match(warnings[2], /removes filesystem paths/);
|
||||
assert.deepEqual(getCommandTemplateRiskLabels(config), [
|
||||
"risk.shell",
|
||||
"risk.eval",
|
||||
"risk.destructive_fs",
|
||||
"risk.external_side_effect",
|
||||
"risk.network",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Command templates resolve typed inline placeholders", () => {
|
||||
const invocation = buildCommandTemplateInvocation(
|
||||
"tool {file:path} {timeout:int=60000} {speed:number=1.5} {mode:enum(check,fix)=check}",
|
||||
{ file: "/tmp/a.txt" },
|
||||
"/work",
|
||||
);
|
||||
assert.deepEqual(invocation.args, ["/tmp/a.txt", "60000", "1.5", "check"]);
|
||||
});
|
||||
|
||||
test("Command templates resolve defaults and inline placeholder defaults", () => {
|
||||
const invocation = buildCommandTemplateInvocation(
|
||||
{
|
||||
template: "./tts --text {text} --lang {lang=ru} --rate {rate}",
|
||||
defaults: { rate: "+30%" },
|
||||
},
|
||||
{ text: "hello world" },
|
||||
"/work",
|
||||
);
|
||||
assert.deepEqual(invocation, {
|
||||
command: "/work/tts",
|
||||
args: ["--text", "hello world", "--lang", "ru", "--rate", "+30%"],
|
||||
});
|
||||
});
|
||||
|
||||
test("Command templates resolve modern placeholder operators and filter empty args", () => {
|
||||
assert.deepEqual(
|
||||
buildCommandTemplateInvocation(
|
||||
"tool --model {model??auto} {enabled?--enabled:} {missing?--missing:}",
|
||||
{ enabled: true },
|
||||
"/work",
|
||||
),
|
||||
{ command: "tool", args: ["--model", "auto", "--enabled"] },
|
||||
);
|
||||
});
|
||||
|
||||
test("Command templates resolve inherited default references and when guards", () => {
|
||||
const steps = expandCommandTemplateConfigs({
|
||||
defaults: { base: "parent" },
|
||||
template: [{ template: "echo {child}", defaults: { child: "{base}" } }],
|
||||
});
|
||||
assert.deepEqual(steps[0]?.defaults, { base: "parent", child: "parent" });
|
||||
assert.equal(
|
||||
shouldRunCommandTemplateNode("enabled", { enabled: "yes" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRunCommandTemplateNode("!enabled", { enabled: "no" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRunCommandTemplateNode("{enabled?yes:}", { enabled: false }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("Command template arrays preserve modern control fields", () => {
|
||||
const steps = expandCommandTemplateConfigs({
|
||||
parallel: true,
|
||||
when: "enabled",
|
||||
timeout: "{timeout}",
|
||||
delay: "{delay}",
|
||||
retry: "{retry}",
|
||||
template: "run",
|
||||
});
|
||||
assert.deepEqual(steps, [
|
||||
{
|
||||
parallel: true,
|
||||
when: "enabled",
|
||||
timeout: "{timeout}",
|
||||
delay: "{delay}",
|
||||
retry: "{retry}",
|
||||
template: "run",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("Command templates resolve array-index placeholders and recursive defaults", () => {
|
||||
const invocation = buildCommandTemplateInvocation(
|
||||
{
|
||||
defaults: { prompt: "{prompts[index]}" },
|
||||
template: "subagent {prompt}",
|
||||
},
|
||||
{ index: "1", prompts: ["left", "right"] },
|
||||
"/work",
|
||||
);
|
||||
assert.deepEqual(invocation.args, ["right"]);
|
||||
});
|
||||
|
||||
test("Command template execution writes stdin without invoking a shell", async () => {
|
||||
const result = await execCommandTemplate(
|
||||
process.execPath,
|
||||
[
|
||||
"-e",
|
||||
"process.stdin.on('data', data => process.stdout.write(String(data).toUpperCase()))",
|
||||
],
|
||||
{ stdin: "hello" },
|
||||
);
|
||||
assert.deepEqual(result, {
|
||||
stdout: "HELLO",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
killed: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("Command template timeout escalates when SIGTERM is ignored", async () => {
|
||||
const startedAt = Date.now();
|
||||
const result = await execCommandTemplate(
|
||||
process.execPath,
|
||||
["-e", "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);"],
|
||||
{ timeout: 500, killGrace: 10 },
|
||||
);
|
||||
assert.equal(result.killed, true);
|
||||
assert.notEqual(result.code, 0);
|
||||
assert.ok(Date.now() - startedAt < 2000);
|
||||
});
|
||||
|
||||
test("Command template retry succeeds on second attempt", async () => {
|
||||
const counterFile = `/tmp/ct-retry-${process.pid}.txt`;
|
||||
const { writeFileSync, readFileSync, unlinkSync } = await import("node:fs");
|
||||
writeFileSync(counterFile, "0");
|
||||
const script = `
|
||||
const fs = require("fs");
|
||||
const p = "${counterFile}";
|
||||
let n = parseInt(fs.readFileSync(p, "utf8"));
|
||||
n++;
|
||||
fs.writeFileSync(p, String(n));
|
||||
if (n < 2) process.exit(1);
|
||||
`;
|
||||
const result = await execCommandTemplate(process.execPath, ["-e", script], {
|
||||
retry: 2,
|
||||
killGrace: 10,
|
||||
});
|
||||
assert.equal(result.code, 0);
|
||||
assert.equal(readFileSync(counterFile, "utf8").trim(), "2");
|
||||
unlinkSync(counterFile);
|
||||
});
|
||||
|
||||
test("Command template retry exhausts attempts and surfaces last failure", async () => {
|
||||
const result = await execCommandTemplate(
|
||||
process.execPath,
|
||||
["-e", "process.exit(3)"],
|
||||
{ retry: 3, killGrace: 10 },
|
||||
);
|
||||
assert.notEqual(result.code, 0);
|
||||
assert.equal(result.killed, false);
|
||||
});
|
||||
|
||||
test("Command template retry default is 1 (no retry)", async () => {
|
||||
const result = await execCommandTemplate(
|
||||
process.execPath,
|
||||
["-e", "process.exit(1)"],
|
||||
{ killGrace: 10 },
|
||||
);
|
||||
assert.notEqual(result.code, 0);
|
||||
});
|
||||
|
||||
test("Command templates leave timeout disabled by default", async () => {
|
||||
const result = await execCommandTemplate(
|
||||
process.execPath,
|
||||
["-e", "setTimeout(() => {}, 100);"],
|
||||
{ killGrace: 10 },
|
||||
);
|
||||
assert.equal(result.killed, false);
|
||||
assert.equal(result.code, 0);
|
||||
});
|
||||
|
||||
test("Command templates report missing required placeholders", () => {
|
||||
assert.throws(
|
||||
() => buildCommandTemplateInvocation("tool {missing}", {}, "/work"),
|
||||
/Missing command template value: missing/,
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,920 @@
|
||||
/**
|
||||
* Regression tests for Telegram config and setup prompt defaults
|
||||
* Covers persisted config state plus token-prefill priority across stored config, environment variables, and placeholder fallback
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import {
|
||||
mkdtemp,
|
||||
readdir,
|
||||
readFile,
|
||||
rm,
|
||||
stat,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import type { TelegramConfig } from "../lib/config.ts";
|
||||
import {
|
||||
createTelegramConfigControls,
|
||||
createTelegramConfigStore,
|
||||
createTelegramProactivePushTargetGetter,
|
||||
createTelegramTimeInjectionModeGetter,
|
||||
createTelegramTimeInjectionModeSetter,
|
||||
createTelegramUserPairingRuntime,
|
||||
createTelegramVoiceReplyModeConfiguredChecker,
|
||||
createTelegramVoiceReplyModeGetter,
|
||||
createTelegramVoiceReplyModeSetter,
|
||||
getTelegramAuthorizationState,
|
||||
isValidTelegramProfileName,
|
||||
pairTelegramUserIfNeeded,
|
||||
readTelegramConfig,
|
||||
setGlobalTelegramConfigRuntime,
|
||||
updateTelegramVoiceConfig,
|
||||
writeTelegramConfig,
|
||||
} from "../lib/config.ts";
|
||||
import { createTelegramSettingsMenuRuntime } from "../lib/menu-settings.ts";
|
||||
import {
|
||||
createTelegramSetupPromptRuntime,
|
||||
getTelegramBotTokenInputDefault,
|
||||
getTelegramBotTokenPromptSpec,
|
||||
runTelegramSetup,
|
||||
} from "../lib/setup.ts";
|
||||
|
||||
test("Telegram profile names allow only lowercase letters and digits", () => {
|
||||
assert.equal(isValidTelegramProfileName("work2"), true);
|
||||
assert.equal(isValidTelegramProfileName("previous"), true);
|
||||
assert.equal(isValidTelegramProfileName("prev"), true);
|
||||
for (const name of [
|
||||
"default",
|
||||
"main",
|
||||
"active",
|
||||
"Work",
|
||||
"work-one",
|
||||
"work_one",
|
||||
"work.one",
|
||||
"work one",
|
||||
"",
|
||||
]) {
|
||||
assert.equal(isValidTelegramProfileName(name), false, name);
|
||||
}
|
||||
});
|
||||
|
||||
test("Telegram config helper returns empty config when file is absent", async () => {
|
||||
const agentDir = await mkdtemp(join(tmpdir(), "pi-telegram-missing-config-"));
|
||||
assert.deepEqual(
|
||||
await readTelegramConfig(join(agentDir, "telegram.json")),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
test("Telegram proactive target getter prefers active then assigned targets", () => {
|
||||
const target = createTelegramProactivePushTargetGetter({
|
||||
getActiveTurnTarget: () => undefined,
|
||||
getAssignedTarget: () => ({ chatId: -1007, threadId: 42 }),
|
||||
getAllowedUserId: () => 7,
|
||||
});
|
||||
assert.deepEqual(target(), { chatId: -1007, threadId: 42 });
|
||||
|
||||
const activeTarget = createTelegramProactivePushTargetGetter({
|
||||
getActiveTurnTarget: () => ({ chatId: -1008, threadId: 99 }),
|
||||
getAssignedTarget: () => ({ chatId: -1007, threadId: 42 }),
|
||||
getAllowedUserId: () => 7,
|
||||
});
|
||||
assert.deepEqual(activeTarget(), { chatId: -1008, threadId: 99 });
|
||||
|
||||
const privateTarget = createTelegramProactivePushTargetGetter({
|
||||
getActiveTurnTarget: () => undefined,
|
||||
getAssignedTarget: () => undefined,
|
||||
getAllowedUserId: () => 7,
|
||||
});
|
||||
assert.deepEqual(privateTarget(), { chatId: 7 });
|
||||
});
|
||||
|
||||
test("Telegram config helpers persist and reload config", async () => {
|
||||
const agentDir = await mkdtemp(join(tmpdir(), "pi-telegram-config-"));
|
||||
const configPath = join(agentDir, "telegram.json");
|
||||
const config = {
|
||||
botToken: "123:abc",
|
||||
botUsername: "demo_bot",
|
||||
allowedUserId: 42,
|
||||
};
|
||||
await writeTelegramConfig(agentDir, configPath, config);
|
||||
const reloaded = await readTelegramConfig(configPath);
|
||||
assert.deepEqual(reloaded, config);
|
||||
const raw = await readFile(configPath, "utf8");
|
||||
assert.match(raw, /demo_bot/);
|
||||
assert.equal((await stat(configPath)).mode & 0o777, 0o600);
|
||||
assert.deepEqual(
|
||||
(await readdir(agentDir)).filter((entry) => entry.includes(".tmp-")),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("Telegram config store persists active named profile session fields without overwriting default", async () => {
|
||||
const agentDir = await mkdtemp(join(tmpdir(), "pi-telegram-profile-config-"));
|
||||
const configPath = join(agentDir, "telegram.json");
|
||||
const store = createTelegramConfigStore({
|
||||
agentDir,
|
||||
configPath,
|
||||
initialConfig: {
|
||||
botToken: "default-token",
|
||||
botUsername: "default_bot",
|
||||
allowedUserId: 1,
|
||||
voice: { replyMode: "mirror" },
|
||||
profiles: {
|
||||
omp: {
|
||||
botToken: "omp-token",
|
||||
botUsername: "omp_bot",
|
||||
allowedUserId: 2,
|
||||
lastUpdateId: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(store.activateProfile("omp"), true);
|
||||
assert.equal(store.getBotToken(), "omp-token");
|
||||
assert.equal(store.getAllowedUserId(), 2);
|
||||
store.setAllowedUserId(3);
|
||||
await store.persist({ ...store.get(), lastUpdateId: 99 });
|
||||
|
||||
assert.deepEqual(await readTelegramConfig(configPath), {
|
||||
botToken: "default-token",
|
||||
botUsername: "default_bot",
|
||||
allowedUserId: 1,
|
||||
voice: { replyMode: "mirror" },
|
||||
profiles: {
|
||||
omp: {
|
||||
botToken: "omp-token",
|
||||
botUsername: "omp_bot",
|
||||
allowedUserId: 3,
|
||||
lastUpdateId: 99,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("Telegram config transactions merge concurrent profile offsets and global fields", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "pi-telegram-config-race-"));
|
||||
const configPath = join(dir, "telegram.json");
|
||||
const startPath = join(dir, "start");
|
||||
await writeFile(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
botToken: "default-token",
|
||||
profiles: {
|
||||
work: { botToken: "work-token", lastUpdateId: 1 },
|
||||
personal: { botToken: "personal-token", lastUpdateId: 2 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const moduleUrl = new URL("../lib/config.ts", import.meta.url).href;
|
||||
const children = [
|
||||
{ profile: "work", offset: "11", field: "proactive" },
|
||||
{ profile: "personal", offset: "22", field: "voice" },
|
||||
].map(({ profile, offset, field }) => {
|
||||
const readyPath = join(dir, `ready-${profile}`);
|
||||
const source = `
|
||||
import { existsSync, writeFileSync } from "node:fs";
|
||||
import { createTelegramConfigStore } from ${JSON.stringify(moduleUrl)};
|
||||
const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||||
const store = createTelegramConfigStore({
|
||||
agentDir: process.env.AGENT_DIR,
|
||||
configPath: process.env.CONFIG_PATH,
|
||||
});
|
||||
await store.load();
|
||||
if (!store.activateProfile(process.env.PROFILE)) throw new Error("profile missing");
|
||||
writeFileSync(process.env.READY_PATH, "ready");
|
||||
while (!existsSync(process.env.START_PATH)) sleep(2);
|
||||
store.update((config) => {
|
||||
config.lastUpdateId = Number(process.env.OFFSET);
|
||||
if (process.env.FIELD === "proactive") config.proactivePush = true;
|
||||
else config.voice = { ...(config.voice ?? {}), replyMode: "mirror" };
|
||||
});
|
||||
await store.persist();
|
||||
`;
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
["--experimental-strip-types", "--input-type=module", "--eval", source],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
AGENT_DIR: dir,
|
||||
CONFIG_PATH: configPath,
|
||||
PROFILE: profile,
|
||||
OFFSET: offset,
|
||||
FIELD: field,
|
||||
READY_PATH: readyPath,
|
||||
START_PATH: startPath,
|
||||
},
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
},
|
||||
);
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (chunk) => (stderr += String(chunk)));
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code) =>
|
||||
code === 0
|
||||
? resolve()
|
||||
: reject(new Error(`config child exited ${code}: ${stderr}`)),
|
||||
);
|
||||
});
|
||||
return { readyPath, done };
|
||||
});
|
||||
try {
|
||||
const deadline = Date.now() + 3000;
|
||||
while (
|
||||
!children.every((child) => existsSync(child.readyPath)) &&
|
||||
Date.now() < deadline
|
||||
) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
assert.equal(children.every((child) => existsSync(child.readyPath)), true);
|
||||
await writeFile(startPath, "start");
|
||||
await Promise.all(children.map((child) => child.done));
|
||||
|
||||
assert.deepEqual(await readTelegramConfig(configPath), {
|
||||
botToken: "default-token",
|
||||
proactivePush: true,
|
||||
voice: { replyMode: "mirror" },
|
||||
profiles: {
|
||||
work: { botToken: "work-token", lastUpdateId: 11 },
|
||||
personal: { botToken: "personal-token", lastUpdateId: 22 },
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Telegram config keeps same-profile polling offsets monotonic", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "pi-telegram-config-offset-"));
|
||||
const configPath = join(dir, "telegram.json");
|
||||
await writeFile(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
profiles: { work: { botToken: "work-token", lastUpdateId: 10 } },
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const stale = createTelegramConfigStore({ agentDir: dir, configPath });
|
||||
const replacement = createTelegramConfigStore({ agentDir: dir, configPath });
|
||||
await stale.load();
|
||||
await replacement.load();
|
||||
assert.equal(stale.activateProfile("work"), true);
|
||||
assert.equal(replacement.activateProfile("work"), true);
|
||||
const stalePollingConfig = stale.get();
|
||||
stalePollingConfig.lastUpdateId = 12;
|
||||
const replacementPollingConfig = replacement.get();
|
||||
replacementPollingConfig.lastUpdateId = 20;
|
||||
|
||||
await replacement.persist(replacementPollingConfig);
|
||||
await stale.persist(stalePollingConfig);
|
||||
|
||||
assert.equal(
|
||||
(await readTelegramConfig(configPath)).profiles?.work.lastUpdateId,
|
||||
20,
|
||||
);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Telegram config store rejects missing named profile activation", () => {
|
||||
const store = createTelegramConfigStore({
|
||||
initialConfig: { profiles: { work: { botToken: "work-token" } } },
|
||||
});
|
||||
|
||||
assert.equal(store.activateProfile("missing"), false);
|
||||
assert.equal(store.getActiveProfileName(), undefined);
|
||||
assert.equal(store.getBotToken(), undefined);
|
||||
});
|
||||
|
||||
test("Telegram config load recovers invalid JSON and records a diagnostic", async () => {
|
||||
const agentDir = await mkdtemp(join(tmpdir(), "pi-telegram-invalid-config-"));
|
||||
const configPath = join(agentDir, "telegram.json");
|
||||
await writeFile(configPath, "{not valid json", "utf8");
|
||||
const events: string[] = [];
|
||||
const store = createTelegramConfigStore({
|
||||
agentDir,
|
||||
configPath,
|
||||
initialConfig: { botToken: "previous" },
|
||||
recordRuntimeEvent: (category, error, details) => {
|
||||
events.push(
|
||||
`${category}:${error instanceof Error ? error.name : String(error)}:${details?.phase}:${String(details?.recoveryPath ?? "")}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
await store.load();
|
||||
|
||||
assert.deepEqual(store.get(), {});
|
||||
const entries = await readdir(agentDir);
|
||||
const recovery = entries.find((entry) =>
|
||||
entry.startsWith("telegram.json.invalid-"),
|
||||
);
|
||||
assert.ok(recovery);
|
||||
assert.equal(
|
||||
await readFile(join(agentDir, recovery), "utf8"),
|
||||
"{not valid json",
|
||||
);
|
||||
assert.equal(entries.includes("telegram.json"), false);
|
||||
assert.equal(events.length, 1);
|
||||
assert.match(events[0] ?? "", /^config:SyntaxError:load:/);
|
||||
});
|
||||
|
||||
test("Telegram voice reply mode helpers distinguish implicit and explicit manual", () => {
|
||||
let config: TelegramConfig = {};
|
||||
const store = { get: () => config };
|
||||
const getMode = createTelegramVoiceReplyModeGetter(store);
|
||||
const isConfigured = createTelegramVoiceReplyModeConfiguredChecker(store);
|
||||
|
||||
assert.equal(getMode(), "manual");
|
||||
assert.equal(isConfigured(), false);
|
||||
|
||||
config = { voice: { replyMode: "manual" } };
|
||||
assert.equal(getMode(), "manual");
|
||||
assert.equal(isConfigured(), true);
|
||||
|
||||
config = { voice: { replyMode: "invalid" } } as unknown as TelegramConfig;
|
||||
assert.equal(getMode(), "manual");
|
||||
assert.equal(isConfigured(), false);
|
||||
});
|
||||
|
||||
test("Telegram voice reply mode setter persists telegram.json", async () => {
|
||||
const agentDir = await mkdtemp(join(tmpdir(), "pi-telegram-voice-mode-"));
|
||||
const configPath = join(agentDir, "telegram.json");
|
||||
const store = createTelegramConfigStore({
|
||||
initialConfig: { botToken: "123:abc" },
|
||||
agentDir,
|
||||
configPath,
|
||||
});
|
||||
const setMode = createTelegramVoiceReplyModeSetter(store);
|
||||
|
||||
await setMode("mirror");
|
||||
|
||||
assert.deepEqual(store.get().voice, { replyMode: "mirror" });
|
||||
assert.deepEqual(await readTelegramConfig(configPath), {
|
||||
botToken: "123:abc",
|
||||
voice: { replyMode: "mirror" },
|
||||
});
|
||||
|
||||
await setMode(undefined);
|
||||
|
||||
assert.equal(store.get().voice, undefined);
|
||||
assert.deepEqual(await readTelegramConfig(configPath), {
|
||||
botToken: "123:abc",
|
||||
});
|
||||
});
|
||||
|
||||
test("Telegram settings setters reload before scoped writes to preserve shared config changes", async () => {
|
||||
const agentDir = await mkdtemp(
|
||||
join(tmpdir(), "pi-telegram-shared-settings-"),
|
||||
);
|
||||
const configPath = join(agentDir, "telegram.json");
|
||||
await writeTelegramConfig(agentDir, configPath, { botToken: "123:abc" });
|
||||
const firstStore = createTelegramConfigStore({ agentDir, configPath });
|
||||
const secondStore = createTelegramConfigStore({ agentDir, configPath });
|
||||
await firstStore.load();
|
||||
await secondStore.load();
|
||||
|
||||
const setVoiceMode = createTelegramVoiceReplyModeSetter(firstStore);
|
||||
const controls = createTelegramConfigControls(secondStore);
|
||||
|
||||
await setVoiceMode("mirror");
|
||||
await controls.setProactivePushEnabled(true);
|
||||
await controls.setDraftPreviewsEnabled(true);
|
||||
await controls.setAssistantRenderingMode("html");
|
||||
|
||||
assert.deepEqual(await readTelegramConfig(configPath), {
|
||||
botToken: "123:abc",
|
||||
proactivePush: true,
|
||||
assistant: { draftPreviews: true, rendering: "html" },
|
||||
voice: { replyMode: "mirror" },
|
||||
});
|
||||
assert.equal(controls.getAssistantRenderingMode(), "html");
|
||||
assert.deepEqual(secondStore.get().voice, { replyMode: "mirror" });
|
||||
});
|
||||
|
||||
test("Telegram draft preview config reads and migrates legacy rich flag", async () => {
|
||||
const agentDir = await mkdtemp(join(tmpdir(), "pi-telegram-draft-legacy-"));
|
||||
const configPath = join(agentDir, "telegram.json");
|
||||
await writeTelegramConfig(agentDir, configPath, {
|
||||
botToken: "123:abc",
|
||||
richDraftPreviews: true,
|
||||
});
|
||||
const store = createTelegramConfigStore({ agentDir, configPath });
|
||||
await store.load();
|
||||
const controls = createTelegramConfigControls(store);
|
||||
|
||||
assert.equal(controls.areDraftPreviewsEnabled(), true);
|
||||
await controls.setDraftPreviewsEnabled(false);
|
||||
assert.deepEqual(await readTelegramConfig(configPath), {
|
||||
botToken: "123:abc",
|
||||
assistant: { draftPreviews: false },
|
||||
});
|
||||
});
|
||||
|
||||
test("Telegram settings menu callbacks persist voice and time settings to telegram.json", async () => {
|
||||
const agentDir = await mkdtemp(
|
||||
join(tmpdir(), "pi-telegram-settings-callbacks-"),
|
||||
);
|
||||
const configPath = join(agentDir, "telegram.json");
|
||||
const store = createTelegramConfigStore({
|
||||
initialConfig: { botToken: "123:abc" },
|
||||
agentDir,
|
||||
configPath,
|
||||
});
|
||||
const controls = createTelegramConfigControls(store);
|
||||
const state = {
|
||||
chatId: 1,
|
||||
messageId: 2,
|
||||
mode: "settings" as const,
|
||||
page: 0,
|
||||
scope: "all" as const,
|
||||
scopedModels: [],
|
||||
allModels: [],
|
||||
};
|
||||
const runtime = createTelegramSettingsMenuRuntime({
|
||||
...controls,
|
||||
getModelMenuState: async () => state,
|
||||
getStoredModelMenuState: () => state,
|
||||
storeModelMenuState: () => {},
|
||||
editInteractiveMessage: async () => {},
|
||||
sendInteractiveMessage: async () => state.messageId,
|
||||
answerCallbackQuery: async () => {},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
await runtime.handleCallbackQuery(
|
||||
{
|
||||
id: "voice",
|
||||
data: "settings:set:voice-reply:mirror",
|
||||
message: { message_id: state.messageId },
|
||||
},
|
||||
{},
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await runtime.handleCallbackQuery(
|
||||
{
|
||||
id: "time",
|
||||
data: "settings:set:time-injection:always",
|
||||
message: { message_id: state.messageId },
|
||||
},
|
||||
{},
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await runtime.handleCallbackQuery(
|
||||
{
|
||||
id: "drafts",
|
||||
data: "settings:set:draft-previews:on",
|
||||
message: { message_id: state.messageId },
|
||||
},
|
||||
{},
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(await readTelegramConfig(configPath), {
|
||||
botToken: "123:abc",
|
||||
assistant: { draftPreviews: true },
|
||||
voice: { replyMode: "mirror" },
|
||||
time: { injectionMode: "always" },
|
||||
});
|
||||
});
|
||||
|
||||
test("Telegram time injection mode setter persists telegram.json", async () => {
|
||||
const agentDir = await mkdtemp(join(tmpdir(), "pi-telegram-time-mode-"));
|
||||
const configPath = join(agentDir, "telegram.json");
|
||||
const store = createTelegramConfigStore({
|
||||
initialConfig: { botToken: "123:abc", time: { interval: 5000 } },
|
||||
agentDir,
|
||||
configPath,
|
||||
});
|
||||
const getMode = createTelegramTimeInjectionModeGetter(store);
|
||||
const setMode = createTelegramTimeInjectionModeSetter(store);
|
||||
|
||||
assert.equal(getMode(), "hidden");
|
||||
|
||||
await setMode("interval");
|
||||
|
||||
assert.equal(getMode(), "interval");
|
||||
assert.deepEqual(await readTelegramConfig(configPath), {
|
||||
botToken: "123:abc",
|
||||
time: { interval: 5000, injectionMode: "interval" },
|
||||
});
|
||||
|
||||
await setMode("hidden");
|
||||
|
||||
assert.equal(getMode(), "hidden");
|
||||
assert.deepEqual(await readTelegramConfig(configPath), {
|
||||
botToken: "123:abc",
|
||||
time: { interval: 5000 },
|
||||
});
|
||||
});
|
||||
|
||||
test("Telegram config runtime lets extensions update live voice config", async () => {
|
||||
let voice: TelegramConfig["voice"] | undefined;
|
||||
setGlobalTelegramConfigRuntime({
|
||||
updateVoiceConfig: (nextVoice) => {
|
||||
voice = nextVoice;
|
||||
},
|
||||
});
|
||||
try {
|
||||
assert.equal(updateTelegramVoiceConfig({ replyMode: "mirror" }), true);
|
||||
assert.deepEqual(voice, { replyMode: "mirror" });
|
||||
} finally {
|
||||
setGlobalTelegramConfigRuntime(undefined);
|
||||
}
|
||||
assert.equal(updateTelegramVoiceConfig({ replyMode: "always" }), false);
|
||||
});
|
||||
|
||||
test("Telegram config store owns load, mutation, and persistence", async () => {
|
||||
const agentDir = await mkdtemp(join(tmpdir(), "pi-telegram-store-"));
|
||||
const configPath = join(agentDir, "telegram.json");
|
||||
const store = createTelegramConfigStore({
|
||||
initialConfig: {
|
||||
botToken: "initial",
|
||||
inboundHandlers: [{ type: "text", template: "translate" }],
|
||||
attachmentHandlers: [{ mime: "audio/*", template: "transcribe {file}" }],
|
||||
},
|
||||
agentDir,
|
||||
configPath,
|
||||
});
|
||||
assert.deepEqual(store.get(), {
|
||||
botToken: "initial",
|
||||
inboundHandlers: [{ type: "text", template: "translate" }],
|
||||
attachmentHandlers: [{ mime: "audio/*", template: "transcribe {file}" }],
|
||||
});
|
||||
store.update((config) => {
|
||||
config.allowedUserId = 42;
|
||||
});
|
||||
assert.equal(store.getBotToken(), "initial");
|
||||
assert.equal(store.hasBotToken(), true);
|
||||
assert.equal(store.getAllowedUserId(), 42);
|
||||
assert.deepEqual(store.getInboundHandlers(), [
|
||||
{ type: "text", template: "translate" },
|
||||
{ mime: "audio/*", template: "transcribe {file}" },
|
||||
]);
|
||||
assert.deepEqual(store.getAttachmentHandlers(), [
|
||||
{ mime: "audio/*", template: "transcribe {file}" },
|
||||
]);
|
||||
store.setAllowedUserId(43);
|
||||
assert.equal(store.getAllowedUserId(), 43);
|
||||
await store.persist();
|
||||
assert.deepEqual(await readTelegramConfig(configPath), {
|
||||
botToken: "initial",
|
||||
inboundHandlers: [{ type: "text", template: "translate" }],
|
||||
attachmentHandlers: [{ mime: "audio/*", template: "transcribe {file}" }],
|
||||
allowedUserId: 43,
|
||||
});
|
||||
store.set({ botToken: "next" });
|
||||
assert.deepEqual(store.get(), { botToken: "next" });
|
||||
await store.load();
|
||||
assert.deepEqual(store.get(), {
|
||||
botToken: "initial",
|
||||
inboundHandlers: [{ type: "text", template: "translate" }],
|
||||
attachmentHandlers: [{ mime: "audio/*", template: "transcribe {file}" }],
|
||||
allowedUserId: 43,
|
||||
});
|
||||
});
|
||||
|
||||
test("Telegram config helpers classify authorization state for pair, allow, and deny", () => {
|
||||
assert.deepEqual(getTelegramAuthorizationState(10), {
|
||||
kind: "pair",
|
||||
userId: 10,
|
||||
});
|
||||
assert.deepEqual(getTelegramAuthorizationState(10, 10), { kind: "allow" });
|
||||
assert.deepEqual(getTelegramAuthorizationState(10, 11), { kind: "deny" });
|
||||
});
|
||||
|
||||
test("Telegram config helpers pair only when no user is configured", async () => {
|
||||
const events: string[] = [];
|
||||
let allowedUserId: number | undefined;
|
||||
assert.equal(
|
||||
await pairTelegramUserIfNeeded(10, {
|
||||
allowedUserId,
|
||||
ctx: "ctx",
|
||||
setAllowedUserId: (userId) => {
|
||||
allowedUserId = userId;
|
||||
events.push(`set:${userId}`);
|
||||
},
|
||||
persistConfig: async () => {
|
||||
events.push("persist");
|
||||
},
|
||||
updateStatus: (ctx) => {
|
||||
events.push(`status:${ctx}`);
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await pairTelegramUserIfNeeded(11, {
|
||||
allowedUserId,
|
||||
ctx: "ctx",
|
||||
setAllowedUserId: () => {
|
||||
events.push("unexpected:set");
|
||||
},
|
||||
persistConfig: async () => {
|
||||
events.push("unexpected:persist");
|
||||
},
|
||||
updateStatus: () => {
|
||||
events.push("unexpected:status");
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(allowedUserId, 10);
|
||||
assert.deepEqual(events, ["set:10", "persist", "status:ctx"]);
|
||||
});
|
||||
|
||||
test("Telegram config pairing swallows only stale context status errors", async () => {
|
||||
await assert.doesNotReject(() =>
|
||||
pairTelegramUserIfNeeded(10, {
|
||||
ctx: "ctx",
|
||||
setAllowedUserId: () => {},
|
||||
persistConfig: async () => {},
|
||||
updateStatus: () => {
|
||||
throw new Error("ctx is stale after session replacement");
|
||||
},
|
||||
}),
|
||||
);
|
||||
await assert.rejects(
|
||||
() =>
|
||||
pairTelegramUserIfNeeded(10, {
|
||||
ctx: "ctx",
|
||||
setAllowedUserId: () => {},
|
||||
persistConfig: async () => {},
|
||||
updateStatus: () => {
|
||||
throw new Error("status broke");
|
||||
},
|
||||
}),
|
||||
/status broke/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Telegram config pairing runtime binds config and status ports", async () => {
|
||||
const events: string[] = [];
|
||||
let allowedUserId: number | undefined;
|
||||
const runtime = createTelegramUserPairingRuntime({
|
||||
getAllowedUserId: () => allowedUserId,
|
||||
setAllowedUserId: (userId) => {
|
||||
allowedUserId = userId;
|
||||
events.push(`set:${userId}`);
|
||||
},
|
||||
persistConfig: async () => {
|
||||
events.push("persist");
|
||||
},
|
||||
updateStatus: (ctx: string) => {
|
||||
events.push(`status:${ctx}`);
|
||||
},
|
||||
});
|
||||
assert.equal(await runtime.pairIfNeeded(7, "ctx"), true);
|
||||
assert.equal(await runtime.pairIfNeeded(8, "ctx"), false);
|
||||
assert.deepEqual(events, ["set:7", "persist", "status:ctx"]);
|
||||
});
|
||||
|
||||
test("Bot token input prefers stored config over env vars", () => {
|
||||
const value = getTelegramBotTokenInputDefault(
|
||||
{
|
||||
TELEGRAM_KEY: "key-last",
|
||||
TELEGRAM_TOKEN: "token-third",
|
||||
TELEGRAM_BOT_KEY: "key-second",
|
||||
TELEGRAM_BOT_TOKEN: "token-first",
|
||||
},
|
||||
"stored-token",
|
||||
);
|
||||
assert.equal(value, "stored-token");
|
||||
});
|
||||
|
||||
test("Bot token input prefers the first configured Telegram env var when no config exists", () => {
|
||||
const value = getTelegramBotTokenInputDefault({
|
||||
TELEGRAM_KEY: "key-last",
|
||||
TELEGRAM_TOKEN: "token-third",
|
||||
TELEGRAM_BOT_KEY: "key-second",
|
||||
TELEGRAM_BOT_TOKEN: "token-first",
|
||||
});
|
||||
assert.equal(value, "token-first");
|
||||
});
|
||||
|
||||
test("Bot token prompt uses the editor when a real prefill exists", () => {
|
||||
const prompt = getTelegramBotTokenPromptSpec({
|
||||
TELEGRAM_BOT_TOKEN: "token-first",
|
||||
});
|
||||
assert.deepEqual(prompt, {
|
||||
method: "editor",
|
||||
value: "token-first",
|
||||
});
|
||||
});
|
||||
|
||||
test("Bot token prompt shows stored config before env values", () => {
|
||||
const prompt = getTelegramBotTokenPromptSpec(
|
||||
{
|
||||
TELEGRAM_BOT_TOKEN: "token-first",
|
||||
},
|
||||
"stored-token",
|
||||
);
|
||||
assert.deepEqual(prompt, {
|
||||
method: "editor",
|
||||
value: "stored-token",
|
||||
});
|
||||
});
|
||||
|
||||
test("Bot token input skips blank env vars and falls back to config", () => {
|
||||
const value = getTelegramBotTokenInputDefault(
|
||||
{
|
||||
TELEGRAM_BOT_TOKEN: " ",
|
||||
TELEGRAM_BOT_KEY: "",
|
||||
TELEGRAM_TOKEN: " ",
|
||||
},
|
||||
"stored-token",
|
||||
);
|
||||
assert.equal(value, "stored-token");
|
||||
});
|
||||
|
||||
test("Bot token input falls back to placeholder when no value exists", () => {
|
||||
const value = getTelegramBotTokenInputDefault({});
|
||||
assert.equal(value, "123456:ABCDEF...");
|
||||
});
|
||||
|
||||
test("Bot token prompt uses placeholder input when no prefill exists", () => {
|
||||
const prompt = getTelegramBotTokenPromptSpec({});
|
||||
assert.deepEqual(prompt, {
|
||||
method: "input",
|
||||
value: "123456:ABCDEF...",
|
||||
});
|
||||
});
|
||||
|
||||
test("Setup runtime prompts, validates token, persists config, and starts polling", async () => {
|
||||
const events: string[] = [];
|
||||
const nextConfig = await runTelegramSetup({
|
||||
hasUI: true,
|
||||
env: { TELEGRAM_BOT_TOKEN: "env-token" },
|
||||
config: { allowedUserId: 7 },
|
||||
promptInput: async () => {
|
||||
events.push("input");
|
||||
return undefined;
|
||||
},
|
||||
promptEditor: async (label, value) => {
|
||||
events.push(`editor:${label}:${value}`);
|
||||
return "new-token";
|
||||
},
|
||||
getMe: async (botToken) => {
|
||||
events.push(`getMe:${botToken}`);
|
||||
return { ok: true, result: { id: 42, username: "demo_bot" } };
|
||||
},
|
||||
persistConfig: async (config) => {
|
||||
events.push(`persist:${config.botToken}:${config.botUsername}`);
|
||||
},
|
||||
notify: (message, level) => {
|
||||
events.push(`notify:${level}:${message}`);
|
||||
},
|
||||
startPolling: async () => {
|
||||
events.push("poll");
|
||||
},
|
||||
updateStatus: () => {
|
||||
events.push("status");
|
||||
},
|
||||
});
|
||||
assert.deepEqual(nextConfig, {
|
||||
status: "success",
|
||||
config: {
|
||||
allowedUserId: 7,
|
||||
botToken: "new-token",
|
||||
botId: 42,
|
||||
botUsername: "demo_bot",
|
||||
},
|
||||
});
|
||||
assert.deepEqual(events, [
|
||||
"editor:Telegram bot token:env-token",
|
||||
"getMe:new-token",
|
||||
"persist:new-token:demo_bot",
|
||||
"notify:info:Telegram bot connected: @demo_bot",
|
||||
"notify:info:Send /start to your bot in Telegram to pair this extension with your account.",
|
||||
"poll",
|
||||
"status",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Setup runtime reports invalid tokens without persisting", async () => {
|
||||
const events: string[] = [];
|
||||
const nextConfig = await runTelegramSetup({
|
||||
hasUI: true,
|
||||
env: {},
|
||||
config: {},
|
||||
promptInput: async () => "bad-token",
|
||||
promptEditor: async () => undefined,
|
||||
getMe: async () => ({ ok: false, description: "nope" }),
|
||||
persistConfig: async () => {
|
||||
events.push("persist");
|
||||
},
|
||||
notify: (message, level) => {
|
||||
events.push(`notify:${level}:${message}`);
|
||||
},
|
||||
startPolling: async () => {
|
||||
events.push("poll");
|
||||
},
|
||||
updateStatus: () => {
|
||||
events.push("status");
|
||||
},
|
||||
});
|
||||
assert.deepEqual(nextConfig, { status: "validation-failed" });
|
||||
assert.deepEqual(events, ["notify:error:nope"]);
|
||||
});
|
||||
|
||||
test("Setup prompt runtime guards concurrent setup and stores successful config", async () => {
|
||||
const events: string[] = [];
|
||||
let config: TelegramConfig = { allowedUserId: 7 };
|
||||
let inProgress = false;
|
||||
const promptForConfig = createTelegramSetupPromptRuntime({
|
||||
env: { TELEGRAM_BOT_TOKEN: "env-token" },
|
||||
getConfig: () => config,
|
||||
setConfig: (nextConfig) => {
|
||||
config = nextConfig;
|
||||
events.push(`set:${nextConfig.botUsername}`);
|
||||
},
|
||||
setupGuard: {
|
||||
start: () => {
|
||||
events.push("start");
|
||||
if (inProgress) return false;
|
||||
inProgress = true;
|
||||
return true;
|
||||
},
|
||||
finish: () => {
|
||||
events.push("finish");
|
||||
inProgress = false;
|
||||
},
|
||||
},
|
||||
getMe: async (botToken) => {
|
||||
events.push(`getMe:${botToken}`);
|
||||
return { ok: true, result: { id: 42, username: "demo_bot" } };
|
||||
},
|
||||
persistConfig: async (nextConfig) => {
|
||||
events.push(`persist:${nextConfig.botToken}`);
|
||||
},
|
||||
startPolling: async () => {
|
||||
events.push("poll");
|
||||
},
|
||||
updateStatus: () => {
|
||||
events.push("status");
|
||||
},
|
||||
});
|
||||
await promptForConfig({
|
||||
hasUI: true,
|
||||
ui: {
|
||||
input: async () => undefined,
|
||||
editor: async (_label, value) => {
|
||||
events.push(`editor:${value}`);
|
||||
return "new-token";
|
||||
},
|
||||
notify: (message, level) => {
|
||||
events.push(`notify:${level}:${message}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
inProgress = true;
|
||||
await promptForConfig({
|
||||
hasUI: true,
|
||||
ui: {
|
||||
input: async () => {
|
||||
events.push("blocked-input");
|
||||
return undefined;
|
||||
},
|
||||
editor: async () => {
|
||||
events.push("blocked-editor");
|
||||
return undefined;
|
||||
},
|
||||
notify: () => {
|
||||
events.push("blocked-notify");
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.deepEqual(config, {
|
||||
allowedUserId: 7,
|
||||
botToken: "new-token",
|
||||
botId: 42,
|
||||
botUsername: "demo_bot",
|
||||
});
|
||||
assert.deepEqual(events, [
|
||||
"start",
|
||||
"editor:env-token",
|
||||
"getMe:new-token",
|
||||
"set:demo_bot",
|
||||
"persist:new-token",
|
||||
"notify:info:Telegram bot connected: @demo_bot",
|
||||
"notify:info:Send /start to your bot in Telegram to pair this extension with your account.",
|
||||
"poll",
|
||||
"status",
|
||||
"finish",
|
||||
"start",
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,752 @@
|
||||
/**
|
||||
* Telegram delivery API domain regressions
|
||||
* Zones: telegram delivery, extension API, runtime binding
|
||||
* Mirrors lib/delivery.ts and protects public operation delegation, generation fencing, and fail-soft availability
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
bindTelegramDeliveryRuntime,
|
||||
clearTelegramDeliveryRuntime,
|
||||
createTelegramBridgeDeliveryRuntime,
|
||||
createTelegramDeliveryLifecycleHooks,
|
||||
createTelegramDeliveryTargetPolicyRuntime,
|
||||
createTelegramDeliveryRuntime as createConcreteDeliveryRuntime,
|
||||
deleteTelegramView,
|
||||
editTelegramView,
|
||||
isTelegramDeliveryExplicitTargetAuthorized,
|
||||
resolveTelegramDeliveryAggregateTarget,
|
||||
resolveTelegramDeliveryInstanceTarget,
|
||||
sendTelegramChatAction,
|
||||
sendTelegramView,
|
||||
type TelegramDeliveryHandle,
|
||||
type TelegramDeliveryRuntime,
|
||||
type TelegramDeliveryRuntimeDeps,
|
||||
} from "../lib/delivery.ts";
|
||||
import { TelegramApiCommitUnknownError } from "../lib/telegram-api.ts";
|
||||
|
||||
const target = { chatId: 42, threadId: 7 };
|
||||
|
||||
function createRuntime(
|
||||
generation: string,
|
||||
calls: string[] = [],
|
||||
): TelegramDeliveryRuntime {
|
||||
return {
|
||||
generation,
|
||||
shutdown() {},
|
||||
async sendView() {
|
||||
calls.push("send");
|
||||
return {
|
||||
ok: true,
|
||||
value: { target, messageIds: [11], generation },
|
||||
};
|
||||
},
|
||||
async editView() {
|
||||
calls.push("edit");
|
||||
return {
|
||||
ok: true,
|
||||
value: { target, messageIds: [11], generation },
|
||||
};
|
||||
},
|
||||
async deleteView() {
|
||||
calls.push("delete");
|
||||
return { ok: true, value: undefined };
|
||||
},
|
||||
async sendChatAction(action) {
|
||||
calls.push(`action:${action}`);
|
||||
return { ok: true, value: undefined };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function handle(generation: string): TelegramDeliveryHandle {
|
||||
return { target, messageIds: [11], generation };
|
||||
}
|
||||
|
||||
test.afterEach(() => clearTelegramDeliveryRuntime());
|
||||
|
||||
test("Delivery API fails softly while no runtime is bound", async () => {
|
||||
const result = await sendTelegramView(
|
||||
{ text: "hello" },
|
||||
{ scope: { kind: "instance" } },
|
||||
);
|
||||
assert.deepEqual(result, {
|
||||
ok: false,
|
||||
reason: "runtime-unavailable",
|
||||
message: "Telegram delivery runtime is unavailable in this Pi session.",
|
||||
});
|
||||
});
|
||||
|
||||
test("Delivery API delegates operations to the current runtime", async () => {
|
||||
const calls: string[] = [];
|
||||
bindTelegramDeliveryRuntime(createRuntime("one", calls));
|
||||
const sent = await sendTelegramView(
|
||||
{ text: "hello", parseMode: "html" },
|
||||
{ scope: { kind: "active-turn" }, replyToMessageId: 5 },
|
||||
);
|
||||
assert.equal(sent.ok, true);
|
||||
await editTelegramView(handle("one"), { text: "updated" });
|
||||
await deleteTelegramView(handle("one"));
|
||||
await sendTelegramChatAction("typing", {
|
||||
scope: { kind: "aggregate" },
|
||||
});
|
||||
assert.deepEqual(calls, ["send", "edit", "delete", "action:typing"]);
|
||||
});
|
||||
|
||||
test("Delivery API rejects empty views before transport", async () => {
|
||||
const calls: string[] = [];
|
||||
bindTelegramDeliveryRuntime(createRuntime("one", calls));
|
||||
const result = await sendTelegramView(
|
||||
{ text: " " },
|
||||
{ scope: { kind: "instance" } },
|
||||
);
|
||||
assert.equal(result.ok, false);
|
||||
if (!result.ok) assert.equal(result.reason, "invalid-view");
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test("Delivery API fences handles from older runtime generations", async () => {
|
||||
const calls: string[] = [];
|
||||
bindTelegramDeliveryRuntime(createRuntime("new", calls));
|
||||
const edited = await editTelegramView(handle("old"), { text: "updated" });
|
||||
const deleted = await deleteTelegramView(handle("old"));
|
||||
assert.equal(edited.ok, false);
|
||||
assert.equal(deleted.ok, false);
|
||||
if (!edited.ok) assert.equal(edited.reason, "stale-handle");
|
||||
if (!deleted.ok) assert.equal(deleted.reason, "stale-handle");
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test("A stale runtime disposer cannot clear its replacement", async () => {
|
||||
const firstCalls: string[] = [];
|
||||
const secondCalls: string[] = [];
|
||||
const disposeFirst = bindTelegramDeliveryRuntime(
|
||||
createRuntime("first", firstCalls),
|
||||
);
|
||||
bindTelegramDeliveryRuntime(createRuntime("second", secondCalls));
|
||||
disposeFirst();
|
||||
await sendTelegramView(
|
||||
{ text: "hello" },
|
||||
{ scope: { kind: "instance" } },
|
||||
);
|
||||
assert.deepEqual(firstCalls, []);
|
||||
assert.deepEqual(secondCalls, ["send"]);
|
||||
});
|
||||
|
||||
test("Delivery lifecycle mints fresh generations and invalidates on shutdown", async () => {
|
||||
const calls: string[] = [];
|
||||
let sequence = 0;
|
||||
const lifecycle = createTelegramDeliveryLifecycleHooks(() =>
|
||||
createRuntime(`generation-${++sequence}`, calls),
|
||||
);
|
||||
await lifecycle.onSessionStart();
|
||||
const active = await sendTelegramView(
|
||||
{ text: "hello" },
|
||||
{ scope: { kind: "instance" } },
|
||||
);
|
||||
assert.equal(active.ok, true);
|
||||
if (!active.ok) return;
|
||||
|
||||
await lifecycle.onSessionShutdown();
|
||||
const stopped = await sendTelegramView(
|
||||
{ text: "hello" },
|
||||
{ scope: { kind: "instance" } },
|
||||
);
|
||||
assert.equal(stopped.ok, false);
|
||||
if (!stopped.ok) assert.equal(stopped.reason, "runtime-unavailable");
|
||||
|
||||
await lifecycle.onSessionStart();
|
||||
const replacement = await sendTelegramView(
|
||||
{ text: "replacement" },
|
||||
{ scope: { kind: "instance" } },
|
||||
);
|
||||
assert.equal(replacement.ok, true);
|
||||
if (replacement.ok) {
|
||||
assert.notEqual(replacement.value.generation, active.value.generation);
|
||||
}
|
||||
const staleEdit = await editTelegramView(active.value, { text: "stale" });
|
||||
const staleDelete = await deleteTelegramView(active.value);
|
||||
assert.equal(staleEdit.ok, false);
|
||||
assert.equal(staleDelete.ok, false);
|
||||
if (!staleEdit.ok) assert.equal(staleEdit.reason, "stale-handle");
|
||||
if (!staleDelete.ok) assert.equal(staleDelete.reason, "stale-handle");
|
||||
assert.deepEqual(calls, ["send", "send"]);
|
||||
});
|
||||
|
||||
test("Delivery shutdown fences an in-flight chunk sequence and queued work", async () => {
|
||||
const transportCalls: string[] = [];
|
||||
let releaseFirstChunk: (() => void) | undefined;
|
||||
let markFirstChunkStarted: (() => void) | undefined;
|
||||
const firstChunkGate = new Promise<void>((resolve) => {
|
||||
releaseFirstChunk = resolve;
|
||||
});
|
||||
const firstChunkStarted = new Promise<void>((resolve) => {
|
||||
markFirstChunkStarted = resolve;
|
||||
});
|
||||
const runtime = createConcreteDeliveryRuntime({
|
||||
generation: "generation-one",
|
||||
getActiveTurnTarget: () => target,
|
||||
getInstanceTarget: () => target,
|
||||
getAggregateTarget: () => ({ chatId: target.chatId }),
|
||||
isExplicitTargetAuthorized: () => true,
|
||||
renderView: () => [
|
||||
{ text: "first", parseMode: "html" },
|
||||
{ text: "second", parseMode: "html" },
|
||||
],
|
||||
async sendChunk() {
|
||||
transportCalls.push("send");
|
||||
if (transportCalls.length === 1) {
|
||||
markFirstChunkStarted?.();
|
||||
await firstChunkGate;
|
||||
}
|
||||
return 100 + transportCalls.length;
|
||||
},
|
||||
async editChunk() {
|
||||
transportCalls.push("edit");
|
||||
},
|
||||
async deleteMessage() {
|
||||
transportCalls.push("delete");
|
||||
},
|
||||
async sendChatAction() {
|
||||
transportCalls.push("action");
|
||||
},
|
||||
});
|
||||
|
||||
const inFlight = runtime.sendView(
|
||||
{ text: "two chunks" },
|
||||
{ scope: { kind: "instance" } },
|
||||
);
|
||||
await firstChunkStarted;
|
||||
const queued = runtime.sendChatAction("typing", { kind: "instance" });
|
||||
runtime.shutdown();
|
||||
releaseFirstChunk?.();
|
||||
|
||||
const [inFlightResult, queuedResult] = await Promise.all([inFlight, queued]);
|
||||
assert.equal(inFlightResult.ok, false);
|
||||
assert.equal(queuedResult.ok, false);
|
||||
if (!inFlightResult.ok) {
|
||||
assert.equal(inFlightResult.reason, "runtime-unavailable");
|
||||
}
|
||||
if (!queuedResult.ok) assert.equal(queuedResult.reason, "runtime-unavailable");
|
||||
assert.deepEqual(transportCalls, ["send"]);
|
||||
});
|
||||
|
||||
test("Delivery API converts unexpected runtime errors into transport failures", async () => {
|
||||
const runtime = createRuntime("one");
|
||||
runtime.sendView = async () => {
|
||||
throw new Error("secret transport detail");
|
||||
};
|
||||
bindTelegramDeliveryRuntime(runtime);
|
||||
const result = await sendTelegramView(
|
||||
{ text: "hello" },
|
||||
{ scope: { kind: "instance" } },
|
||||
);
|
||||
assert.deepEqual(result, {
|
||||
ok: false,
|
||||
reason: "transport-failed",
|
||||
message: "Telegram delivery failed.",
|
||||
});
|
||||
});
|
||||
|
||||
test("Delivery target policy runtime projects live authority and excludes Guest turns", () => {
|
||||
let ownsDirect = false;
|
||||
let followerRegistered = true;
|
||||
let guestQueryId: string | undefined;
|
||||
const runtime = createTelegramDeliveryTargetPolicyRuntime({
|
||||
ownsDirect: () => ownsDirect,
|
||||
isFollowerRegistered: () => followerRegistered,
|
||||
getAllowedChatId: () => 42,
|
||||
getFollowerTarget: () => ({ chatId: 42, threadId: 7 }),
|
||||
getLeaderTarget: () => ({ chatId: 42, threadId: 8 }),
|
||||
listThreadRecords: () => [
|
||||
{ target: { chatId: 42, threadId: 7 } },
|
||||
{ target: { chatId: 42, threadId: 8 } },
|
||||
],
|
||||
getActiveTurnTarget: () => ({ chatId: 42, threadId: 7 }),
|
||||
getActiveGuestQueryId: () => guestQueryId,
|
||||
});
|
||||
|
||||
assert.deepEqual(runtime.getTargetPolicyView(), {
|
||||
canDeliver: true,
|
||||
ownsDirect: false,
|
||||
allowedChatId: 42,
|
||||
followerTarget: { chatId: 42, threadId: 7 },
|
||||
leaderTarget: { chatId: 42, threadId: 8 },
|
||||
liveTargets: [
|
||||
{ chatId: 42, threadId: 7 },
|
||||
{ chatId: 42, threadId: 8 },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(runtime.getActiveTurnTarget(), {
|
||||
chatId: 42,
|
||||
threadId: 7,
|
||||
});
|
||||
guestQueryId = "guest";
|
||||
assert.equal(runtime.getActiveTurnTarget(), undefined);
|
||||
followerRegistered = false;
|
||||
ownsDirect = false;
|
||||
assert.equal(runtime.getTargetPolicyView().canDeliver, false);
|
||||
});
|
||||
|
||||
test("Delivery target policy resolves classic, leader, and follower instance surfaces", () => {
|
||||
assert.deepEqual(
|
||||
resolveTelegramDeliveryInstanceTarget({
|
||||
canDeliver: true,
|
||||
ownsDirect: true,
|
||||
allowedChatId: 42,
|
||||
}),
|
||||
{ chatId: 42 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveTelegramDeliveryInstanceTarget({
|
||||
canDeliver: true,
|
||||
ownsDirect: true,
|
||||
allowedChatId: 42,
|
||||
leaderTarget: { chatId: 42, threadId: 3 },
|
||||
}),
|
||||
{ chatId: 42, threadId: 3 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveTelegramDeliveryInstanceTarget({
|
||||
canDeliver: true,
|
||||
ownsDirect: false,
|
||||
allowedChatId: 42,
|
||||
followerTarget: { chatId: 42, threadId: 7 },
|
||||
}),
|
||||
{ chatId: 42, threadId: 7 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveTelegramDeliveryAggregateTarget({
|
||||
canDeliver: true,
|
||||
ownsDirect: false,
|
||||
allowedChatId: 42,
|
||||
}),
|
||||
{ chatId: 42 },
|
||||
);
|
||||
});
|
||||
|
||||
test("Delivery target policy restricts follower and leader explicit targets", () => {
|
||||
const followerView = {
|
||||
canDeliver: true,
|
||||
ownsDirect: false,
|
||||
allowedChatId: 42,
|
||||
followerTarget: { chatId: 42, threadId: 7 },
|
||||
};
|
||||
assert.equal(
|
||||
isTelegramDeliveryExplicitTargetAuthorized(
|
||||
{ chatId: 42, threadId: 7 },
|
||||
followerView,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isTelegramDeliveryExplicitTargetAuthorized(
|
||||
{ chatId: 42, threadId: 8 },
|
||||
followerView,
|
||||
),
|
||||
false,
|
||||
);
|
||||
const leaderView = {
|
||||
canDeliver: true,
|
||||
ownsDirect: true,
|
||||
allowedChatId: 42,
|
||||
leaderTarget: { chatId: 42, threadId: 3 },
|
||||
liveTargets: [{ chatId: 42, threadId: 8 }],
|
||||
};
|
||||
assert.equal(
|
||||
isTelegramDeliveryExplicitTargetAuthorized(
|
||||
{ chatId: 42, threadId: 8 },
|
||||
leaderView,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isTelegramDeliveryExplicitTargetAuthorized({ chatId: 99 }, leaderView),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
function createConcreteRuntimeHarness(
|
||||
overrides: Partial<TelegramDeliveryRuntimeDeps> = {},
|
||||
) {
|
||||
let nextMessageId = 100;
|
||||
const events: Array<Record<string, unknown>> = [];
|
||||
const deps: TelegramDeliveryRuntimeDeps = {
|
||||
generation: "generation-one",
|
||||
getActiveTurnTarget: () => target,
|
||||
getInstanceTarget: () => target,
|
||||
getAggregateTarget: () => ({ chatId: target.chatId }),
|
||||
isExplicitTargetAuthorized: (candidate) => candidate.chatId === target.chatId,
|
||||
renderView: (view) =>
|
||||
view.text.split("|").map((text) => ({
|
||||
text,
|
||||
parseMode: view.parseMode ?? "plain",
|
||||
})),
|
||||
async sendChunk(deliveryTarget, chunk, options) {
|
||||
nextMessageId += 1;
|
||||
events.push({ type: "send", deliveryTarget, chunk, options, messageId: nextMessageId });
|
||||
return nextMessageId;
|
||||
},
|
||||
async editChunk(deliveryTarget, messageId, chunk, options) {
|
||||
events.push({ type: "edit", deliveryTarget, messageId, chunk, options });
|
||||
},
|
||||
async deleteMessage(deliveryTarget, messageId) {
|
||||
events.push({ type: "delete", deliveryTarget, messageId });
|
||||
},
|
||||
async sendChatAction(deliveryTarget, action) {
|
||||
events.push({ type: "action", deliveryTarget, action });
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
return { runtime: createConcreteDeliveryRuntime(deps), events };
|
||||
}
|
||||
|
||||
test("Bridge delivery runtime owns rendering and bus-aware transport adaptation", async () => {
|
||||
const sentBodies: Array<Record<string, unknown>> = [];
|
||||
const ownership: Array<Record<string, unknown>> = [];
|
||||
const runtime = createTelegramBridgeDeliveryRuntime({
|
||||
generation: "generation-one",
|
||||
getTargetPolicyView: () => ({
|
||||
canDeliver: true,
|
||||
ownsDirect: true,
|
||||
allowedChatId: 42,
|
||||
leaderTarget: target,
|
||||
}),
|
||||
getActiveTurnTarget: () => target,
|
||||
api: {
|
||||
async sendMessage(body) {
|
||||
sentBodies.push(body);
|
||||
return { message_id: 101 };
|
||||
},
|
||||
async editMessageText() {
|
||||
return "edited";
|
||||
},
|
||||
async deleteMessage() {},
|
||||
async sendChatAction() {
|
||||
return true;
|
||||
},
|
||||
},
|
||||
recordOwnership(input) {
|
||||
ownership.push(input);
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runtime.sendView(
|
||||
{ text: "<b>Working</b>", parseMode: "html" },
|
||||
{ scope: { kind: "active-turn" }, replyToMessageId: 9 },
|
||||
);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(sentBodies, [
|
||||
{
|
||||
chat_id: 42,
|
||||
text: "<b>Working</b>",
|
||||
parse_mode: "HTML",
|
||||
message_thread_id: 7,
|
||||
reply_parameters: {
|
||||
message_id: 9,
|
||||
allow_sending_without_reply: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(ownership, [
|
||||
{ chatId: 42, messageId: 101, target },
|
||||
]);
|
||||
});
|
||||
|
||||
test("Bridge delivery runtime rejects work after transport generation replacement", async () => {
|
||||
let transportActive = true;
|
||||
let apiCalls = 0;
|
||||
const runtime = createTelegramBridgeDeliveryRuntime({
|
||||
generation: "generation-one",
|
||||
isTransportActive: () => transportActive,
|
||||
getTargetPolicyView: () => ({
|
||||
canDeliver: true,
|
||||
ownsDirect: true,
|
||||
allowedChatId: 42,
|
||||
leaderTarget: target,
|
||||
}),
|
||||
getActiveTurnTarget: () => target,
|
||||
api: {
|
||||
async sendMessage() {
|
||||
apiCalls += 1;
|
||||
return { message_id: 101 };
|
||||
},
|
||||
async editMessageText() {
|
||||
apiCalls += 1;
|
||||
return "edited";
|
||||
},
|
||||
async deleteMessage() {
|
||||
apiCalls += 1;
|
||||
},
|
||||
async sendChatAction() {
|
||||
apiCalls += 1;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
recordOwnership() {},
|
||||
});
|
||||
transportActive = false;
|
||||
|
||||
const result = await runtime.sendView(
|
||||
{ text: "stale" },
|
||||
{ scope: { kind: "instance" } },
|
||||
);
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
if (!result.ok) assert.equal(result.reason, "runtime-unavailable");
|
||||
assert.equal(apiCalls, 0);
|
||||
});
|
||||
|
||||
test("Concrete delivery runtime resolves scopes and rejects unauthorized targets", async () => {
|
||||
const { runtime, events } = createConcreteRuntimeHarness();
|
||||
const sent = await runtime.sendView(
|
||||
{ text: "hello" },
|
||||
{ scope: { kind: "instance" } },
|
||||
);
|
||||
const denied = await runtime.sendView(
|
||||
{ text: "hello" },
|
||||
{ scope: { kind: "target", target: { chatId: 99 } } },
|
||||
);
|
||||
assert.equal(sent.ok, true);
|
||||
assert.equal(denied.ok, false);
|
||||
if (!denied.ok) assert.equal(denied.reason, "target-unauthorized");
|
||||
assert.equal(events.length, 1);
|
||||
});
|
||||
|
||||
test("Concrete delivery runtime keeps active-turn, instance, and aggregate scopes distinct", async () => {
|
||||
const activeTurnTarget = { chatId: 42, threadId: 7 };
|
||||
const instanceTarget = { chatId: 42, threadId: 3 };
|
||||
const aggregateTarget = { chatId: 42 };
|
||||
const { runtime, events } = createConcreteRuntimeHarness({
|
||||
getActiveTurnTarget: () => activeTurnTarget,
|
||||
getInstanceTarget: () => instanceTarget,
|
||||
getAggregateTarget: () => aggregateTarget,
|
||||
});
|
||||
|
||||
await runtime.sendView(
|
||||
{ text: "turn" },
|
||||
{ scope: { kind: "active-turn" } },
|
||||
);
|
||||
await runtime.sendView(
|
||||
{ text: "instance" },
|
||||
{ scope: { kind: "instance" } },
|
||||
);
|
||||
await runtime.sendView(
|
||||
{ text: "all" },
|
||||
{ scope: { kind: "aggregate" } },
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
events.map((event) => event.deliveryTarget),
|
||||
[activeTurnTarget, instanceTarget, aggregateTarget],
|
||||
);
|
||||
});
|
||||
|
||||
test("Concrete delivery runtime anchors first chunk and keyboard on last chunk", async () => {
|
||||
const { runtime, events } = createConcreteRuntimeHarness();
|
||||
const replyMarkup = {
|
||||
inline_keyboard: [[{ text: "Run", callback_data: "demo:run" }]],
|
||||
};
|
||||
const result = await runtime.sendView(
|
||||
{ text: "one|two", parseMode: "html", replyMarkup },
|
||||
{ scope: { kind: "active-turn" }, replyToMessageId: 9 },
|
||||
);
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(
|
||||
events.map((event) => event.options),
|
||||
[{ replyToMessageId: 9 }, { replyMarkup }],
|
||||
);
|
||||
if (result.ok) assert.deepEqual(result.value.messageIds, [101, 102]);
|
||||
});
|
||||
|
||||
test("Concrete delivery runtime keeps handles opaque and deeply immutable", async () => {
|
||||
const { runtime } = createConcreteRuntimeHarness();
|
||||
const sent = await runtime.sendView(
|
||||
{ text: "hello" },
|
||||
{ scope: { kind: "instance" } },
|
||||
);
|
||||
assert.equal(sent.ok, true);
|
||||
if (!sent.ok) return;
|
||||
assert.equal(Object.isFrozen(sent.value), true);
|
||||
assert.equal(Object.isFrozen(sent.value.target), true);
|
||||
assert.equal(Object.isFrozen(sent.value.messageIds), true);
|
||||
assert.throws(() => {
|
||||
(sent.value.target as { threadId?: number }).threadId = 999;
|
||||
}, TypeError);
|
||||
const forged = {
|
||||
...sent.value,
|
||||
target: { ...sent.value.target },
|
||||
messageIds: [...sent.value.messageIds],
|
||||
};
|
||||
const edited = await runtime.editView(forged, { text: "forged" });
|
||||
assert.equal(edited.ok, false);
|
||||
if (!edited.ok) assert.equal(edited.reason, "stale-handle");
|
||||
});
|
||||
|
||||
test("Concrete delivery runtime exposes non-idempotent commit-unknown outcomes", async () => {
|
||||
const { runtime } = createConcreteRuntimeHarness({
|
||||
async sendChunk() {
|
||||
throw new TelegramApiCommitUnknownError(
|
||||
"sendMessage",
|
||||
new TypeError("fetch failed"),
|
||||
);
|
||||
},
|
||||
});
|
||||
const result = await runtime.sendView(
|
||||
{ text: "hello" },
|
||||
{ scope: { kind: "target", target } },
|
||||
);
|
||||
assert.deepEqual(result, {
|
||||
ok: false,
|
||||
reason: "commit-unknown",
|
||||
message: "Telegram delivery send may have committed before transport failed.",
|
||||
});
|
||||
});
|
||||
|
||||
test("Concrete delivery runtime returns a recoverable handle after partial send failure", async () => {
|
||||
let sendCount = 0;
|
||||
const deleted: number[] = [];
|
||||
const { runtime } = createConcreteRuntimeHarness({
|
||||
async sendChunk() {
|
||||
sendCount += 1;
|
||||
if (sendCount === 2) throw new Error("second chunk failed");
|
||||
return 101;
|
||||
},
|
||||
async deleteMessage(_deliveryTarget, messageId) {
|
||||
deleted.push(messageId);
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runtime.sendView(
|
||||
{ text: "one|two" },
|
||||
{ scope: { kind: "instance" } },
|
||||
);
|
||||
assert.equal(result.ok, false);
|
||||
if (result.ok) return;
|
||||
assert.equal(result.reason, "transport-failed");
|
||||
assert.deepEqual(result.partial?.messageIds, [101]);
|
||||
assert.equal(result.partial?.generation, "generation-one");
|
||||
assert.deepEqual(result.partial?.target, target);
|
||||
assert.ok(result.partial);
|
||||
const cleanup = await runtime.deleteView(result.partial);
|
||||
assert.equal(cleanup.ok, true);
|
||||
assert.deepEqual(deleted, [101]);
|
||||
});
|
||||
|
||||
test("Concrete delivery runtime returns all visible ids after edit growth failure", async () => {
|
||||
let growthSendCount = 0;
|
||||
const deleted: number[] = [];
|
||||
const { runtime } = createConcreteRuntimeHarness({
|
||||
async sendChunk() {
|
||||
growthSendCount += 1;
|
||||
if (growthSendCount === 3) throw new Error("growth chunk failed");
|
||||
return growthSendCount === 1 ? 11 : 12;
|
||||
},
|
||||
async deleteMessage(_deliveryTarget, messageId) {
|
||||
deleted.push(messageId);
|
||||
},
|
||||
});
|
||||
const initial = await runtime.sendView(
|
||||
{ text: "one" },
|
||||
{ scope: { kind: "instance" } },
|
||||
);
|
||||
assert.equal(initial.ok, true);
|
||||
if (!initial.ok) return;
|
||||
const result = await runtime.editView(initial.value, {
|
||||
text: "one|two|three",
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
if (result.ok) return;
|
||||
assert.equal(result.reason, "transport-failed");
|
||||
assert.deepEqual(result.partial?.messageIds, [11, 12]);
|
||||
assert.ok(result.partial);
|
||||
const cleanup = await runtime.deleteView(result.partial);
|
||||
assert.equal(cleanup.ok, true);
|
||||
assert.deepEqual(deleted, [11, 12]);
|
||||
});
|
||||
|
||||
test("Concrete delivery runtime removes already-deleted ids from partial edit recovery", async () => {
|
||||
const deleteCalls: number[] = [];
|
||||
let failedOnce = false;
|
||||
const { runtime } = createConcreteRuntimeHarness({
|
||||
async deleteMessage(_deliveryTarget, messageId) {
|
||||
deleteCalls.push(messageId);
|
||||
if (messageId === 103 && !failedOnce) {
|
||||
failedOnce = true;
|
||||
throw new Error("shrink delete failed");
|
||||
}
|
||||
},
|
||||
});
|
||||
const initial = await runtime.sendView(
|
||||
{ text: "one|two|three" },
|
||||
{ scope: { kind: "instance" } },
|
||||
);
|
||||
assert.equal(initial.ok, true);
|
||||
if (!initial.ok) return;
|
||||
const result = await runtime.editView(initial.value, { text: "one" });
|
||||
assert.equal(result.ok, false);
|
||||
if (result.ok) return;
|
||||
assert.deepEqual(result.partial?.messageIds, [101, 103]);
|
||||
assert.ok(result.partial);
|
||||
const cleanup = await runtime.deleteView(result.partial);
|
||||
assert.equal(cleanup.ok, true);
|
||||
assert.deepEqual(deleteCalls, [102, 103, 101, 103]);
|
||||
});
|
||||
|
||||
test("Concrete delivery runtime reconciles logical view growth and shrink", async () => {
|
||||
const { runtime, events } = createConcreteRuntimeHarness();
|
||||
const initial = await runtime.sendView(
|
||||
{ text: "one" },
|
||||
{ scope: { kind: "instance" } },
|
||||
);
|
||||
assert.equal(initial.ok, true);
|
||||
if (!initial.ok) return;
|
||||
events.length = 0;
|
||||
const grown = await runtime.editView(initial.value, { text: "one|two" });
|
||||
assert.equal(grown.ok, true);
|
||||
assert.deepEqual(
|
||||
events.map((event) => event.type),
|
||||
["edit", "send"],
|
||||
);
|
||||
assert.deepEqual(events[0]?.options, { replyMarkup: null });
|
||||
if (!grown.ok) return;
|
||||
events.length = 0;
|
||||
const shrunk = await runtime.editView(grown.value, { text: "one" });
|
||||
assert.equal(shrunk.ok, true);
|
||||
assert.deepEqual(
|
||||
events.map((event) => event.type),
|
||||
["edit", "delete"],
|
||||
);
|
||||
assert.deepEqual(events[0]?.options, { replyMarkup: null });
|
||||
});
|
||||
|
||||
test("Concrete delivery runtime serializes operations per target", async () => {
|
||||
let releaseFirst: (() => void) | undefined;
|
||||
const order: string[] = [];
|
||||
const { runtime } = createConcreteRuntimeHarness({
|
||||
async sendChunk() {
|
||||
order.push("first-start");
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
order.push("first-end");
|
||||
return 201;
|
||||
},
|
||||
async sendChatAction() {
|
||||
order.push("action");
|
||||
},
|
||||
});
|
||||
const sending = runtime.sendView(
|
||||
{ text: "one" },
|
||||
{ scope: { kind: "instance" } },
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const action = runtime.sendChatAction("typing", { kind: "instance" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(order, ["first-start"]);
|
||||
releaseFirst?.();
|
||||
await Promise.all([sending, action]);
|
||||
assert.deepEqual(order, ["first-start", "first-end", "action"]);
|
||||
});
|
||||
@@ -0,0 +1,734 @@
|
||||
/**
|
||||
* Regression tests for inbound Telegram handlers
|
||||
* Covers MIME/type matching, template substitution, fallback failures, and prompt-text routing
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildTelegramInboundHandlerInvocation,
|
||||
clearTelegramInboundHandlers,
|
||||
processTelegramInboundHandlers,
|
||||
registerTelegramInboundHandler,
|
||||
telegramInboundHandlerMatchesFile,
|
||||
} from "../lib/inbound.ts";
|
||||
import {
|
||||
clearTelegramVoiceTranscriptionProviders,
|
||||
registerTelegramVoiceTranscriptionProvider,
|
||||
} from "../lib/voice.ts";
|
||||
|
||||
test.beforeEach(() => {
|
||||
clearTelegramInboundHandlers();
|
||||
clearTelegramVoiceTranscriptionProviders();
|
||||
});
|
||||
|
||||
test("Inbound handlers match MIME wildcards and Telegram file types", () => {
|
||||
const voiceFile = {
|
||||
path: "/tmp/voice.ogg",
|
||||
fileName: "voice.ogg",
|
||||
mimeType: "audio/ogg",
|
||||
kind: "voice",
|
||||
};
|
||||
assert.equal(
|
||||
telegramInboundHandlerMatchesFile({ mime: "audio/*" }, voiceFile),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
telegramInboundHandlerMatchesFile({ type: "voice" }, voiceFile),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
telegramInboundHandlerMatchesFile({ match: "application/pdf" }, voiceFile),
|
||||
false,
|
||||
);
|
||||
assert.equal(telegramInboundHandlerMatchesFile({}, voiceFile), true);
|
||||
});
|
||||
|
||||
test("Inbound template handlers substitute paths without shell interpolation", async () => {
|
||||
const calls: Array<{ command: string; args: string[]; cwd?: string }> = [];
|
||||
const file = {
|
||||
path: "/tmp/voice one.ogg",
|
||||
fileName: "voice one.ogg",
|
||||
mimeType: "audio/ogg",
|
||||
kind: "voice",
|
||||
};
|
||||
const result = await processTelegramInboundHandlers({
|
||||
files: [file],
|
||||
rawText: "please summarize",
|
||||
handlers: [
|
||||
{
|
||||
mime: "audio/*",
|
||||
template: "/opt/transcribe --file={file} --mime {mime} --type {type}",
|
||||
},
|
||||
],
|
||||
cwd: "/work",
|
||||
execCommand: async (command, args, options) => {
|
||||
calls.push({ command, args, cwd: options?.cwd });
|
||||
return {
|
||||
stdout: "hello from voice\n",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
killed: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
assert.deepEqual(calls, [
|
||||
{
|
||||
command: "/opt/transcribe",
|
||||
args: [
|
||||
"--file=/tmp/voice one.ogg",
|
||||
"--mime",
|
||||
"audio/ogg",
|
||||
"--type",
|
||||
"voice",
|
||||
],
|
||||
cwd: "/work",
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(result.promptFiles, [file]);
|
||||
assert.equal(result.rawText, "please summarize");
|
||||
assert.deepEqual(result.handlerOutputs, ["hello from voice"]);
|
||||
});
|
||||
|
||||
test("Inbound template handlers apply declared defaults", async () => {
|
||||
const calls: Array<{ command: string; args: string[] }> = [];
|
||||
const file = {
|
||||
path: "/tmp/voice one.ogg",
|
||||
fileName: "voice one.ogg",
|
||||
mimeType: "audio/ogg",
|
||||
kind: "voice",
|
||||
};
|
||||
const result = await processTelegramInboundHandlers({
|
||||
files: [file],
|
||||
rawText: "",
|
||||
handlers: [
|
||||
{
|
||||
type: "voice",
|
||||
template: "/opt/transcribe {file} {lang} {model}",
|
||||
args: ["file", "lang", "model"],
|
||||
defaults: { lang: "ru", model: "voxtral-mini-latest" },
|
||||
},
|
||||
],
|
||||
cwd: "/work",
|
||||
execCommand: async (command, args) => {
|
||||
calls.push({ command, args });
|
||||
return { stdout: "voice transcript", stderr: "", code: 0, killed: false };
|
||||
},
|
||||
});
|
||||
assert.deepEqual(calls, [
|
||||
{
|
||||
command: "/opt/transcribe",
|
||||
args: ["/tmp/voice one.ogg", "ru", "voxtral-mini-latest"],
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(result.handlerOutputs, ["voice transcript"]);
|
||||
});
|
||||
|
||||
test("Inbound text handlers transform raw prompt text through stdin", async () => {
|
||||
const calls: Array<{ command: string; args: string[]; stdin?: string }> = [];
|
||||
const result = await processTelegramInboundHandlers({
|
||||
files: [],
|
||||
rawText: "привет",
|
||||
handlers: [
|
||||
{
|
||||
type: "text",
|
||||
template: "/tools/translate --to en --type {type} --mime {mime}",
|
||||
},
|
||||
],
|
||||
cwd: "/work",
|
||||
execCommand: async (command, args, options) => {
|
||||
calls.push({ command, args, stdin: options?.stdin });
|
||||
return { stdout: "hello", stderr: "", code: 0, killed: false };
|
||||
},
|
||||
});
|
||||
assert.deepEqual(calls, [
|
||||
{
|
||||
command: "/tools/translate",
|
||||
args: ["--to", "en", "--type", "text", "--mime", "text/plain"],
|
||||
stdin: "привет",
|
||||
},
|
||||
]);
|
||||
assert.equal(result.rawText, "hello");
|
||||
assert.deepEqual(result.promptFiles, []);
|
||||
});
|
||||
|
||||
test("Inbound text handlers can match raw text by MIME only", async () => {
|
||||
const result = await processTelegramInboundHandlers({
|
||||
files: [],
|
||||
rawText: "bonjour",
|
||||
handlers: [
|
||||
{
|
||||
mime: "text/plain",
|
||||
template: "/tools/translate",
|
||||
},
|
||||
],
|
||||
cwd: "/work",
|
||||
execCommand: async (_command, _args, options) => ({
|
||||
stdout: `translated:${options?.stdin ?? ""}`,
|
||||
stderr: "",
|
||||
code: 0,
|
||||
killed: false,
|
||||
}),
|
||||
});
|
||||
assert.equal(result.rawText, "translated:bonjour");
|
||||
});
|
||||
|
||||
test("Inbound text handlers keep original text on empty or failed output", async () => {
|
||||
const events: string[] = [];
|
||||
const result = await processTelegramInboundHandlers({
|
||||
files: [],
|
||||
rawText: "original",
|
||||
handlers: [
|
||||
{ type: "text", template: "/tools/empty" },
|
||||
{ type: "text", template: "/tools/fail" },
|
||||
],
|
||||
cwd: "/work",
|
||||
execCommand: async (command) => ({
|
||||
stdout: "",
|
||||
stderr: command,
|
||||
code: command.endsWith("fail") ? 1 : 0,
|
||||
killed: false,
|
||||
}),
|
||||
recordRuntimeEvent: (category) => {
|
||||
events.push(category);
|
||||
},
|
||||
});
|
||||
assert.equal(result.rawText, "original");
|
||||
assert.deepEqual(events, ["inbound-text-handler"]);
|
||||
});
|
||||
|
||||
test("Built-in text attachment handling injects text files into outputs", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "pi-telegram-text-attachment-"));
|
||||
const filePath = join(dir, "note.txt");
|
||||
await writeFile(filePath, "hello from file\n", "utf8");
|
||||
const result = await processTelegramInboundHandlers({
|
||||
files: [
|
||||
{
|
||||
path: filePath,
|
||||
fileName: "note.txt",
|
||||
mimeType: "text/plain",
|
||||
kind: "document",
|
||||
},
|
||||
],
|
||||
rawText: "see attached",
|
||||
handlers: [],
|
||||
cwd: "/work",
|
||||
execCommand: async () => ({
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
killed: false,
|
||||
}),
|
||||
});
|
||||
assert.deepEqual(result.handlerOutputs, ["[note.txt]\nhello from file"]);
|
||||
});
|
||||
|
||||
test("Inbound handler output is bounded before entering prompts", async () => {
|
||||
const file = {
|
||||
path: "/tmp/huge.pdf",
|
||||
fileName: "huge.pdf",
|
||||
mimeType: "application/pdf",
|
||||
kind: "document",
|
||||
};
|
||||
const result = await processTelegramInboundHandlers({
|
||||
files: [file],
|
||||
rawText: "summarize",
|
||||
handlers: [{ mime: "application/pdf", template: "/tools/ocr {file}" }],
|
||||
cwd: "/work",
|
||||
execCommand: async () => ({
|
||||
stdout: "x".repeat(13_000),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
killed: false,
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(result.handlerOutputs.length, 1);
|
||||
assert.equal(result.handlerOutputs[0]?.length, 12_024);
|
||||
assert.match(result.handlerOutputs[0] ?? "", /truncated 1000 chars/);
|
||||
});
|
||||
|
||||
test("Inbound handler failure output is bounded before runtime events", async () => {
|
||||
const events: string[] = [];
|
||||
const result = await processTelegramInboundHandlers({
|
||||
files: [
|
||||
{
|
||||
path: "/tmp/huge.pdf",
|
||||
fileName: "huge.pdf",
|
||||
mimeType: "application/pdf",
|
||||
kind: "document",
|
||||
},
|
||||
],
|
||||
rawText: "summarize",
|
||||
handlers: [{ mime: "application/pdf", template: "/tools/ocr {file}" }],
|
||||
cwd: "/work",
|
||||
execCommand: async () => ({
|
||||
stdout: "o".repeat(9_000),
|
||||
stderr: "e".repeat(8_000),
|
||||
code: 1,
|
||||
killed: false,
|
||||
}),
|
||||
recordRuntimeEvent: (category, error) => {
|
||||
events.push(
|
||||
`${category}:${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(result.handlerOutputs, []);
|
||||
assert.equal(events.length, 1);
|
||||
assert.match(events[0] ?? "", /stderr:\ne{4000}… \[truncated 4000 chars\]/);
|
||||
assert.match(events[0] ?? "", /stdout:\no{4000}… \[truncated 5000 chars\]/);
|
||||
assert.equal((events[0]?.match(/e/g) ?? []).length < 5000, true);
|
||||
assert.equal((events[0]?.match(/o/g) ?? []).length < 5000, true);
|
||||
});
|
||||
|
||||
test("Built-in text attachment handling accepts text wildcards", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "pi-telegram-text-attachment-"));
|
||||
const filePath = join(dir, "note.md");
|
||||
await writeFile(filePath, "# Hello\n", "utf8");
|
||||
const result = await processTelegramInboundHandlers({
|
||||
files: [
|
||||
{
|
||||
path: filePath,
|
||||
fileName: "note.md",
|
||||
mimeType: "text/markdown",
|
||||
kind: "document",
|
||||
},
|
||||
],
|
||||
rawText: "see attached",
|
||||
handlers: [],
|
||||
cwd: "/work",
|
||||
execCommand: async () => ({
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
killed: false,
|
||||
}),
|
||||
});
|
||||
assert.deepEqual(result.handlerOutputs, ["[note.md]\n# Hello"]);
|
||||
});
|
||||
|
||||
test("Inbound template invocation keeps args as name declarations only", () => {
|
||||
const invocation = buildTelegramInboundHandlerInvocation(
|
||||
{
|
||||
template: "./scripts/transcribe {file} {lang=ru}",
|
||||
args: ["file", "lang"],
|
||||
},
|
||||
{ path: "/tmp/a.ogg" },
|
||||
"/work",
|
||||
);
|
||||
assert.deepEqual(invocation, {
|
||||
command: "/work/scripts/transcribe",
|
||||
args: ["/tmp/a.ogg", "ru"],
|
||||
});
|
||||
});
|
||||
|
||||
test("Inbound template invocation supports inline placeholder defaults", () => {
|
||||
const invocation = buildTelegramInboundHandlerInvocation(
|
||||
{
|
||||
template:
|
||||
"./scripts/transcribe {file} {lang=ru} {model=voxtral-mini-latest}",
|
||||
},
|
||||
{ path: "/tmp/a.ogg" },
|
||||
"/work",
|
||||
);
|
||||
assert.deepEqual(invocation, {
|
||||
command: "/work/scripts/transcribe",
|
||||
args: ["/tmp/a.ogg", "ru", "voxtral-mini-latest"],
|
||||
});
|
||||
});
|
||||
|
||||
test("Inbound template handlers resolve relative commands", () => {
|
||||
const invocation = buildTelegramInboundHandlerInvocation(
|
||||
{ template: "./scripts/transcribe {file} ru" },
|
||||
{ path: "/tmp/a.ogg" },
|
||||
"/work",
|
||||
);
|
||||
assert.deepEqual(invocation, {
|
||||
command: "/work/scripts/transcribe",
|
||||
args: ["/tmp/a.ogg", "ru"],
|
||||
});
|
||||
});
|
||||
|
||||
test("Inbound template handlers append the path when no placeholder is present", () => {
|
||||
const invocation = buildTelegramInboundHandlerInvocation(
|
||||
{ template: "./scripts/transcribe --lang ru" },
|
||||
{ path: "/tmp/a.ogg" },
|
||||
"/work",
|
||||
);
|
||||
assert.deepEqual(invocation, {
|
||||
command: "/work/scripts/transcribe",
|
||||
args: ["--lang", "ru", "/tmp/a.ogg"],
|
||||
});
|
||||
});
|
||||
|
||||
test("Inbound template composition handlers execute steps in order", async () => {
|
||||
const calls: Array<{ command: string; args: string[] }> = [];
|
||||
const file = {
|
||||
path: "/tmp/voice one.ogg",
|
||||
fileName: "voice one.ogg",
|
||||
mimeType: "audio/ogg",
|
||||
kind: "voice",
|
||||
};
|
||||
const result = await processTelegramInboundHandlers({
|
||||
files: [file],
|
||||
rawText: "",
|
||||
handlers: [
|
||||
{
|
||||
type: "voice",
|
||||
template: [
|
||||
"/tools/extract {file} --out /tmp/raw.wav",
|
||||
"/tools/transcribe /tmp/raw.wav {lang}",
|
||||
],
|
||||
defaults: { lang: "ru" },
|
||||
},
|
||||
],
|
||||
cwd: "/work",
|
||||
execCommand: async (command, args) => {
|
||||
calls.push({ command, args });
|
||||
return {
|
||||
stdout: command.endsWith("transcribe") ? "pipe transcript" : "",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
killed: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
assert.deepEqual(calls, [
|
||||
{
|
||||
command: "/tools/extract",
|
||||
args: ["/tmp/voice one.ogg", "--out", "/tmp/raw.wav"],
|
||||
},
|
||||
{ command: "/tools/transcribe", args: ["/tmp/raw.wav", "ru"] },
|
||||
]);
|
||||
assert.deepEqual(result.handlerOutputs, ["pipe transcript"]);
|
||||
});
|
||||
|
||||
test("Inbound template composition wraps timeout and pipes stdout to stdin", async () => {
|
||||
const calls: Array<{ command: string; stdin?: string; timeout?: number }> =
|
||||
[];
|
||||
const result = await processTelegramInboundHandlers({
|
||||
files: [{ path: "/tmp/voice.ogg", mimeType: "audio/ogg", kind: "voice" }],
|
||||
rawText: "",
|
||||
handlers: [
|
||||
{
|
||||
type: "voice",
|
||||
template: [
|
||||
"/tools/extract {file}",
|
||||
{ template: "/tools/transcribe", timeout: 222 },
|
||||
],
|
||||
timeout: 111000,
|
||||
},
|
||||
],
|
||||
cwd: "/work",
|
||||
execCommand: async (command, _args, options) => {
|
||||
calls.push({
|
||||
command,
|
||||
stdin: options?.stdin,
|
||||
timeout: options?.timeout,
|
||||
});
|
||||
return {
|
||||
stdout:
|
||||
command === "/tools/extract"
|
||||
? "raw transcript\n"
|
||||
: `seen:${options?.stdin ?? ""}`,
|
||||
stderr: "",
|
||||
code: 0,
|
||||
killed: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
assert.deepEqual(calls, [
|
||||
{ command: "/tools/extract", stdin: undefined, timeout: 111000 },
|
||||
{ command: "/tools/transcribe", stdin: "raw transcript\n", timeout: 222 },
|
||||
]);
|
||||
assert.deepEqual(result.handlerOutputs, ["seen:raw transcript"]);
|
||||
});
|
||||
|
||||
test("Inbound handlers fall back to the next matching handler on failure", async () => {
|
||||
const calls: string[] = [];
|
||||
const events: Array<{ category: string; details?: Record<string, unknown> }> =
|
||||
[];
|
||||
const file = {
|
||||
path: "/tmp/voice.ogg",
|
||||
fileName: "voice.ogg",
|
||||
mimeType: "audio/ogg",
|
||||
kind: "voice",
|
||||
};
|
||||
const result = await processTelegramInboundHandlers({
|
||||
files: [file],
|
||||
rawText: "",
|
||||
handlers: [
|
||||
{ type: "voice", template: "/tools/primary {file} ru" },
|
||||
{ mime: "audio/*", template: "/tools/fallback {file} ru" },
|
||||
],
|
||||
cwd: "/work",
|
||||
execCommand: async (command) => {
|
||||
calls.push(command);
|
||||
if (command === "/tools/primary") {
|
||||
return { stdout: "", stderr: "primary down", code: 1, killed: false };
|
||||
}
|
||||
return {
|
||||
stdout: "fallback transcript",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
killed: false,
|
||||
};
|
||||
},
|
||||
recordRuntimeEvent: (category, _error, details) => {
|
||||
events.push({ category, details });
|
||||
},
|
||||
});
|
||||
assert.deepEqual(calls, ["/tools/primary", "/tools/fallback"]);
|
||||
assert.deepEqual(result.handlerOutputs, ["fallback transcript"]);
|
||||
assert.deepEqual(events, [
|
||||
{
|
||||
category: "inbound-handler",
|
||||
details: { fileName: "voice.ogg", handler: "template" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("Inbound handler failures fall back to normal inbound prompts", async () => {
|
||||
const events: Array<{ category: string; details?: Record<string, unknown> }> =
|
||||
[];
|
||||
const file = {
|
||||
path: "/tmp/report.pdf",
|
||||
fileName: "report.pdf",
|
||||
mimeType: "application/pdf",
|
||||
kind: "document",
|
||||
};
|
||||
const result = await processTelegramInboundHandlers({
|
||||
files: [file],
|
||||
rawText: "read this",
|
||||
handlers: [
|
||||
{ mime: "application/pdf", template: "/opt/pdf-to-text {file}" },
|
||||
],
|
||||
cwd: "/work",
|
||||
execCommand: async () => ({
|
||||
stdout: "partial",
|
||||
stderr: "boom",
|
||||
code: 1,
|
||||
killed: false,
|
||||
}),
|
||||
recordRuntimeEvent: (category, _error, details) => {
|
||||
events.push({ category, details });
|
||||
},
|
||||
});
|
||||
assert.equal(result.rawText, "read this");
|
||||
assert.deepEqual(result.handlerOutputs, []);
|
||||
assert.deepEqual(result.promptFiles, [file]);
|
||||
assert.deepEqual(events, [
|
||||
{
|
||||
category: "inbound-handler",
|
||||
details: { fileName: "report.pdf", handler: "template" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
// --- Critical-step composition tests ---
|
||||
|
||||
test("Inbound handler composition: non-critical failure continues to next step", async () => {
|
||||
const calls: string[] = [];
|
||||
const result = await processTelegramInboundHandlers({
|
||||
files: [{ path: "/tmp/in.ogg", mimeType: "audio/ogg", kind: "voice" }],
|
||||
rawText: "",
|
||||
handlers: [
|
||||
{
|
||||
type: "voice",
|
||||
template: ["scan --file {file}", "transcribe --file {file}"],
|
||||
},
|
||||
],
|
||||
cwd: "/work",
|
||||
execCommand: async (command) => {
|
||||
calls.push(command);
|
||||
if (command === "scan")
|
||||
return { stdout: "", stderr: "skip", code: 1, killed: false };
|
||||
return { stdout: "transcribed\n", stderr: "", code: 0, killed: false };
|
||||
},
|
||||
});
|
||||
assert.deepEqual(calls, ["scan", "transcribe"]);
|
||||
assert.deepEqual(result.handlerOutputs, ["transcribed"]);
|
||||
});
|
||||
|
||||
test("Programmatic inbound text handlers transform text after configured text handlers", async () => {
|
||||
const dispose = registerTelegramInboundHandler(
|
||||
"text",
|
||||
async ({ text }) => `${text} world`,
|
||||
);
|
||||
try {
|
||||
const result = await processTelegramInboundHandlers({
|
||||
files: [],
|
||||
rawText: "hello",
|
||||
handlers: [
|
||||
{
|
||||
type: "text",
|
||||
template: "uppercase",
|
||||
},
|
||||
],
|
||||
cwd: "/work",
|
||||
execCommand: async (_command, _args, options) => ({
|
||||
stdout: String(options?.stdin ?? "").toUpperCase(),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
killed: false,
|
||||
}),
|
||||
});
|
||||
assert.equal(result.rawText, "HELLO world");
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("Programmatic inbound media handlers run before voice transcription providers", async () => {
|
||||
const disposeInbound = registerTelegramInboundHandler(
|
||||
"voice",
|
||||
async ({ file }) => ({
|
||||
text: `programmatic transcript for ${file?.fileName}`,
|
||||
}),
|
||||
);
|
||||
const disposeProvider = registerTelegramVoiceTranscriptionProvider(
|
||||
async () => "provider transcript",
|
||||
);
|
||||
try {
|
||||
const result = await processTelegramInboundHandlers({
|
||||
files: [
|
||||
{
|
||||
path: "/tmp/programmatic.ogg",
|
||||
fileName: "programmatic.ogg",
|
||||
mimeType: "audio/ogg",
|
||||
kind: "voice",
|
||||
},
|
||||
],
|
||||
rawText: "",
|
||||
handlers: [],
|
||||
cwd: "/work",
|
||||
execCommand: async () => ({
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
killed: false,
|
||||
}),
|
||||
});
|
||||
assert.deepEqual(result.handlerOutputs, [
|
||||
"programmatic transcript for programmatic.ogg",
|
||||
]);
|
||||
assert.equal(result.handledFiles[0]?.handler.type, "programmatic");
|
||||
} finally {
|
||||
disposeInbound();
|
||||
disposeProvider();
|
||||
}
|
||||
});
|
||||
|
||||
test("Inbound voice transcription providers handle voice files when no handler matches", async () => {
|
||||
const dispose = registerTelegramVoiceTranscriptionProvider(async (file) => ({
|
||||
text: `provider transcript for ${file.fileName}`,
|
||||
}));
|
||||
try {
|
||||
const result = await processTelegramInboundHandlers({
|
||||
files: [
|
||||
{
|
||||
path: "/tmp/provider.ogg",
|
||||
fileName: "provider.ogg",
|
||||
mimeType: "audio/ogg",
|
||||
kind: "voice",
|
||||
},
|
||||
],
|
||||
rawText: "",
|
||||
handlers: [],
|
||||
cwd: "/work",
|
||||
execCommand: async () => ({
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
killed: false,
|
||||
}),
|
||||
});
|
||||
assert.deepEqual(result.handlerOutputs, [
|
||||
"provider transcript for provider.ogg",
|
||||
]);
|
||||
assert.equal(result.handledFiles[0]?.handler.type, "voice-provider");
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("Inbound handlers take precedence over voice transcription providers", async () => {
|
||||
const dispose = registerTelegramVoiceTranscriptionProvider(
|
||||
async () => "provider transcript",
|
||||
);
|
||||
try {
|
||||
const result = await processTelegramInboundHandlers({
|
||||
files: [{ path: "/tmp/in.ogg", mimeType: "audio/ogg", kind: "voice" }],
|
||||
rawText: "",
|
||||
handlers: [{ type: "voice", template: "transcribe --file {file}" }],
|
||||
cwd: "/work",
|
||||
execCommand: async () => ({
|
||||
stdout: "handler transcript\n",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
killed: false,
|
||||
}),
|
||||
});
|
||||
assert.deepEqual(result.handlerOutputs, ["handler transcript"]);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("Inbound handler composition: critical failure aborts composition", async () => {
|
||||
const calls: string[] = [];
|
||||
const result = await processTelegramInboundHandlers({
|
||||
files: [{ path: "/tmp/in.ogg", mimeType: "audio/ogg", kind: "voice" }],
|
||||
rawText: "",
|
||||
handlers: [
|
||||
{
|
||||
type: "voice",
|
||||
template: [
|
||||
{ template: "scan --file {file}", failure: "root" },
|
||||
"transcribe --file {file}",
|
||||
"summarize --file {file}",
|
||||
],
|
||||
},
|
||||
],
|
||||
cwd: "/work",
|
||||
execCommand: async (command) => {
|
||||
calls.push(command);
|
||||
if (command === "scan")
|
||||
return { stdout: "", stderr: "fatal", code: 1, killed: false };
|
||||
return { stdout: "ok\n", stderr: "", code: 0, killed: false };
|
||||
},
|
||||
});
|
||||
assert.deepEqual(calls, ["scan"]);
|
||||
assert.deepEqual(result.handlerOutputs, []);
|
||||
});
|
||||
|
||||
test("Inbound handler composition: non-critical failure continues, critical stops", async () => {
|
||||
const calls: string[] = [];
|
||||
const result = await processTelegramInboundHandlers({
|
||||
files: [{ path: "/tmp/in.ogg", mimeType: "audio/ogg", kind: "voice" }],
|
||||
rawText: "",
|
||||
handlers: [
|
||||
{
|
||||
type: "voice",
|
||||
template: ["step-a", { template: "step-b", failure: "root" }, "step-c"],
|
||||
},
|
||||
],
|
||||
cwd: "/work",
|
||||
execCommand: async (command) => {
|
||||
calls.push(command);
|
||||
if (command === "step-a")
|
||||
return { stdout: "a\n", stderr: "", code: 0, killed: false };
|
||||
if (command === "step-b")
|
||||
return { stdout: "", stderr: "boom", code: 1, killed: false };
|
||||
return { stdout: "c\n", stderr: "", code: 0, killed: false };
|
||||
},
|
||||
});
|
||||
assert.deepEqual(calls, ["step-a", "step-b"]);
|
||||
assert.deepEqual(result.handlerOutputs, []);
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Regression tests for the Telegram extension entrypoint wiring
|
||||
* Covers composition-root binding of tools, commands, lifecycle hooks, and prompt injection
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import telegramExtension from "../index.ts";
|
||||
import * as telegramEntrypoint from "../index.ts";
|
||||
import type { ExtensionAPI, ExtensionContext } from "../lib/pi.ts";
|
||||
|
||||
type RegisteredIndexTool = {
|
||||
name?: string;
|
||||
};
|
||||
|
||||
type RegisteredIndexCommand = {
|
||||
handler: (...args: never[]) => unknown;
|
||||
};
|
||||
|
||||
type RegisteredIndexHandler = (
|
||||
event: unknown,
|
||||
ctx: ExtensionContext,
|
||||
) => Promise<unknown> | unknown;
|
||||
|
||||
function createIndexApiHarness() {
|
||||
const tools = new Map<string, RegisteredIndexTool>();
|
||||
const commands = new Map<string, RegisteredIndexCommand>();
|
||||
const handlers = new Map<string, RegisteredIndexHandler>();
|
||||
const api = {
|
||||
on: (event: string, handler: RegisteredIndexHandler) => {
|
||||
handlers.set(event, handler);
|
||||
},
|
||||
registerTool: (definition: RegisteredIndexTool) => {
|
||||
if (definition.name) tools.set(definition.name, definition);
|
||||
},
|
||||
registerCommand: (name: string, definition: RegisteredIndexCommand) => {
|
||||
commands.set(name, definition);
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
return { tools, commands, handlers, api };
|
||||
}
|
||||
|
||||
function getRequiredIndexHandler(
|
||||
handlers: Map<string, RegisteredIndexHandler>,
|
||||
name: string,
|
||||
): RegisteredIndexHandler {
|
||||
const handler = handlers.get(name);
|
||||
assert.ok(handler, `Expected entrypoint handler ${name}`);
|
||||
return handler;
|
||||
}
|
||||
|
||||
function createIndexExtensionContext(): ExtensionContext {
|
||||
return {} as ExtensionContext;
|
||||
}
|
||||
|
||||
function assertSystemPromptResult(
|
||||
value: unknown,
|
||||
): asserts value is { systemPrompt: string } {
|
||||
assert.ok(typeof value === "object" && value !== null);
|
||||
assert.equal(typeof Reflect.get(value, "systemPrompt"), "string");
|
||||
}
|
||||
|
||||
test("Extension entrypoint exposes only the default composition root", () => {
|
||||
assert.deepEqual(Object.keys(telegramEntrypoint), ["default"]);
|
||||
});
|
||||
|
||||
test("Extension entrypoint wires domain bindings into the pi API", () => {
|
||||
const harness = createIndexApiHarness();
|
||||
telegramExtension(harness.api);
|
||||
assert.deepEqual(
|
||||
[...harness.tools.keys()],
|
||||
["telegram_attach", "telegram_message", "telegram_help"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...harness.commands.keys()],
|
||||
[
|
||||
"telegram-setup",
|
||||
"telegram-status",
|
||||
"telegram-connect",
|
||||
"telegram-disconnect",
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...harness.handlers.keys()],
|
||||
[
|
||||
"input",
|
||||
"session_start",
|
||||
"session_shutdown",
|
||||
"session_before_compact",
|
||||
"session_compact",
|
||||
"before_agent_start",
|
||||
"model_select",
|
||||
"agent_start",
|
||||
"tool_execution_start",
|
||||
"tool_execution_update",
|
||||
"tool_execution_end",
|
||||
"message_start",
|
||||
"message_update",
|
||||
"agent_end",
|
||||
"agent_settled",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("Extension before-agent-start hook skips Telegram guidance when unconfigured", async () => {
|
||||
const harness = createIndexApiHarness();
|
||||
telegramExtension(harness.api);
|
||||
const handler = getRequiredIndexHandler(
|
||||
harness.handlers,
|
||||
"before_agent_start",
|
||||
);
|
||||
const basePrompt = "System base";
|
||||
const telegramResult = await handler(
|
||||
{ systemPrompt: basePrompt, prompt: "[telegram] hello" },
|
||||
createIndexExtensionContext(),
|
||||
);
|
||||
const localResult = await handler(
|
||||
{ systemPrompt: basePrompt, prompt: "hello" },
|
||||
createIndexExtensionContext(),
|
||||
);
|
||||
assertSystemPromptResult(telegramResult);
|
||||
assertSystemPromptResult(localResult);
|
||||
assert.equal(telegramResult.systemPrompt, basePrompt);
|
||||
assert.equal(localResult.systemPrompt, basePrompt);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* Regression tests for project architecture invariants
|
||||
* Guards import graph shape, shared-bucket bans, and polling SDK boundary rules
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { join, normalize, relative } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const PROJECT_ROOT = process.cwd();
|
||||
|
||||
function getProjectTypeScriptFiles(): string[] {
|
||||
return [
|
||||
"index.ts",
|
||||
...readdirSync(join(PROJECT_ROOT, "api"))
|
||||
.filter((name) => name.endsWith(".ts"))
|
||||
.map((name) => join("api", name)),
|
||||
...readdirSync(join(PROJECT_ROOT, "lib"))
|
||||
.filter((name) => name.endsWith(".ts"))
|
||||
.map((name) => join("lib", name)),
|
||||
...readdirSync(join(PROJECT_ROOT, "tests"))
|
||||
.filter((name) => name.endsWith(".test.ts"))
|
||||
.map((name) => join("tests", name)),
|
||||
].sort();
|
||||
}
|
||||
|
||||
function getProjectSourceFiles(): string[] {
|
||||
return [
|
||||
"index.ts",
|
||||
...readdirSync(join(PROJECT_ROOT, "api"))
|
||||
.filter((name) => name.endsWith(".ts"))
|
||||
.map((name) => join("api", name)),
|
||||
...readdirSync(join(PROJECT_ROOT, "lib"))
|
||||
.filter((name) => name.endsWith(".ts"))
|
||||
.map((name) => join("lib", name)),
|
||||
].sort();
|
||||
}
|
||||
|
||||
function getImportSpecifiersFromSource(source: string): string[] {
|
||||
const specifiers = new Set<string>();
|
||||
for (const match of source.matchAll(/from\s+["']([^"']+)["']/g)) {
|
||||
specifiers.add(match[1] ?? "");
|
||||
}
|
||||
for (const match of source.matchAll(/import\s+["']([^"']+)["']/g)) {
|
||||
specifiers.add(match[1] ?? "");
|
||||
}
|
||||
for (const match of source.matchAll(/import\s*\(\s*["']([^"']+)["']/g)) {
|
||||
specifiers.add(match[1] ?? "");
|
||||
}
|
||||
return [...specifiers];
|
||||
}
|
||||
|
||||
function getImportSpecifiers(file: string): string[] {
|
||||
return getImportSpecifiersFromSource(
|
||||
readFileSync(join(PROJECT_ROOT, file), "utf8"),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveProjectImport(
|
||||
fromFile: string,
|
||||
specifier: string,
|
||||
): string | undefined {
|
||||
if (!specifier.startsWith(".")) return undefined;
|
||||
const resolved = normalize(join(PROJECT_ROOT, fromFile, "..", specifier));
|
||||
const relativePath = relative(PROJECT_ROOT, resolved);
|
||||
return relativePath.startsWith("..") ? undefined : relativePath;
|
||||
}
|
||||
|
||||
function buildProjectImportGraph(files: string[]): Map<string, string[]> {
|
||||
const fileSet = new Set(files.map((file) => normalize(file)));
|
||||
const graph = new Map<string, string[]>();
|
||||
for (const file of files) {
|
||||
const deps: string[] = [];
|
||||
for (const specifier of getImportSpecifiers(file)) {
|
||||
const resolved = resolveProjectImport(file, specifier);
|
||||
if (resolved && fileSet.has(normalize(resolved))) {
|
||||
deps.push(normalize(resolved));
|
||||
}
|
||||
}
|
||||
graph.set(normalize(file), deps.sort());
|
||||
}
|
||||
return graph;
|
||||
}
|
||||
|
||||
function findImportCycles(graph: Map<string, string[]>): string[][] {
|
||||
const cycles: string[][] = [];
|
||||
const visited = new Set<string>();
|
||||
const activeStack: string[] = [];
|
||||
const visit = (file: string): void => {
|
||||
const activeIndex = activeStack.indexOf(file);
|
||||
if (activeIndex !== -1) {
|
||||
cycles.push([...activeStack.slice(activeIndex), file]);
|
||||
return;
|
||||
}
|
||||
if (visited.has(file)) return;
|
||||
visited.add(file);
|
||||
activeStack.push(file);
|
||||
for (const dep of graph.get(file) ?? []) visit(dep);
|
||||
activeStack.pop();
|
||||
};
|
||||
for (const file of graph.keys()) visit(file);
|
||||
return cycles;
|
||||
}
|
||||
|
||||
function stripSourceTextAndComments(source: string): string {
|
||||
const withoutStrings = source.replace(/(["'`])(?:\\.|(?!\1)[^\\])*\1/g, "");
|
||||
return withoutStrings
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/\/\/[^\n]*/g, "");
|
||||
}
|
||||
|
||||
test("Import parser includes side-effect imports for cycle checks", () => {
|
||||
assert.deepEqual(
|
||||
getImportSpecifiersFromSource(
|
||||
[
|
||||
"import type { A } from './a.ts';",
|
||||
"import { b } from './b.ts';",
|
||||
"import './side-effect.ts';",
|
||||
"export { c } from './c.ts';",
|
||||
"const module = await import('./dynamic.ts');",
|
||||
].join("\n"),
|
||||
).sort(),
|
||||
["./a.ts", "./b.ts", "./c.ts", "./dynamic.ts", "./side-effect.ts"],
|
||||
);
|
||||
});
|
||||
|
||||
test("Source-only invariant scans ignore strings and comments", () => {
|
||||
assert.equal(
|
||||
stripSourceTextAndComments(
|
||||
[
|
||||
"const text = '=> process.env pi.';",
|
||||
"// interface Example extends Other {}",
|
||||
"/* function helper() { return process.env; } */",
|
||||
"const value = 1;",
|
||||
].join("\n"),
|
||||
).trim(),
|
||||
"const text = ;\n\n\nconst value = 1;",
|
||||
);
|
||||
});
|
||||
|
||||
test("Domain test filenames mirror their owning lib domain", () => {
|
||||
const libDomains = new Set(
|
||||
readdirSync(join(PROJECT_ROOT, "lib"))
|
||||
.filter((name) => name.endsWith(".ts"))
|
||||
.map((name) => name.replace(/\.ts$/, "")),
|
||||
);
|
||||
const nonLibTestDomains = new Set([
|
||||
"index",
|
||||
"integration",
|
||||
"invariants",
|
||||
"process-shutdown",
|
||||
"public-api",
|
||||
]);
|
||||
const unmirrored = readdirSync(join(PROJECT_ROOT, "tests"))
|
||||
.filter((name) => name.endsWith(".test.ts"))
|
||||
.map((name) => name.replace(/\.test\.ts$/, ""))
|
||||
.filter(
|
||||
(domain) => !libDomains.has(domain) && !nonLibTestDomains.has(domain),
|
||||
);
|
||||
|
||||
assert.deepEqual(unmirrored, []);
|
||||
});
|
||||
|
||||
test("Project source imports stay acyclic", () => {
|
||||
const graph = buildProjectImportGraph(getProjectSourceFiles());
|
||||
const cycles = findImportCycles(graph);
|
||||
|
||||
assert.deepEqual(
|
||||
cycles,
|
||||
[],
|
||||
"Import cycles found:\n" + cycles.map((c) => c.join(" -> ")).join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
test("Project no longer has shared constants or transport-type domains", () => {
|
||||
assert.equal(existsSync(join(PROJECT_ROOT, "lib", "constants.ts")), false);
|
||||
assert.equal(existsSync(join(PROJECT_ROOT, "lib", "types.ts")), false);
|
||||
});
|
||||
|
||||
test("Preview domain stays independent from UI/compat rendering", () => {
|
||||
assert.equal(
|
||||
getImportSpecifiers(join("lib", "preview.ts")).includes("./rendering.ts"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("Package exports expose only stable public domains", () => {
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(join(PROJECT_ROOT, "package.json"), "utf8"),
|
||||
) as { exports?: Record<string, string> };
|
||||
|
||||
assert.deepEqual(packageJson.exports, {
|
||||
".": "./index.ts",
|
||||
"./inbound": "./api/inbound.ts",
|
||||
"./outbound": "./api/outbound.ts",
|
||||
"./delivery": "./api/delivery.ts",
|
||||
"./activity": "./api/activity.ts",
|
||||
"./updates": "./api/updates.ts",
|
||||
"./commands": "./api/commands.ts",
|
||||
"./sections": "./api/sections.ts",
|
||||
"./status": "./api/status.ts",
|
||||
"./voice": "./api/voice.ts",
|
||||
"./keyboard": "./api/keyboard.ts",
|
||||
});
|
||||
});
|
||||
|
||||
test("Project TypeScript files start with responsibility headers", () => {
|
||||
const filesWithoutHeaders = getProjectTypeScriptFiles().filter((file) => {
|
||||
return !readFileSync(join(PROJECT_ROOT, file), "utf8").startsWith("/**");
|
||||
});
|
||||
assert.deepEqual(filesWithoutHeaders, []);
|
||||
});
|
||||
|
||||
test("Project source module headers include Domain DAG zone tags", () => {
|
||||
const sourceFilesWithoutZoneTags = getProjectSourceFiles().filter((file) => {
|
||||
const source = readFileSync(join(PROJECT_ROOT, file), "utf8");
|
||||
const header = source.match(/^\/\*\*[\s\S]*?\*\//)?.[0] ?? "";
|
||||
return !/^ \* Zones: .+/m.test(header);
|
||||
});
|
||||
assert.deepEqual(sourceFilesWithoutZoneTags, []);
|
||||
});
|
||||
|
||||
test("Project source avoids empty interface-extension shells", () => {
|
||||
const emptyInterfacePattern =
|
||||
/export\s+interface\s+\w+(?:<[^>{}]+>)?\s+extends[^{]+\{\s*\}/g;
|
||||
const emptyInterfaceExtensions = getProjectSourceFiles().flatMap((file) => {
|
||||
const source = stripSourceTextAndComments(
|
||||
readFileSync(join(PROJECT_ROOT, file), "utf8"),
|
||||
);
|
||||
return [...source.matchAll(emptyInterfacePattern)].map(
|
||||
(match) => `${file}: ${match[0].replace(/\s+/g, " ")}`,
|
||||
);
|
||||
});
|
||||
assert.deepEqual(emptyInterfaceExtensions, []);
|
||||
});
|
||||
|
||||
test("Pi SDK imports stay centralized in the pi adapter", () => {
|
||||
const directSdkImportFiles = getProjectSourceFiles().filter((file) => {
|
||||
if (file === normalize(join("lib", "pi.ts"))) return false;
|
||||
const source = readFileSync(join(PROJECT_ROOT, file), "utf8");
|
||||
const piSdkPackages = [
|
||||
"@mariozechner/pi-coding-agent",
|
||||
"@earendil-works/pi-coding-agent",
|
||||
"@earendil-works/pi-agent-core",
|
||||
"@earendil-works/pi-ai",
|
||||
];
|
||||
return piSdkPackages.some((packageName) => source.includes(packageName));
|
||||
});
|
||||
assert.deepEqual(directSdkImportFiles, []);
|
||||
});
|
||||
|
||||
test("Entrypoint stays free of direct Node runtime imports", () => {
|
||||
const nodeImportSpecifiers = getImportSpecifiers("index.ts").filter(
|
||||
(specifier) => specifier.startsWith("node:"),
|
||||
);
|
||||
assert.deepEqual(nodeImportSpecifiers, []);
|
||||
});
|
||||
|
||||
test("Entrypoint stays a composition root without local runtime adapters", () => {
|
||||
const source = stripSourceTextAndComments(
|
||||
readFileSync(join(PROJECT_ROOT, "index.ts"), "utf8"),
|
||||
);
|
||||
const localFunctionDeclarations = [
|
||||
...source.matchAll(/(?:^|\n)\s*(?:async\s+)?function\s+\w+/g),
|
||||
].map((match) => match[0].trim());
|
||||
assert.deepEqual(localFunctionDeclarations, []);
|
||||
assert.equal(source.includes("=>"), false);
|
||||
assert.equal(source.includes("process.env"), false);
|
||||
assert.equal(/\bpi\./.test(source), false);
|
||||
});
|
||||
|
||||
test("Visible thread identity never falls back directly to bare slot labels", () => {
|
||||
const forbiddenPatterns: Array<[RegExp, string]> = [
|
||||
[/\bthreadName\s*\?\?\s*slot\b/g, "threadName ?? slot"],
|
||||
[
|
||||
/\brecord\.threadName\s*\?\?\s*record\.slot\b/g,
|
||||
"record.threadName ?? record.slot",
|
||||
],
|
||||
[/\?\s*[\w.]+\.threadName\s*:\s*[\w.]+\.slot\b/g, "ternary slot fallback"],
|
||||
];
|
||||
const violations = getProjectSourceFiles().flatMap((file) => {
|
||||
const source = stripSourceTextAndComments(
|
||||
readFileSync(join(PROJECT_ROOT, file), "utf8"),
|
||||
);
|
||||
return forbiddenPatterns.flatMap(([pattern, label]) =>
|
||||
[...source.matchAll(pattern)].map(
|
||||
(match) => `${file}: ${label}: ${match[0].replace(/\s+/g, " ")}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
assert.deepEqual(violations, []);
|
||||
});
|
||||
|
||||
test("Destructive forum-topic lifecycle cleanup stays in the thread reconciler", () => {
|
||||
const directCleanupFiles = getProjectSourceFiles().filter((file) => {
|
||||
if (file === join("lib", "thread-reconciler.ts")) return false;
|
||||
const source = stripSourceTextAndComments(
|
||||
readFileSync(join(PROJECT_ROOT, file), "utf8"),
|
||||
);
|
||||
return /\b(?:closeForumTopic|deleteForumTopic)\b/.test(source);
|
||||
});
|
||||
assert.deepEqual(directCleanupFiles, []);
|
||||
});
|
||||
|
||||
test("Runtime state domain stays free of local domain imports", () => {
|
||||
const localImportSpecifiers = getImportSpecifiers(
|
||||
join("lib", "runtime.ts"),
|
||||
).filter((specifier) => specifier.startsWith("."));
|
||||
assert.deepEqual(localImportSpecifiers, []);
|
||||
});
|
||||
|
||||
test("Structural leaf domains stay free of local nominal imports", () => {
|
||||
const leafFiles = ["polling.ts", "setup.ts", "status.ts"];
|
||||
const localImportsByFile = Object.fromEntries(
|
||||
leafFiles.map((file) => [
|
||||
join("lib", file),
|
||||
getImportSpecifiers(join("lib", file)).filter((specifier) =>
|
||||
specifier.startsWith("."),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
assert.deepEqual(localImportsByFile, {
|
||||
[join("lib", "polling.ts")]: [],
|
||||
[join("lib", "setup.ts")]: [],
|
||||
[join("lib", "status.ts")]: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("Menu domain stays on structural ports and does not re-export model", () => {
|
||||
const menuImports = getImportSpecifiers(join("lib", "menu.ts"));
|
||||
assert.equal(menuImports.includes("./pi.ts"), false);
|
||||
const menuSource = readFileSync(join(PROJECT_ROOT, "lib", "menu.ts"), "utf8");
|
||||
assert.equal(
|
||||
/export\s+(?:type\s+)?\{[\s\S]*?\}\s+from\s+["']\.\/model\.ts["']/.test(
|
||||
menuSource,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("Telegram API transport stays decoupled from persisted config defaults", () => {
|
||||
const apiImports = getImportSpecifiers(join("lib", "telegram-api.ts"));
|
||||
assert.equal(apiImports.includes("./config.ts"), false);
|
||||
});
|
||||
|
||||
test("Structural update and media domains stay decoupled from concrete API transport shapes", () => {
|
||||
const structuralFiles = ["updates.ts", "media.ts"];
|
||||
const apiImportsByFile = Object.fromEntries(
|
||||
structuralFiles.map((file) => [
|
||||
join("lib", file),
|
||||
getImportSpecifiers(join("lib", file)).includes("./telegram-api.ts"),
|
||||
]),
|
||||
);
|
||||
assert.deepEqual(apiImportsByFile, {
|
||||
[join("lib", "updates.ts")]: false,
|
||||
[join("lib", "media.ts")]: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("Outbound attachment delivery stays decoupled from queue, inbound media, and API helpers", () => {
|
||||
const attachmentImports = getImportSpecifiers(
|
||||
join("lib", "outbound-attachments.ts"),
|
||||
);
|
||||
assert.equal(attachmentImports.includes("./queue.ts"), false);
|
||||
assert.equal(attachmentImports.includes("./media.ts"), false);
|
||||
assert.equal(attachmentImports.includes("./telegram-api.ts"), false);
|
||||
});
|
||||
@@ -0,0 +1,562 @@
|
||||
/**
|
||||
* Regression tests for Telegram lifecycle hook helpers
|
||||
* Covers pi lifecycle hook registration and hook composition ordering
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
appendTelegramLifecycleHooks,
|
||||
createTelegramCompactionObserverRuntime,
|
||||
createTelegramSessionContextStore,
|
||||
createTelegramSessionGenerationFence,
|
||||
createTelegramMessageActivityTypingHooks,
|
||||
registerTelegramLifecycleHooks,
|
||||
} from "../lib/lifecycle.ts";
|
||||
import type { ExtensionAPI, ExtensionContext } from "../lib/pi.ts";
|
||||
import {
|
||||
createTelegramBridgeRuntime,
|
||||
createTelegramTypingLoopStarter,
|
||||
} from "../lib/runtime.ts";
|
||||
|
||||
type RegisteredLifecycleHandler = (
|
||||
event: unknown,
|
||||
ctx: ExtensionContext,
|
||||
) => Promise<unknown> | unknown;
|
||||
|
||||
function createLifecycleApiHarness() {
|
||||
const handlers = new Map<string, RegisteredLifecycleHandler>();
|
||||
const api = {
|
||||
on: (event: string, handler: RegisteredLifecycleHandler) => {
|
||||
handlers.set(event, handler);
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
return { api, handlers };
|
||||
}
|
||||
|
||||
function getRequiredLifecycleHandler(
|
||||
handlers: Map<string, RegisteredLifecycleHandler>,
|
||||
name: string,
|
||||
): RegisteredLifecycleHandler {
|
||||
const handler = handlers.get(name);
|
||||
assert.ok(handler, `Expected lifecycle handler ${name}`);
|
||||
return handler;
|
||||
}
|
||||
|
||||
function createLifecycleContext(): ExtensionContext {
|
||||
return {} as ExtensionContext;
|
||||
}
|
||||
|
||||
test("Session generation fence ignores delayed shutdown from a replaced context", async () => {
|
||||
const store = createTelegramSessionContextStore<ExtensionContext>();
|
||||
const shutdowns: ExtensionContext[] = [];
|
||||
const runtime = createTelegramSessionGenerationFence(store, {
|
||||
onSessionStart: async () => undefined,
|
||||
onSessionShutdown: async (_event, ctx) => {
|
||||
shutdowns.push(ctx);
|
||||
},
|
||||
});
|
||||
const oldContext = { cwd: "/old" } as ExtensionContext;
|
||||
const newContext = { cwd: "/new" } as ExtensionContext;
|
||||
|
||||
await runtime.onSessionStart({} as never, oldContext);
|
||||
await runtime.onSessionStart({} as never, newContext);
|
||||
await runtime.onSessionShutdown({} as never, oldContext);
|
||||
|
||||
assert.equal(store.get(), newContext);
|
||||
assert.deepEqual(shutdowns, []);
|
||||
await runtime.onSessionShutdown({} as never, newContext);
|
||||
assert.equal(store.get(), undefined);
|
||||
assert.deepEqual(shutdowns, [newContext]);
|
||||
});
|
||||
|
||||
test("Session context store accepts only the current explicit session identity", () => {
|
||||
type Context = { session: object };
|
||||
const store = createTelegramSessionContextStore<Context>({
|
||||
getIdentity: (ctx) => ctx.session,
|
||||
});
|
||||
const currentSession = {};
|
||||
const currentStart = { session: currentSession };
|
||||
const currentEvent = { session: currentSession };
|
||||
const unseenStaleEvent = { session: {} };
|
||||
|
||||
const generation = store.set(currentStart);
|
||||
assert.equal(store.isCurrent(currentEvent, generation), true);
|
||||
assert.equal(store.isCurrent(unseenStaleEvent, generation), false);
|
||||
assert.equal(store.isCurrent(unseenStaleEvent), false);
|
||||
assert.equal(store.getGeneration(), generation);
|
||||
});
|
||||
|
||||
test("Lifecycle composition skips old shutdown extras after replacement during await", async () => {
|
||||
let active = true;
|
||||
let releaseBase: (() => void) | undefined;
|
||||
const baseBlocked = new Promise<void>((resolve) => {
|
||||
releaseBase = resolve;
|
||||
});
|
||||
const events: string[] = [];
|
||||
const ctx = createLifecycleContext();
|
||||
const hooks = appendTelegramLifecycleHooks(
|
||||
{
|
||||
onSessionStart: async () => {},
|
||||
onSessionShutdown: async () => {
|
||||
events.push("base-start");
|
||||
await baseBlocked;
|
||||
events.push("base-end");
|
||||
},
|
||||
},
|
||||
{
|
||||
onSessionShutdown: async () => {
|
||||
events.push("extra-shutdown");
|
||||
},
|
||||
},
|
||||
() => active,
|
||||
);
|
||||
|
||||
const shutdown = hooks.onSessionShutdown({} as never, ctx);
|
||||
await Promise.resolve();
|
||||
active = false;
|
||||
releaseBase?.();
|
||||
await shutdown;
|
||||
|
||||
assert.deepEqual(events, ["base-start", "base-end"]);
|
||||
});
|
||||
|
||||
test("Lifecycle helpers compose session hooks in order", async () => {
|
||||
const events: string[] = [];
|
||||
const hooks = appendTelegramLifecycleHooks(
|
||||
{
|
||||
onSessionStart: async () => {
|
||||
events.push("base-start");
|
||||
},
|
||||
onSessionShutdown: async () => {
|
||||
events.push("base-shutdown");
|
||||
},
|
||||
},
|
||||
{
|
||||
onSessionStart: async () => {
|
||||
events.push("extra-start");
|
||||
},
|
||||
onSessionShutdown: async () => {
|
||||
events.push("extra-shutdown");
|
||||
},
|
||||
},
|
||||
);
|
||||
await hooks.onSessionStart({} as never, createLifecycleContext());
|
||||
await hooks.onSessionShutdown({} as never, createLifecycleContext());
|
||||
assert.deepEqual(events, [
|
||||
"base-start",
|
||||
"extra-start",
|
||||
"base-shutdown",
|
||||
"extra-shutdown",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Compaction observer mirrors active work with native typing", () => {
|
||||
const events: string[] = [];
|
||||
const timers = new Map<number, () => void>();
|
||||
let nextTimer = 0;
|
||||
const observer = createTelegramCompactionObserverRuntime({
|
||||
setCompactionInProgress(inProgress) {
|
||||
events.push(`compact:${String(inProgress)}`);
|
||||
},
|
||||
updateStatus() {
|
||||
events.push("status");
|
||||
},
|
||||
startTypingLoop() {
|
||||
events.push("typing:start");
|
||||
},
|
||||
stopTypingLoop() {
|
||||
events.push("typing:stop");
|
||||
},
|
||||
requestDeferredDispatchNextQueuedTelegramTurn(dispatch) {
|
||||
events.push("dispatch:request");
|
||||
dispatch(createLifecycleContext());
|
||||
},
|
||||
dispatchNextQueuedTelegramTurn() {
|
||||
events.push("dispatch");
|
||||
},
|
||||
recordRuntimeEvent(category, error) {
|
||||
events.push(`${category}:${(error as Error).message}`);
|
||||
},
|
||||
timeoutMs: 10,
|
||||
setTimer(callback) {
|
||||
nextTimer += 1;
|
||||
timers.set(nextTimer, callback);
|
||||
return nextTimer;
|
||||
},
|
||||
clearTimer(timer) {
|
||||
timers.delete(timer as number);
|
||||
},
|
||||
});
|
||||
observer.onSessionBeforeCompact({} as never, createLifecycleContext());
|
||||
observer.onSessionCompact({} as never, createLifecycleContext());
|
||||
assert.deepEqual(events, [
|
||||
"compact:true",
|
||||
"typing:start",
|
||||
"status",
|
||||
"compact:false",
|
||||
"typing:stop",
|
||||
"status",
|
||||
"dispatch:request",
|
||||
"dispatch",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Compaction observer keeps native typing for non-turn compaction", () => {
|
||||
const events: string[] = [];
|
||||
let timerCallback: (() => void) | undefined;
|
||||
const observer = createTelegramCompactionObserverRuntime({
|
||||
setCompactionInProgress(inProgress) {
|
||||
events.push(`compact:${String(inProgress)}`);
|
||||
},
|
||||
updateStatus() {
|
||||
events.push("status");
|
||||
},
|
||||
startTypingLoop() {
|
||||
events.push("typing:start");
|
||||
},
|
||||
stopTypingLoop() {
|
||||
events.push("typing:stop");
|
||||
},
|
||||
requestDeferredDispatchNextQueuedTelegramTurn(dispatch) {
|
||||
events.push("dispatch:request");
|
||||
dispatch(createLifecycleContext());
|
||||
},
|
||||
dispatchNextQueuedTelegramTurn() {
|
||||
events.push("dispatch");
|
||||
},
|
||||
setTimer(callback) {
|
||||
timerCallback = callback;
|
||||
return 1;
|
||||
},
|
||||
clearTimer() {
|
||||
timerCallback = undefined;
|
||||
},
|
||||
});
|
||||
observer.onSessionBeforeCompact({} as never, createLifecycleContext());
|
||||
observer.onSessionCompact({} as never, createLifecycleContext());
|
||||
observer.onSessionBeforeCompact({} as never, createLifecycleContext());
|
||||
timerCallback?.();
|
||||
observer.onSessionShutdown();
|
||||
assert.deepEqual(events, [
|
||||
"compact:true",
|
||||
"typing:start",
|
||||
"status",
|
||||
"compact:false",
|
||||
"typing:stop",
|
||||
"status",
|
||||
"dispatch:request",
|
||||
"dispatch",
|
||||
"compact:true",
|
||||
"typing:start",
|
||||
"status",
|
||||
"compact:false",
|
||||
"typing:stop",
|
||||
"status",
|
||||
"dispatch:request",
|
||||
"dispatch",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Compaction observer sends native typing to the resolved thread and All", async () => {
|
||||
const runtime = createTelegramBridgeRuntime();
|
||||
const actions: Array<string> = [];
|
||||
const startTyping = createTelegramTypingLoopStarter({
|
||||
typing: runtime.typing,
|
||||
getDefaultChatId: () => 77,
|
||||
sendTypingAction: async (chatId, options) => {
|
||||
actions.push(`thread:${chatId}:${options?.message_thread_id ?? "all"}`);
|
||||
},
|
||||
sendAggregateTypingAction: async (chatId) => {
|
||||
actions.push(`aggregate:${chatId}`);
|
||||
},
|
||||
updateStatus: () => {},
|
||||
intervalMs: 60_000,
|
||||
});
|
||||
const observer = createTelegramCompactionObserverRuntime({
|
||||
setCompactionInProgress: runtime.lifecycle.setCompactionInProgress,
|
||||
updateStatus: () => {},
|
||||
startTypingLoop: (ctx) =>
|
||||
startTyping(ctx, 77, { target: { chatId: 77, threadId: 12 } }),
|
||||
stopTypingLoop: runtime.typing.stop,
|
||||
requestDeferredDispatchNextQueuedTelegramTurn: () => {},
|
||||
dispatchNextQueuedTelegramTurn: () => {},
|
||||
});
|
||||
|
||||
observer.onSessionBeforeCompact({} as never, createLifecycleContext());
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
observer.onSessionCompact({} as never, createLifecycleContext());
|
||||
await runtime.typing.waitForIdle();
|
||||
|
||||
assert.deepEqual(actions, ["thread:77:12", "aggregate:77"]);
|
||||
assert.equal(runtime.lifecycle.isCompactionInProgress(), false);
|
||||
});
|
||||
|
||||
test("Compaction observer unrefs fallback timers when supported", () => {
|
||||
const events: string[] = [];
|
||||
const observer = createTelegramCompactionObserverRuntime({
|
||||
setCompactionInProgress: () => {},
|
||||
updateStatus: () => {},
|
||||
requestDeferredDispatchNextQueuedTelegramTurn: () => {},
|
||||
dispatchNextQueuedTelegramTurn: () => {},
|
||||
setTimer(callback) {
|
||||
assert.equal(typeof callback, "function");
|
||||
return {
|
||||
unref() {
|
||||
events.push("unref");
|
||||
},
|
||||
} as ReturnType<typeof setTimeout>;
|
||||
},
|
||||
clearTimer() {},
|
||||
});
|
||||
observer.onSessionBeforeCompact({} as never, createLifecycleContext());
|
||||
assert.deepEqual(events, ["unref"]);
|
||||
});
|
||||
|
||||
test("Compaction observer stops typing on timeout and shutdown", () => {
|
||||
const events: string[] = [];
|
||||
let timerCallback: (() => void) | undefined;
|
||||
const observer = createTelegramCompactionObserverRuntime({
|
||||
setCompactionInProgress(inProgress) {
|
||||
events.push(`compact:${String(inProgress)}`);
|
||||
},
|
||||
updateStatus() {
|
||||
events.push("status");
|
||||
},
|
||||
startTypingLoop() {
|
||||
events.push("typing:start");
|
||||
},
|
||||
stopTypingLoop() {
|
||||
events.push("typing:stop");
|
||||
},
|
||||
requestDeferredDispatchNextQueuedTelegramTurn() {
|
||||
events.push("dispatch:request");
|
||||
},
|
||||
dispatchNextQueuedTelegramTurn() {
|
||||
events.push("dispatch");
|
||||
},
|
||||
recordRuntimeEvent(category, error) {
|
||||
events.push(`${category}:${(error as Error).message}`);
|
||||
},
|
||||
onCompactionAbandoned() {
|
||||
events.push("activity:compact-abandoned");
|
||||
},
|
||||
setTimer(callback) {
|
||||
timerCallback = callback;
|
||||
return 1;
|
||||
},
|
||||
clearTimer() {
|
||||
timerCallback = undefined;
|
||||
},
|
||||
});
|
||||
observer.onSessionBeforeCompact({} as never, createLifecycleContext());
|
||||
timerCallback?.();
|
||||
observer.onSessionBeforeCompact({} as never, createLifecycleContext());
|
||||
observer.onSessionShutdown();
|
||||
assert.deepEqual(events, [
|
||||
"compact:true",
|
||||
"typing:start",
|
||||
"status",
|
||||
"compact:false",
|
||||
"typing:stop",
|
||||
"status",
|
||||
"compact:Compaction observer timed out",
|
||||
"activity:compact-abandoned",
|
||||
"dispatch:request",
|
||||
"compact:true",
|
||||
"typing:start",
|
||||
"status",
|
||||
"typing:stop",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Message activity hooks re-arm typing for active Telegram turns", async () => {
|
||||
const events: string[] = [];
|
||||
let active = true;
|
||||
const hooks = createTelegramMessageActivityTypingHooks({
|
||||
hasActiveTurn() {
|
||||
return active;
|
||||
},
|
||||
startTypingLoop() {
|
||||
events.push("typing:start");
|
||||
},
|
||||
async onMessageStart() {
|
||||
events.push("message:start");
|
||||
},
|
||||
async onMessageUpdate() {
|
||||
events.push("message:update");
|
||||
},
|
||||
});
|
||||
await hooks.onMessageStart(
|
||||
{ message: {} as never },
|
||||
createLifecycleContext(),
|
||||
);
|
||||
active = false;
|
||||
await hooks.onMessageUpdate(
|
||||
{ message: {} as never, assistantMessageEvent: {} as never },
|
||||
createLifecycleContext(),
|
||||
);
|
||||
assert.deepEqual(events, [
|
||||
"typing:start",
|
||||
"message:start",
|
||||
"typing:start",
|
||||
"message:update",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Message activity hooks preserve typing after transient preview errors", async () => {
|
||||
const events: string[] = [];
|
||||
const hooks = createTelegramMessageActivityTypingHooks({
|
||||
hasActiveTurn: () => true,
|
||||
startTypingLoop: () => {
|
||||
events.push("typing:start");
|
||||
},
|
||||
onMessageStart: async () => {
|
||||
events.push("message:start");
|
||||
},
|
||||
onMessageUpdate: async () => {
|
||||
events.push("message:update");
|
||||
throw new Error("websocket disconnected");
|
||||
},
|
||||
recordRuntimeEvent: (category, error, details) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
events.push(`${category}:${message}:${details?.phase}`);
|
||||
},
|
||||
});
|
||||
|
||||
await hooks.onMessageUpdate(
|
||||
{ message: {} as never, assistantMessageEvent: {} as never },
|
||||
createLifecycleContext(),
|
||||
);
|
||||
|
||||
assert.deepEqual(events, [
|
||||
"typing:start",
|
||||
"message:update",
|
||||
"message-activity:websocket disconnected:update",
|
||||
"typing:start",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Lifecycle helpers register pi hooks and delegate to handlers", async () => {
|
||||
const harness = createLifecycleApiHarness();
|
||||
const events: string[] = [];
|
||||
registerTelegramLifecycleHooks(harness.api, {
|
||||
onSessionStart: async () => {
|
||||
events.push("session-start");
|
||||
},
|
||||
onSessionShutdown: async () => {
|
||||
events.push("session-shutdown");
|
||||
},
|
||||
onSessionBeforeCompact: () => {
|
||||
events.push("session-before-compact");
|
||||
},
|
||||
onSessionCompact: () => {
|
||||
events.push("session-compact");
|
||||
},
|
||||
onBeforeAgentStart: () => {
|
||||
events.push("before-agent-start");
|
||||
return { systemPrompt: "prompt" };
|
||||
},
|
||||
onModelSelect: () => {
|
||||
events.push("model-select");
|
||||
},
|
||||
onAgentStart: async () => {
|
||||
events.push("agent-start");
|
||||
},
|
||||
onToolExecutionStart: () => {
|
||||
events.push("tool-start");
|
||||
},
|
||||
onToolExecutionUpdate: () => {
|
||||
events.push("tool-update");
|
||||
},
|
||||
onToolExecutionEnd: () => {
|
||||
events.push("tool-end");
|
||||
},
|
||||
onMessageStart: async () => {
|
||||
events.push("message-start");
|
||||
},
|
||||
onMessageUpdate: async () => {
|
||||
events.push("message-update");
|
||||
},
|
||||
onAgentEnd: async () => {
|
||||
events.push("agent-end");
|
||||
},
|
||||
});
|
||||
assert.deepEqual(
|
||||
[...harness.handlers.keys()],
|
||||
[
|
||||
"input",
|
||||
"session_start",
|
||||
"session_shutdown",
|
||||
"session_before_compact",
|
||||
"session_compact",
|
||||
"before_agent_start",
|
||||
"model_select",
|
||||
"agent_start",
|
||||
"tool_execution_start",
|
||||
"tool_execution_update",
|
||||
"tool_execution_end",
|
||||
"message_start",
|
||||
"message_update",
|
||||
"agent_end",
|
||||
"agent_settled",
|
||||
],
|
||||
);
|
||||
const ctx = createLifecycleContext();
|
||||
await getRequiredLifecycleHandler(harness.handlers, "input")({}, ctx);
|
||||
await getRequiredLifecycleHandler(harness.handlers, "session_start")({}, ctx);
|
||||
await getRequiredLifecycleHandler(harness.handlers, "session_shutdown")(
|
||||
{},
|
||||
ctx,
|
||||
);
|
||||
await getRequiredLifecycleHandler(harness.handlers, "session_before_compact")(
|
||||
{},
|
||||
ctx,
|
||||
);
|
||||
await getRequiredLifecycleHandler(harness.handlers, "session_compact")(
|
||||
{},
|
||||
ctx,
|
||||
);
|
||||
const beforeAgentStartResult = await getRequiredLifecycleHandler(
|
||||
harness.handlers,
|
||||
"before_agent_start",
|
||||
)({}, ctx);
|
||||
await getRequiredLifecycleHandler(harness.handlers, "model_select")({}, ctx);
|
||||
await getRequiredLifecycleHandler(harness.handlers, "agent_start")({}, ctx);
|
||||
await getRequiredLifecycleHandler(harness.handlers, "tool_execution_start")(
|
||||
{},
|
||||
ctx,
|
||||
);
|
||||
await getRequiredLifecycleHandler(harness.handlers, "tool_execution_update")(
|
||||
{},
|
||||
ctx,
|
||||
);
|
||||
await getRequiredLifecycleHandler(harness.handlers, "tool_execution_end")(
|
||||
{},
|
||||
ctx,
|
||||
);
|
||||
await getRequiredLifecycleHandler(harness.handlers, "message_start")({}, ctx);
|
||||
await getRequiredLifecycleHandler(harness.handlers, "message_update")(
|
||||
{},
|
||||
ctx,
|
||||
);
|
||||
await getRequiredLifecycleHandler(harness.handlers, "agent_end")({}, ctx);
|
||||
await getRequiredLifecycleHandler(harness.handlers, "agent_settled")({}, ctx);
|
||||
assert.deepEqual(beforeAgentStartResult, { systemPrompt: "prompt" });
|
||||
assert.deepEqual(events, [
|
||||
"session-start",
|
||||
"session-shutdown",
|
||||
"session-before-compact",
|
||||
"session-compact",
|
||||
"before-agent-start",
|
||||
"model-select",
|
||||
"agent-start",
|
||||
"tool-start",
|
||||
"tool-update",
|
||||
"tool-end",
|
||||
"message-start",
|
||||
"message-update",
|
||||
"agent-end",
|
||||
]);
|
||||
});
|
||||
+1825
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Regression tests for Telegram runtime JSONL diagnostics log
|
||||
* Covers session-local reset, scope changes, and append-only event evidence
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
createTelegramRuntimeJsonlLog,
|
||||
getTelegramPreviousRuntimeLogPath,
|
||||
getTelegramRuntimeLogPath,
|
||||
} from "../lib/logs.ts";
|
||||
|
||||
async function readJsonl(path: string): Promise<unknown[]> {
|
||||
const text = await readFile(path, "utf8");
|
||||
return text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as unknown);
|
||||
}
|
||||
|
||||
test("Runtime log paths preserve default compatibility and isolate named profiles", async () => {
|
||||
assert.equal(
|
||||
getTelegramRuntimeLogPath("/agent"),
|
||||
"/agent/tmp/telegram/logs.jsonl",
|
||||
);
|
||||
assert.equal(
|
||||
getTelegramPreviousRuntimeLogPath("/agent"),
|
||||
"/agent/tmp/telegram/logs._prev.jsonl",
|
||||
);
|
||||
assert.equal(
|
||||
getTelegramRuntimeLogPath("/agent", "omp"),
|
||||
"/agent/tmp/telegram/logs.omp.jsonl",
|
||||
);
|
||||
assert.equal(
|
||||
getTelegramPreviousRuntimeLogPath("/agent", "omp"),
|
||||
"/agent/tmp/telegram/logs.omp._prev.jsonl",
|
||||
);
|
||||
});
|
||||
|
||||
test("Runtime JSONL paths do not collide across profile lifecycle names", () => {
|
||||
const profiles = [
|
||||
"prev",
|
||||
"previous",
|
||||
"current",
|
||||
"work",
|
||||
"workone",
|
||||
"worktwo",
|
||||
];
|
||||
const paths = new Set([
|
||||
getTelegramRuntimeLogPath("/agent"),
|
||||
getTelegramPreviousRuntimeLogPath("/agent"),
|
||||
]);
|
||||
for (const profile of profiles) {
|
||||
paths.add(getTelegramRuntimeLogPath("/agent", profile));
|
||||
paths.add(getTelegramPreviousRuntimeLogPath("/agent", profile));
|
||||
}
|
||||
assert.equal(paths.size, 2 + profiles.length * 2);
|
||||
assert.equal(
|
||||
getTelegramRuntimeLogPath("/agent", "previous"),
|
||||
"/agent/tmp/telegram/logs.previous.jsonl",
|
||||
);
|
||||
assert.equal(
|
||||
getTelegramPreviousRuntimeLogPath("/agent", "previous"),
|
||||
"/agent/tmp/telegram/logs.previous._prev.jsonl",
|
||||
);
|
||||
});
|
||||
|
||||
test("Runtime JSONL log resets and appends session events", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "pi-telegram-log-"));
|
||||
try {
|
||||
let nowMs = 1000;
|
||||
const path = join(dir, "logs.jsonl");
|
||||
const previousPath = join(dir, "logs._prev.jsonl");
|
||||
const log = createTelegramRuntimeJsonlLog({
|
||||
path,
|
||||
previousPath,
|
||||
getNowMs: () => nowMs,
|
||||
});
|
||||
|
||||
log.reset("extension-start", { role: "leader" });
|
||||
log.record({
|
||||
at: 1001,
|
||||
category: "bus",
|
||||
message: "started",
|
||||
details: { phase: "leader-start" },
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
assert.deepEqual(await readJsonl(path), [
|
||||
{
|
||||
at: 1000,
|
||||
kind: "reset",
|
||||
reason: "extension-start",
|
||||
scope: { role: "leader" },
|
||||
previousPath,
|
||||
},
|
||||
{
|
||||
at: 1001,
|
||||
kind: "event",
|
||||
category: "bus",
|
||||
message: "started",
|
||||
details: { phase: "leader-start" },
|
||||
},
|
||||
]);
|
||||
|
||||
nowMs = 2000;
|
||||
log.resetIfScopeChanged("follower", "status-scope-change", {
|
||||
role: "follower",
|
||||
});
|
||||
assert.deepEqual(await readJsonl(path), [
|
||||
{
|
||||
at: 2000,
|
||||
kind: "reset",
|
||||
reason: "status-scope-change",
|
||||
scope: { role: "follower" },
|
||||
previousPath,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(await readJsonl(previousPath), [
|
||||
{
|
||||
at: 1000,
|
||||
kind: "reset",
|
||||
reason: "extension-start",
|
||||
scope: { role: "leader" },
|
||||
previousPath,
|
||||
},
|
||||
{
|
||||
at: 1001,
|
||||
kind: "event",
|
||||
category: "bus",
|
||||
message: "started",
|
||||
details: { phase: "leader-start" },
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Runtime JSONL destructive reset commits only under exact ownership", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "pi-telegram-log-reset-fence-"));
|
||||
const path = join(dir, "logs.jsonl");
|
||||
let owned = false;
|
||||
try {
|
||||
await writeFile(path, '{"kind":"event","message":"replacement"}\n');
|
||||
const log = createTelegramRuntimeJsonlLog({
|
||||
path,
|
||||
canReset: () => true,
|
||||
commitReset(commit) {
|
||||
if (!owned) return false;
|
||||
commit();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
log.resetIfScopeChanged("leader", "status-scope-change", {
|
||||
role: "leader",
|
||||
});
|
||||
assert.deepEqual(await readJsonl(path), [
|
||||
{ kind: "event", message: "replacement" },
|
||||
]);
|
||||
|
||||
owned = true;
|
||||
log.resetIfScopeChanged("leader", "status-scope-change", {
|
||||
role: "leader",
|
||||
});
|
||||
assert.equal(
|
||||
((await readJsonl(path))[0] as { kind?: string } | undefined)?.kind,
|
||||
"reset",
|
||||
);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Runtime JSONL appends serialize across processes without lost lines", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "pi-telegram-log-race-"));
|
||||
const path = join(dir, "logs.jsonl");
|
||||
const startPath = join(dir, "start");
|
||||
const moduleUrl = new URL("../lib/logs.ts", import.meta.url).href;
|
||||
const children = ["a", "b"].map((worker) => {
|
||||
const readyPath = join(dir, `ready-${worker}`);
|
||||
const source = `
|
||||
import { existsSync, writeFileSync } from "node:fs";
|
||||
import { createTelegramRuntimeJsonlLog } from ${JSON.stringify(moduleUrl)};
|
||||
const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||||
const log = createTelegramRuntimeJsonlLog({ path: process.env.LOG_PATH, canReset: () => false });
|
||||
writeFileSync(process.env.READY_PATH, "ready");
|
||||
while (!existsSync(process.env.START_PATH)) sleep(2);
|
||||
for (let index = 0; index < 25; index += 1) {
|
||||
log.record({ at: index, category: process.env.WORKER, message: String(index) });
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
`;
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
["--experimental-strip-types", "--input-type=module", "--eval", source],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
LOG_PATH: path,
|
||||
READY_PATH: readyPath,
|
||||
START_PATH: startPath,
|
||||
WORKER: worker,
|
||||
},
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
},
|
||||
);
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (chunk) => (stderr += String(chunk)));
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code) =>
|
||||
code === 0
|
||||
? resolve()
|
||||
: reject(new Error(`log child exited ${code}: ${stderr}`)),
|
||||
);
|
||||
});
|
||||
return { readyPath, done };
|
||||
});
|
||||
try {
|
||||
const deadline = Date.now() + 3000;
|
||||
while (
|
||||
!children.every((child) => existsSync(child.readyPath)) &&
|
||||
Date.now() < deadline
|
||||
) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
assert.equal(children.every((child) => existsSync(child.readyPath)), true);
|
||||
await writeFile(startPath, "start");
|
||||
await Promise.all(children.map((child) => child.done));
|
||||
const lines = await readJsonl(path);
|
||||
assert.equal(lines.length, 50);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Runtime JSONL append captures its profile path before queued execution", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "pi-telegram-log-path-capture-"));
|
||||
try {
|
||||
let profileName = "alpha";
|
||||
const log = createTelegramRuntimeJsonlLog({
|
||||
path: () => getTelegramRuntimeLogPath(dir, profileName),
|
||||
previousPath: () => getTelegramPreviousRuntimeLogPath(dir, profileName),
|
||||
});
|
||||
|
||||
log.record({ at: 1000, category: "queue", message: "alpha event" });
|
||||
profileName = "beta";
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
assert.deepEqual(
|
||||
await readJsonl(getTelegramRuntimeLogPath(dir, "alpha")),
|
||||
[{ at: 1000, kind: "event", category: "queue", message: "alpha event" }],
|
||||
);
|
||||
await assert.rejects(() => readJsonl(getTelegramRuntimeLogPath(dir, "beta")));
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Runtime JSONL log keeps previous logs per active profile path", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "pi-telegram-profile-log-"));
|
||||
try {
|
||||
let profileName: string | undefined;
|
||||
let nowMs = 1000;
|
||||
const log = createTelegramRuntimeJsonlLog({
|
||||
path: () => getTelegramRuntimeLogPath(dir, profileName),
|
||||
previousPath: () => getTelegramPreviousRuntimeLogPath(dir, profileName),
|
||||
getNowMs: () => nowMs,
|
||||
});
|
||||
|
||||
log.reset("default-start", { profile: "default" });
|
||||
profileName = "omp";
|
||||
nowMs = 2000;
|
||||
log.reset("profile-start", { profile: "omp" });
|
||||
log.record({ at: 2001, category: "bus", message: "profile event" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
assert.deepEqual(await readJsonl(getTelegramRuntimeLogPath(dir)), [
|
||||
{
|
||||
at: 1000,
|
||||
kind: "reset",
|
||||
reason: "default-start",
|
||||
scope: { profile: "default" },
|
||||
previousPath: getTelegramPreviousRuntimeLogPath(dir),
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(await readJsonl(getTelegramRuntimeLogPath(dir, "omp")), [
|
||||
{
|
||||
at: 2000,
|
||||
kind: "reset",
|
||||
reason: "profile-start",
|
||||
scope: { profile: "omp" },
|
||||
previousPath: getTelegramPreviousRuntimeLogPath(dir, "omp"),
|
||||
},
|
||||
{ at: 2001, kind: "event", category: "bus", message: "profile event" },
|
||||
]);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,452 @@
|
||||
/**
|
||||
* Regression tests for Telegram media and text extraction helpers
|
||||
* Covers inbound file-info collection, text extraction, media groups, id collection, and history formatting
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
collectTelegramFileInfos,
|
||||
collectTelegramMessageIds,
|
||||
createTelegramMediaGroupController,
|
||||
createTelegramMediaGroupDispatchRuntime,
|
||||
downloadTelegramMessageFiles,
|
||||
extractFirstTelegramMessageText,
|
||||
extractTelegramMessagePromptText,
|
||||
extractTelegramMessagesPromptText,
|
||||
extractTelegramMessagesText,
|
||||
formatTelegramHistoryText,
|
||||
getTelegramMediaGroupKey,
|
||||
guessMediaType,
|
||||
queueTelegramMediaGroupMessage,
|
||||
removePendingTelegramMediaGroupMessages,
|
||||
type TelegramMediaGroupState,
|
||||
} from "../lib/media.ts";
|
||||
|
||||
type TestTimer = ReturnType<typeof setTimeout>;
|
||||
|
||||
function createTestTimer(id: number): TestTimer {
|
||||
return id as unknown as TestTimer;
|
||||
}
|
||||
|
||||
function getTestTimerId(timer: TestTimer): number {
|
||||
return timer as unknown as number;
|
||||
}
|
||||
|
||||
test("Media helpers collect file infos across Telegram message variants", () => {
|
||||
const files = collectTelegramFileInfos([
|
||||
{
|
||||
message_id: 1,
|
||||
text: "hello",
|
||||
photo: [
|
||||
{ file_id: "small", file_size: 1 },
|
||||
{ file_id: "large", file_size: 10 },
|
||||
],
|
||||
document: {
|
||||
file_id: "doc",
|
||||
file_name: "report.png",
|
||||
mime_type: "image/png",
|
||||
},
|
||||
voice: {
|
||||
file_id: "voice",
|
||||
mime_type: "audio/ogg",
|
||||
},
|
||||
sticker: {
|
||||
file_id: "sticker",
|
||||
},
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(
|
||||
files.map((file) => ({
|
||||
id: file.file_id,
|
||||
name: file.fileName,
|
||||
image: file.isImage,
|
||||
})),
|
||||
[
|
||||
{ id: "large", name: "photo-1.jpg", image: true },
|
||||
{ id: "doc", name: "report.png", image: true },
|
||||
{ id: "voice", name: "voice-1.ogg", image: false },
|
||||
{ id: "sticker", name: "sticker-1.webp", image: true },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("Media helpers download collected file infos", async () => {
|
||||
const downloaded = await downloadTelegramMessageFiles(
|
||||
[
|
||||
{
|
||||
message_id: 3,
|
||||
document: {
|
||||
file_id: "doc-id",
|
||||
file_name: "report.pdf",
|
||||
mime_type: "application/pdf",
|
||||
},
|
||||
},
|
||||
],
|
||||
{
|
||||
downloadFile: async (fileId, fileName) => `/tmp/${fileId}-${fileName}`,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(downloaded, [
|
||||
{
|
||||
path: "/tmp/doc-id-report.pdf",
|
||||
fileName: "report.pdf",
|
||||
isImage: false,
|
||||
mimeType: "application/pdf",
|
||||
kind: "document",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("Media helpers extract text, ids, and history summaries", () => {
|
||||
const messages = [
|
||||
{ message_id: 1, text: "first" },
|
||||
{ message_id: 2, caption: "second" },
|
||||
{ message_id: 2, text: "duplicate id" },
|
||||
];
|
||||
assert.equal(
|
||||
extractTelegramMessagesText(messages),
|
||||
"first\n\nsecond\n\nduplicate id",
|
||||
);
|
||||
assert.equal(extractFirstTelegramMessageText(messages), "first");
|
||||
assert.deepEqual(collectTelegramMessageIds(messages), [1, 2]);
|
||||
assert.equal(
|
||||
formatTelegramHistoryText(
|
||||
"hello",
|
||||
[{ path: "/tmp/demo.txt" }],
|
||||
["transcript"],
|
||||
),
|
||||
"hello\n\n[attachments] /tmp\n- /demo.txt\n\n[outputs]\n- transcript",
|
||||
);
|
||||
});
|
||||
|
||||
test("Media helpers keep raw text command-safe and add reply context only for prompts", () => {
|
||||
const repliedCommand = {
|
||||
message_id: 1,
|
||||
text: "/status",
|
||||
reply_to_message: { text: "quoted context" },
|
||||
};
|
||||
const captionReply = {
|
||||
message_id: 2,
|
||||
caption: "caption reply",
|
||||
reply_to_message: { caption: "quoted caption" },
|
||||
};
|
||||
assert.equal(extractFirstTelegramMessageText([repliedCommand]), "/status");
|
||||
assert.equal(extractTelegramMessagesText([repliedCommand]), "/status");
|
||||
assert.equal(
|
||||
extractTelegramMessagePromptText(repliedCommand),
|
||||
"/status\n\n[reply] quoted context",
|
||||
);
|
||||
assert.equal(
|
||||
extractTelegramMessagesPromptText([captionReply]),
|
||||
"caption reply\n\n[reply] quoted caption",
|
||||
);
|
||||
});
|
||||
|
||||
test("Media helpers prefer rich-message text for reply context", () => {
|
||||
const message = {
|
||||
message_id: 1,
|
||||
text: "current",
|
||||
reply_to_message: {
|
||||
text: "## Raw **Markdown** fallback",
|
||||
rich_message: {
|
||||
blocks: [
|
||||
{ type: "heading", text: "Rendered heading", size: 2 },
|
||||
{
|
||||
type: "paragraph",
|
||||
text: ["Plain ", { type: "bold", text: "reply" }, " text"],
|
||||
},
|
||||
{
|
||||
type: "list",
|
||||
items: [
|
||||
{
|
||||
label: "1.",
|
||||
blocks: [{ type: "paragraph", text: "First item" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
assert.equal(
|
||||
extractTelegramMessagePromptText(message),
|
||||
"current\n\n[reply] Rendered heading\n\nPlain reply text\n\n1. First item",
|
||||
);
|
||||
});
|
||||
|
||||
test("Media helpers truncate long reply context for prompt text", () => {
|
||||
const longQuote = `${"a".repeat(1000)} extra`;
|
||||
assert.equal(
|
||||
extractTelegramMessagesPromptText([
|
||||
{
|
||||
message_id: 1,
|
||||
text: "current",
|
||||
reply_to_message: { text: longQuote },
|
||||
},
|
||||
]),
|
||||
`current\n\n[reply] ${"a".repeat(1000)}…`,
|
||||
);
|
||||
});
|
||||
|
||||
test("Media helpers infer image media types from file paths", () => {
|
||||
assert.equal(guessMediaType("/tmp/demo.png"), "image/png");
|
||||
assert.equal(guessMediaType("/tmp/demo.txt"), undefined);
|
||||
});
|
||||
|
||||
test("Media helpers key messages by chat and media group", () => {
|
||||
assert.equal(
|
||||
getTelegramMediaGroupKey({
|
||||
message_id: 1,
|
||||
chat: { id: 7 },
|
||||
media_group_id: "album",
|
||||
}),
|
||||
"7:private:album",
|
||||
);
|
||||
assert.equal(
|
||||
getTelegramMediaGroupKey({
|
||||
message_id: 1,
|
||||
chat: { id: 7 },
|
||||
message_thread_id: 42,
|
||||
media_group_id: "album",
|
||||
}),
|
||||
"7:thread:42:album",
|
||||
);
|
||||
assert.equal(
|
||||
getTelegramMediaGroupKey({ message_id: 1, chat: { id: 7 } }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("Media helpers replace debounce timers and dispatch grouped messages", async () => {
|
||||
const groups = new Map<
|
||||
string,
|
||||
TelegramMediaGroupState<{
|
||||
message_id: number;
|
||||
chat: { id: number };
|
||||
media_group_id?: string;
|
||||
}>
|
||||
>();
|
||||
const cleared: number[] = [];
|
||||
const callbacks: Array<() => void> = [];
|
||||
const dispatched: number[][] = [];
|
||||
let nextTimer = 1;
|
||||
const setTimer = (callback: () => void): TestTimer => {
|
||||
callbacks.push(callback);
|
||||
return createTestTimer(nextTimer++);
|
||||
};
|
||||
const clearTimer = (timer: TestTimer): void => {
|
||||
cleared.push(getTestTimerId(timer));
|
||||
};
|
||||
assert.equal(
|
||||
queueTelegramMediaGroupMessage({
|
||||
message: { message_id: 1, chat: { id: 7 }, media_group_id: "album" },
|
||||
groups,
|
||||
debounceMs: 100,
|
||||
setTimer,
|
||||
clearTimer,
|
||||
dispatchMessages: (messages) =>
|
||||
dispatched.push(messages.map((message) => message.message_id)),
|
||||
}),
|
||||
true,
|
||||
);
|
||||
queueTelegramMediaGroupMessage({
|
||||
message: { message_id: 2, chat: { id: 7 }, media_group_id: "album" },
|
||||
groups,
|
||||
debounceMs: 100,
|
||||
setTimer,
|
||||
clearTimer,
|
||||
dispatchMessages: (messages) =>
|
||||
dispatched.push(messages.map((message) => message.message_id)),
|
||||
});
|
||||
assert.deepEqual(cleared, [1]);
|
||||
callbacks.at(-1)?.();
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(dispatched, [[1, 2]]);
|
||||
assert.equal(groups.size, 0);
|
||||
});
|
||||
|
||||
test("Media group keeps messages until asynchronous dispatch succeeds", async () => {
|
||||
const groups = new Map<
|
||||
string,
|
||||
TelegramMediaGroupState<{
|
||||
message_id: number;
|
||||
chat: { id: number };
|
||||
media_group_id?: string;
|
||||
}>
|
||||
>();
|
||||
const callbacks: Array<() => void> = [];
|
||||
let attempts = 0;
|
||||
queueTelegramMediaGroupMessage({
|
||||
message: { message_id: 1, chat: { id: 7 }, media_group_id: "album" },
|
||||
groups,
|
||||
debounceMs: 10,
|
||||
setTimer: (callback) => {
|
||||
callbacks.push(callback);
|
||||
return createTestTimer(callbacks.length);
|
||||
},
|
||||
clearTimer: () => {},
|
||||
dispatchMessages: async () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) throw new Error("queue admission failed");
|
||||
},
|
||||
});
|
||||
|
||||
callbacks[0]?.();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.equal(groups.size, 1);
|
||||
assert.equal(callbacks.length, 2);
|
||||
callbacks[1]?.();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.equal(attempts, 2);
|
||||
assert.equal(groups.size, 0);
|
||||
});
|
||||
|
||||
test("Media group controller owns timers, removal, and cleanup", () => {
|
||||
const cleared: number[] = [];
|
||||
const callbacks: Array<() => void> = [];
|
||||
const dispatched: number[][] = [];
|
||||
let nextTimer = 1;
|
||||
const controller = createTelegramMediaGroupController<{
|
||||
message_id: number;
|
||||
chat: { id: number };
|
||||
media_group_id?: string;
|
||||
}>({
|
||||
debounceMs: 100,
|
||||
setTimer: (callback) => {
|
||||
callbacks.push(callback);
|
||||
return createTestTimer(nextTimer++);
|
||||
},
|
||||
clearTimer: (timer) => {
|
||||
cleared.push(getTestTimerId(timer));
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
controller.queueMessage({
|
||||
message: { message_id: 1, chat: { id: 7 }, media_group_id: "album" },
|
||||
dispatchMessages: (messages) =>
|
||||
dispatched.push(messages.map((message) => message.message_id)),
|
||||
}),
|
||||
true,
|
||||
);
|
||||
controller.queueMessage({
|
||||
message: { message_id: 2, chat: { id: 7 }, media_group_id: "album" },
|
||||
dispatchMessages: (messages) =>
|
||||
dispatched.push(messages.map((message) => message.message_id)),
|
||||
});
|
||||
assert.deepEqual(cleared, [1]);
|
||||
assert.equal(controller.removeMessages([2]), 1);
|
||||
assert.deepEqual(cleared, [1, 2]);
|
||||
callbacks.at(-1)?.();
|
||||
assert.equal(dispatched.length, 0);
|
||||
controller.queueMessage({
|
||||
message: { message_id: 3, chat: { id: 7 }, media_group_id: "album" },
|
||||
dispatchMessages: (messages) =>
|
||||
dispatched.push(messages.map((message) => message.message_id)),
|
||||
});
|
||||
controller.clear();
|
||||
assert.deepEqual(cleared, [1, 2, 3]);
|
||||
});
|
||||
|
||||
test("Media group suspension preserves admitted messages for replacement context", async () => {
|
||||
const callbacks: Array<() => void> = [];
|
||||
const cleared: number[] = [];
|
||||
const dispatched: Array<{ ids: number[]; ctx?: string }> = [];
|
||||
let nextTimer = 1;
|
||||
const controller = createTelegramMediaGroupController<
|
||||
{ message_id: number; chat: { id: number }; media_group_id?: string },
|
||||
string
|
||||
>({
|
||||
debounceMs: 100,
|
||||
setTimer: (callback) => {
|
||||
callbacks.push(callback);
|
||||
return createTestTimer(nextTimer++);
|
||||
},
|
||||
clearTimer: (timer) => cleared.push(getTestTimerId(timer)),
|
||||
});
|
||||
controller.queueMessage({
|
||||
message: { message_id: 1, chat: { id: 7 }, media_group_id: "album" },
|
||||
context: "old-session",
|
||||
dispatchMessages: (messages, ctx) =>
|
||||
dispatched.push({
|
||||
ids: messages.map((message) => message.message_id),
|
||||
ctx,
|
||||
}),
|
||||
});
|
||||
|
||||
controller.suspend();
|
||||
controller.resume("new-session");
|
||||
callbacks[1]?.();
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepEqual(cleared, [1]);
|
||||
assert.deepEqual(dispatched, [{ ids: [1], ctx: "new-session" }]);
|
||||
});
|
||||
|
||||
test("Media group dispatch runtime handles immediate and grouped messages", async () => {
|
||||
const callbacks: Array<() => void> = [];
|
||||
const dispatched: Array<{ ids: number[]; ctx: string }> = [];
|
||||
const controller = createTelegramMediaGroupController<
|
||||
{
|
||||
message_id: number;
|
||||
chat: { id: number };
|
||||
media_group_id?: string;
|
||||
},
|
||||
string
|
||||
>({
|
||||
setTimer: (callback) => {
|
||||
callbacks.push(callback);
|
||||
return createTestTimer(callbacks.length);
|
||||
},
|
||||
clearTimer: () => {},
|
||||
});
|
||||
const runtime = createTelegramMediaGroupDispatchRuntime({
|
||||
mediaGroups: controller,
|
||||
dispatchMessages: async (messages, ctx: string) => {
|
||||
dispatched.push({
|
||||
ids: messages.map((message) => message.message_id),
|
||||
ctx,
|
||||
});
|
||||
},
|
||||
});
|
||||
await runtime.handleMessage({ message_id: 1, chat: { id: 7 } }, "ctx-a");
|
||||
await runtime.handleMessage(
|
||||
{ message_id: 2, chat: { id: 7 }, media_group_id: "album" },
|
||||
"ctx-b",
|
||||
);
|
||||
await runtime.handleMessage(
|
||||
{ message_id: 3, chat: { id: 7 }, media_group_id: "album" },
|
||||
"ctx-b",
|
||||
);
|
||||
callbacks.at(-1)?.();
|
||||
assert.deepEqual(dispatched, [
|
||||
{ ids: [1], ctx: "ctx-a" },
|
||||
{ ids: [2, 3], ctx: "ctx-b" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("Media helpers remove pending groups by message id", () => {
|
||||
const groups = new Map<
|
||||
string,
|
||||
TelegramMediaGroupState<{ message_id: number; chat: { id: number } }>
|
||||
>();
|
||||
groups.set("7:album", {
|
||||
messages: [
|
||||
{ message_id: 1, chat: { id: 7 } },
|
||||
{ message_id: 2, chat: { id: 7 } },
|
||||
],
|
||||
flushTimer: createTestTimer(10),
|
||||
});
|
||||
const cleared: number[] = [];
|
||||
assert.equal(
|
||||
removePendingTelegramMediaGroupMessages(groups, [2], (timer) => {
|
||||
cleared.push(getTestTimerId(timer));
|
||||
}),
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(cleared, [10]);
|
||||
assert.equal(groups.size, 0);
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Regression tests for Telegram settings menu helpers
|
||||
* Exercises settings text/markup, callback mutations, stale-message fallback, and runtime wiring
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildProactivePushSettingsReplyMarkup,
|
||||
buildProactivePushSettingsText,
|
||||
buildAssistantRenderingSettingsReplyMarkup,
|
||||
buildAssistantRenderingSettingsText,
|
||||
buildDraftPreviewsSettingsReplyMarkup,
|
||||
buildDraftPreviewsSettingsText,
|
||||
buildTelegramSettingsMenuReplyMarkup,
|
||||
buildTelegramSettingsMenuText,
|
||||
buildTimeInjectionModeSettingsReplyMarkup,
|
||||
buildVoiceReplyModeSettingsReplyMarkup,
|
||||
createTelegramSettingsMenuRuntime,
|
||||
handleTelegramSettingsMenuCallbackAction,
|
||||
} from "../lib/menu-settings.ts";
|
||||
|
||||
test("Settings menu text and reply markup expose built-in controls", () => {
|
||||
assert.equal(buildTelegramSettingsMenuText(), "<b>⚙️ Settings:</b>");
|
||||
|
||||
const markup = buildTelegramSettingsMenuReplyMarkup(
|
||||
true,
|
||||
false,
|
||||
"manual",
|
||||
"hidden",
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
markup.inline_keyboard.map((row) => row[0]?.callback_data),
|
||||
[
|
||||
"menu:back",
|
||||
"settings:open:voice-reply",
|
||||
"settings:open:time-injection",
|
||||
"settings:open:draft-previews",
|
||||
"settings:open:assistant-rendering",
|
||||
"settings:open:proactive",
|
||||
],
|
||||
);
|
||||
assert.equal(markup.inline_keyboard[1]?.[0]?.text, "👄 Voice reply: hidden");
|
||||
assert.equal(
|
||||
markup.inline_keyboard[2]?.[0]?.text,
|
||||
"🕒 Time injection: hidden",
|
||||
);
|
||||
assert.equal(markup.inline_keyboard[3]?.[0]?.text, "📝 Draft previews: off");
|
||||
assert.equal(markup.inline_keyboard[4]?.[0]?.text, "🧾 Rendering: rich");
|
||||
assert.equal(markup.inline_keyboard[5]?.[0]?.text, "📌 Proactive push: on");
|
||||
});
|
||||
|
||||
test("Settings detail markups show active values", () => {
|
||||
assert.match(buildProactivePushSettingsText(true), /<code>on<\/code>/);
|
||||
assert.match(buildDraftPreviewsSettingsText(false), /<code>off<\/code>/);
|
||||
assert.equal(
|
||||
buildDraftPreviewsSettingsReplyMarkup(true).inline_keyboard[1]?.[0]?.text,
|
||||
"🟢 On",
|
||||
);
|
||||
assert.match(buildAssistantRenderingSettingsText("html"), /<code>html<\/code>/);
|
||||
assert.equal(
|
||||
buildAssistantRenderingSettingsReplyMarkup("rich").inline_keyboard[1]?.[0]
|
||||
?.text,
|
||||
"🟢 rich",
|
||||
);
|
||||
assert.equal(
|
||||
buildProactivePushSettingsReplyMarkup(false).inline_keyboard[1]?.[1]?.text,
|
||||
"🟡 Off",
|
||||
);
|
||||
assert.equal(
|
||||
buildTimeInjectionModeSettingsReplyMarkup("interval")
|
||||
.inline_keyboard[3]?.[0]?.text,
|
||||
"🟢 interval",
|
||||
);
|
||||
assert.equal(
|
||||
buildVoiceReplyModeSettingsReplyMarkup("mirror", true)
|
||||
.inline_keyboard[3]?.[0]?.text,
|
||||
"🟢 mirror",
|
||||
);
|
||||
assert.equal(
|
||||
buildVoiceReplyModeSettingsReplyMarkup("manual", false)
|
||||
.inline_keyboard[1]?.[0]?.text,
|
||||
"🟢 hidden",
|
||||
);
|
||||
});
|
||||
|
||||
test("Settings callback action mutates voice, time, and proactive settings", async () => {
|
||||
const calls: string[] = [];
|
||||
const deps = {
|
||||
isProactivePushEnabled: () => false,
|
||||
getVoiceReplyMode: () => "manual" as const,
|
||||
isVoiceReplyModeConfigured: () => true,
|
||||
getTimeInjectionMode: () => "hidden" as const,
|
||||
areDraftPreviewsEnabled: () => false,
|
||||
getAssistantRenderingMode: () => "rich" as const,
|
||||
setProactivePushEnabled: async (enabled: boolean) => {
|
||||
calls.push(`proactive:${enabled}`);
|
||||
},
|
||||
setDraftPreviewsEnabled: async (enabled: boolean) => {
|
||||
calls.push(`draft-previews:${enabled}`);
|
||||
},
|
||||
setAssistantRenderingMode: async (mode: "rich" | "html") => {
|
||||
calls.push(`rendering:${mode}`);
|
||||
},
|
||||
setVoiceReplyMode: async (
|
||||
mode: "manual" | "mirror" | "always" | undefined,
|
||||
) => {
|
||||
calls.push(`voice:${mode ?? "hidden"}`);
|
||||
},
|
||||
setTimeInjectionMode: async (mode: "hidden" | "always" | "interval") => {
|
||||
calls.push(`time:${mode}`);
|
||||
},
|
||||
updateSettingsMessage: async (text: string) => {
|
||||
calls.push(`update:${text.split("\n")[0]}`);
|
||||
},
|
||||
answerCallbackQuery: async (_id: string, text?: string) => {
|
||||
calls.push(`answer:${text ?? ""}`);
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
await handleTelegramSettingsMenuCallbackAction(
|
||||
"q1",
|
||||
"settings:set:voice-reply:hidden",
|
||||
deps,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await handleTelegramSettingsMenuCallbackAction(
|
||||
"q2",
|
||||
"settings:set:time:off",
|
||||
deps,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await handleTelegramSettingsMenuCallbackAction(
|
||||
"q3",
|
||||
"settings:set:draft-previews:on",
|
||||
deps,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await handleTelegramSettingsMenuCallbackAction(
|
||||
"q4",
|
||||
"settings:set:assistant-rendering:html",
|
||||
deps,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await handleTelegramSettingsMenuCallbackAction(
|
||||
"q5",
|
||||
"settings:set:proactive:on",
|
||||
deps,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await handleTelegramSettingsMenuCallbackAction("q6", "other", deps),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
"voice:hidden",
|
||||
"update:<b>👄 Voice reply mode:</b> <code>manual</code>",
|
||||
"answer:Voice reply mode: hidden",
|
||||
"time:hidden",
|
||||
"update:<b>🕒 Time injection mode:</b> <code>hidden</code>",
|
||||
"answer:Time injection: hidden",
|
||||
"draft-previews:true",
|
||||
"update:<b>📝 Draft previews:</b> <code>off</code>",
|
||||
"answer:Draft previews enabled",
|
||||
"rendering:html",
|
||||
"update:<b>🧾 Assistant rendering:</b> <code>rich</code>",
|
||||
"answer:Rendering: html",
|
||||
"proactive:true",
|
||||
"update:<b>📌 Proactive push:</b> <code>off</code>",
|
||||
"answer:Proactive push enabled",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Settings runtime opens menus and applies stale-message fallback toggles", async () => {
|
||||
const state: any = {
|
||||
chatId: 1,
|
||||
messageId: 2,
|
||||
mode: "status",
|
||||
page: 0,
|
||||
scope: "all",
|
||||
scopedModels: [],
|
||||
allModels: [],
|
||||
};
|
||||
const calls: string[] = [];
|
||||
const runtime = createTelegramSettingsMenuRuntime({
|
||||
isProactivePushEnabled: () => true,
|
||||
getVoiceReplyMode: () => "manual",
|
||||
isVoiceReplyModeConfigured: () => true,
|
||||
getTimeInjectionMode: () => "hidden",
|
||||
areDraftPreviewsEnabled: () => false,
|
||||
getAssistantRenderingMode: () => "rich",
|
||||
setProactivePushEnabled: async (enabled) => {
|
||||
calls.push(`proactive:${enabled}`);
|
||||
},
|
||||
setDraftPreviewsEnabled: async (enabled) => {
|
||||
calls.push(`draft-previews:${enabled}`);
|
||||
},
|
||||
setAssistantRenderingMode: async (mode) => {
|
||||
calls.push(`rendering:${mode}`);
|
||||
},
|
||||
setVoiceReplyMode: async (mode) => {
|
||||
calls.push(`voice:${mode ?? "hidden"}`);
|
||||
},
|
||||
setTimeInjectionMode: async (mode) => {
|
||||
calls.push(`time:${mode}`);
|
||||
},
|
||||
getModelMenuState: async () => state,
|
||||
getStoredModelMenuState: () => undefined,
|
||||
storeModelMenuState: (nextState) => calls.push(`store:${nextState.mode}`),
|
||||
editInteractiveMessage: async () => {
|
||||
calls.push("edit");
|
||||
},
|
||||
sendInteractiveMessage: async (_chatId, _text, mode) => {
|
||||
calls.push(`send:${mode}`);
|
||||
return 99;
|
||||
},
|
||||
answerCallbackQuery: async (_id, text) => {
|
||||
calls.push(`answer:${text ?? ""}`);
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.openSettingsMenu(1, 2, "ctx");
|
||||
assert.equal(state.messageId, 99);
|
||||
assert.equal(state.mode, "settings");
|
||||
|
||||
assert.equal(
|
||||
await runtime.handleCallbackQuery(
|
||||
{ id: "q1", data: "settings:set:voice-reply:always" },
|
||||
"ctx",
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await runtime.handleCallbackQuery(
|
||||
{ id: "q2", data: "settings:set:time:off" },
|
||||
"ctx",
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(calls, [
|
||||
"send:html",
|
||||
"store:settings",
|
||||
"voice:always",
|
||||
"answer:Voice reply mode: always",
|
||||
"time:hidden",
|
||||
"answer:Time injection: hidden",
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Regression tests for Telegram status menu helpers
|
||||
* Exercises status-menu reply markup, callback routing, and message render/update helpers
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildStatusReplyMarkup,
|
||||
buildTelegramStatusMenuRenderPayload,
|
||||
handleTelegramStatusMenuCallbackAction,
|
||||
openTelegramStatusMenu,
|
||||
sendTelegramStatusMessage,
|
||||
updateTelegramStatusMessage,
|
||||
} from "../lib/menu-status.ts";
|
||||
|
||||
const reasoningModel = {
|
||||
provider: "openai",
|
||||
id: "gpt-5",
|
||||
reasoning: true,
|
||||
};
|
||||
|
||||
test("Status menu reply markup exposes model, thinking, queue, and settings rows", () => {
|
||||
const markup = buildStatusReplyMarkup(reasoningModel, "medium", 2);
|
||||
|
||||
assert.deepEqual(
|
||||
markup.inline_keyboard.map((row) => row[0]?.callback_data),
|
||||
["menu:model", "menu:thinking", "menu:queue", "menu:settings"],
|
||||
);
|
||||
assert.equal(
|
||||
markup.inline_keyboard[0]?.[0]?.text.startsWith("🤖 Model"),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
markup.inline_keyboard[1]?.[0]?.text.startsWith("🧠 Thinking"),
|
||||
true,
|
||||
);
|
||||
assert.equal(markup.inline_keyboard[2]?.[0]?.text, "⏳ Queue: 2");
|
||||
});
|
||||
|
||||
test("Status menu hides thinking row for non-reasoning and voice-active states", () => {
|
||||
assert.deepEqual(
|
||||
buildStatusReplyMarkup(
|
||||
{ provider: "x", id: "plain" },
|
||||
"off",
|
||||
0,
|
||||
).inline_keyboard.map((row) => row[0]?.callback_data),
|
||||
["menu:model", "menu:queue", "menu:settings"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
buildStatusReplyMarkup(
|
||||
reasoningModel,
|
||||
"medium",
|
||||
0,
|
||||
undefined,
|
||||
true,
|
||||
).inline_keyboard.map((row) => row[0]?.callback_data),
|
||||
["menu:model", "menu:queue", "menu:settings"],
|
||||
);
|
||||
});
|
||||
|
||||
test("Status callback routing updates target menus and guards thinking controls", async () => {
|
||||
const calls: string[] = [];
|
||||
const deps = {
|
||||
updateModelMenuMessage: async () => {
|
||||
calls.push("model");
|
||||
},
|
||||
updateThinkingMenuMessage: async () => {
|
||||
calls.push("thinking");
|
||||
},
|
||||
updateSettingsMenuMessage: async () => {
|
||||
calls.push("settings");
|
||||
},
|
||||
answerCallbackQuery: async (_id: string, text?: string) => {
|
||||
calls.push(text ?? "answered");
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
await handleTelegramStatusMenuCallbackAction(
|
||||
"q1",
|
||||
"status:model",
|
||||
reasoningModel,
|
||||
deps,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await handleTelegramStatusMenuCallbackAction(
|
||||
"q2",
|
||||
"menu:thinking",
|
||||
reasoningModel,
|
||||
deps,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await handleTelegramStatusMenuCallbackAction(
|
||||
"q3",
|
||||
"menu:settings",
|
||||
reasoningModel,
|
||||
deps,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await handleTelegramStatusMenuCallbackAction(
|
||||
"q4",
|
||||
"menu:unknown",
|
||||
reasoningModel,
|
||||
deps,
|
||||
),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
"model",
|
||||
"answered",
|
||||
"thinking",
|
||||
"answered",
|
||||
"settings",
|
||||
"answered",
|
||||
]);
|
||||
|
||||
const voiceCalls: string[] = [];
|
||||
assert.equal(
|
||||
await handleTelegramStatusMenuCallbackAction(
|
||||
"q5",
|
||||
"menu:thinking",
|
||||
reasoningModel,
|
||||
{
|
||||
...deps,
|
||||
isVoiceReplyActive: () => true,
|
||||
answerCallbackQuery: async (_id, text) => {
|
||||
voiceCalls.push(text ?? "");
|
||||
},
|
||||
},
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(voiceCalls, [
|
||||
"Thinking controls are disabled during voice replies.",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Status menu open, send, and update helpers apply status mode", async () => {
|
||||
const state: any = {
|
||||
chatId: 1,
|
||||
threadId: 42,
|
||||
messageId: 2,
|
||||
mode: "model" as const,
|
||||
page: 0,
|
||||
scope: "all",
|
||||
scopedModels: [],
|
||||
allModels: [],
|
||||
};
|
||||
const stored: unknown[] = [];
|
||||
await openTelegramStatusMenu({
|
||||
isIdle: () => true,
|
||||
sendBusyMessage: async () => {},
|
||||
getModelMenuState: async () => state,
|
||||
buildStatusHtml: () => "<b>Status</b>",
|
||||
getActiveModel: () => reasoningModel,
|
||||
getThinkingLevel: () => "medium",
|
||||
getQueueItemCount: () => 1,
|
||||
sendStatusMenu: async (_state, html, _model, thinking, queueCount) => {
|
||||
stored.push({ html, thinking, queueCount });
|
||||
return 99;
|
||||
},
|
||||
storeModelMenuState: (nextState) => stored.push(nextState),
|
||||
});
|
||||
|
||||
assert.equal(state.messageId, 99);
|
||||
assert.equal(state.mode, "status");
|
||||
|
||||
const payload = buildTelegramStatusMenuRenderPayload(
|
||||
"status html",
|
||||
reasoningModel,
|
||||
"medium",
|
||||
1,
|
||||
);
|
||||
assert.equal(payload.nextMode, "status");
|
||||
assert.equal(payload.mode, "html");
|
||||
|
||||
const messages: unknown[] = [];
|
||||
const deps = {
|
||||
editInteractiveMessage: async (...args: unknown[]) => {
|
||||
messages.push(["edit", ...args]);
|
||||
},
|
||||
sendInteractiveMessage: async (...args: unknown[]) => {
|
||||
messages.push(["send", ...args]);
|
||||
return 123;
|
||||
},
|
||||
};
|
||||
|
||||
await updateTelegramStatusMessage(
|
||||
state,
|
||||
"updated",
|
||||
reasoningModel,
|
||||
"medium",
|
||||
deps,
|
||||
0,
|
||||
);
|
||||
const sentId = await sendTelegramStatusMessage(
|
||||
state,
|
||||
"sent",
|
||||
reasoningModel,
|
||||
"medium",
|
||||
deps,
|
||||
0,
|
||||
);
|
||||
|
||||
assert.equal(sentId, 123);
|
||||
assert.equal(state.mode, "status");
|
||||
assert.equal(messages.length, 2);
|
||||
const sentMessage = messages[1] as unknown[];
|
||||
assert.equal(sentMessage[0], "send");
|
||||
assert.equal(sentMessage[1], 1);
|
||||
assert.equal(sentMessage[3], "html");
|
||||
assert.deepEqual(sentMessage[5], { target: { chatId: 1, threadId: 42 } });
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Regression tests for Telegram thinking menu helpers
|
||||
* Exercises thinking-menu markup, callback routing, voice guards, and send/update helpers
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildTelegramThinkingMenuRenderPayload,
|
||||
buildThinkingMenuReplyMarkup,
|
||||
buildThinkingMenuText,
|
||||
handleTelegramThinkingMenuCallbackAction,
|
||||
openTelegramThinkingMenu,
|
||||
updateTelegramThinkingMenuMessage,
|
||||
} from "../lib/menu-thinking.ts";
|
||||
|
||||
const reasoningModel = {
|
||||
provider: "openai",
|
||||
id: "gpt-5",
|
||||
reasoning: true,
|
||||
};
|
||||
|
||||
test("Thinking menu text and reply markup expose all levels with current marker", () => {
|
||||
assert.equal(buildThinkingMenuText(), "<b>🧠 Choose a thinking level:</b>");
|
||||
|
||||
const markup = buildThinkingMenuReplyMarkup("medium");
|
||||
assert.equal(markup.inline_keyboard[0]?.[0]?.text, "⬆️ Main menu");
|
||||
assert.equal(markup.inline_keyboard[0]?.[0]?.callback_data, "menu:back");
|
||||
assert.equal(
|
||||
markup.inline_keyboard.some((row) => row[0]?.text === "🟢 medium"),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
markup.inline_keyboard.every(
|
||||
(row, index) =>
|
||||
index === 0 || row[0]?.callback_data.startsWith("thinking:set:"),
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("Thinking callback sets valid levels and reports current level", async () => {
|
||||
const calls: string[] = [];
|
||||
let current = "low" as const;
|
||||
|
||||
const handled = await handleTelegramThinkingMenuCallbackAction(
|
||||
"q1",
|
||||
"thinking:set:high",
|
||||
reasoningModel,
|
||||
{
|
||||
setThinkingLevel: (level) => {
|
||||
current = level as typeof current;
|
||||
calls.push(`set:${level}`);
|
||||
},
|
||||
getCurrentThinkingLevel: () => current,
|
||||
updateStatusMessage: async () => {
|
||||
calls.push("update-status");
|
||||
},
|
||||
answerCallbackQuery: async (_id, text) => {
|
||||
calls.push(text ?? "answered");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(calls, ["set:high", "update-status", "Thinking: high"]);
|
||||
});
|
||||
|
||||
test("Thinking callback handles invalid, voice-active, non-reasoning, and unrelated actions", async () => {
|
||||
const answered: string[] = [];
|
||||
const deps = {
|
||||
setThinkingLevel: () => {
|
||||
throw new Error("must not set level");
|
||||
},
|
||||
getCurrentThinkingLevel: () => "off" as const,
|
||||
updateStatusMessage: async () => {
|
||||
throw new Error("must not update status");
|
||||
},
|
||||
answerCallbackQuery: async (_id: string, text?: string) => {
|
||||
answered.push(text ?? "answered");
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
await handleTelegramThinkingMenuCallbackAction(
|
||||
"q1",
|
||||
"thinking:set:nope",
|
||||
reasoningModel,
|
||||
deps,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await handleTelegramThinkingMenuCallbackAction(
|
||||
"q2",
|
||||
"thinking:set:low",
|
||||
reasoningModel,
|
||||
{ ...deps, isVoiceReplyActive: () => true },
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await handleTelegramThinkingMenuCallbackAction(
|
||||
"q3",
|
||||
"thinking:set:low",
|
||||
{ provider: "x", id: "plain" },
|
||||
deps,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await handleTelegramThinkingMenuCallbackAction(
|
||||
"q4",
|
||||
"menu:model",
|
||||
reasoningModel,
|
||||
deps,
|
||||
),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.deepEqual(answered, [
|
||||
"Invalid thinking level.",
|
||||
"Thinking controls are disabled during voice replies.",
|
||||
"This model has no reasoning controls.",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Thinking menu open and update helpers apply thinking mode and respect voice guard", async () => {
|
||||
const state: any = {
|
||||
chatId: 1,
|
||||
messageId: 2,
|
||||
mode: "status",
|
||||
page: 0,
|
||||
scope: "all",
|
||||
scopedModels: [],
|
||||
allModels: [],
|
||||
};
|
||||
const messages: unknown[] = [];
|
||||
const deps = {
|
||||
getModelMenuState: async () => state,
|
||||
getActiveModel: () => reasoningModel,
|
||||
getThinkingLevel: () => "medium" as const,
|
||||
storeModelMenuState: (nextState: unknown) =>
|
||||
messages.push(["store", nextState]),
|
||||
editInteractiveMessage: async (...args: unknown[]) => {
|
||||
messages.push(["edit", ...args]);
|
||||
},
|
||||
sendInteractiveMessage: async (...args: unknown[]) => {
|
||||
messages.push(["send", ...args]);
|
||||
return 99;
|
||||
},
|
||||
};
|
||||
|
||||
await openTelegramThinkingMenu(deps);
|
||||
assert.equal(state.messageId, 99);
|
||||
assert.equal(state.mode, "thinking");
|
||||
|
||||
await updateTelegramThinkingMenuMessage(state, reasoningModel, "high", deps);
|
||||
assert.equal(state.mode, "thinking");
|
||||
assert.equal(messages.length, 3);
|
||||
|
||||
const beforeVoiceGuard = messages.length;
|
||||
await openTelegramThinkingMenu({ ...deps, isVoiceReplyActive: () => true });
|
||||
await updateTelegramThinkingMenuMessage(state, reasoningModel, "low", {
|
||||
...deps,
|
||||
isVoiceReplyActive: () => true,
|
||||
});
|
||||
assert.equal(messages.length, beforeVoiceGuard);
|
||||
|
||||
const payload = buildTelegramThinkingMenuRenderPayload(reasoningModel, "low");
|
||||
assert.equal(payload.nextMode, "thinking");
|
||||
assert.equal(payload.mode, "html");
|
||||
});
|
||||
+2396
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,486 @@
|
||||
/**
|
||||
* Regression tests for Telegram model control helpers
|
||||
* Exercises model selection state/resolution plus in-flight model-switch orchestration
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildTelegramModelSwitchContinuationText,
|
||||
buildTelegramModelSwitchContinuationTurn,
|
||||
canRestartTelegramTurnForModelSwitch,
|
||||
createCurrentModelRuntime,
|
||||
createCurrentModelStore,
|
||||
createCurrentModelUpdateRuntime,
|
||||
createPendingModelSwitchStore,
|
||||
createTelegramModelSwitchContinuationQueue,
|
||||
createTelegramModelSwitchContinuationQueueRuntime,
|
||||
createTelegramModelSwitchContinuationTurnBuilder,
|
||||
createTelegramModelSwitchController,
|
||||
createTelegramModelSwitchControllerRuntime,
|
||||
getCanonicalModelId,
|
||||
isThinkingLevel,
|
||||
type MenuModel,
|
||||
modelsMatch,
|
||||
parseTelegramCliScopedModelPatterns,
|
||||
parseTelegramScopedModelPatternList,
|
||||
resolveScopedModelPatterns,
|
||||
restartTelegramModelSwitchContinuation,
|
||||
shouldTriggerPendingTelegramModelSwitchAbort,
|
||||
sortScopedModels,
|
||||
THINKING_LEVELS,
|
||||
} from "../lib/model.ts";
|
||||
import type { PendingTelegramTurn } from "../lib/queue.ts";
|
||||
|
||||
function createModelTestModel(
|
||||
provider = "openai",
|
||||
id = "gpt-5",
|
||||
extra: Partial<MenuModel> = {},
|
||||
): MenuModel {
|
||||
return { provider, id, ...extra };
|
||||
}
|
||||
|
||||
function createModelTestSelection(
|
||||
model = createModelTestModel(),
|
||||
thinkingLevel?: "high",
|
||||
): { model: MenuModel; thinkingLevel?: "high" } {
|
||||
return thinkingLevel === undefined ? { model } : { model, thinkingLevel };
|
||||
}
|
||||
|
||||
function createModelTestTurn(
|
||||
overrides: Partial<PendingTelegramTurn> = {},
|
||||
): PendingTelegramTurn {
|
||||
return {
|
||||
kind: "prompt",
|
||||
chatId: 7,
|
||||
replyToMessageId: 8,
|
||||
sourceMessageIds: [8],
|
||||
queueOrder: 0,
|
||||
queueLane: "default",
|
||||
laneOrder: 0,
|
||||
queuedAttachments: [],
|
||||
content: [],
|
||||
historyText: "active",
|
||||
statusSummary: "active",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("Current model store prefers explicit Telegram model over context fallback", () => {
|
||||
const fallback = { provider: "fallback", id: "f", name: "Fallback" };
|
||||
const selected = { provider: "selected", id: "s", name: "Selected" };
|
||||
const store = createCurrentModelStore<
|
||||
{ model?: typeof fallback },
|
||||
typeof fallback
|
||||
>((ctx) => ctx.model);
|
||||
assert.equal(store.get({ model: fallback }), fallback);
|
||||
assert.equal(store.getStored(), undefined);
|
||||
store.set(selected);
|
||||
assert.equal(store.get({ model: fallback }), selected);
|
||||
assert.equal(store.getStored(), selected);
|
||||
store.set(undefined);
|
||||
assert.equal(store.get({ model: fallback }), fallback);
|
||||
});
|
||||
|
||||
test("Current model runtime combines store fallback and status updates", () => {
|
||||
const fallback = { provider: "fallback", id: "f", name: "Fallback" };
|
||||
const selected = { provider: "selected", id: "s", name: "Selected" };
|
||||
const statuses: string[] = [];
|
||||
const runtime = createCurrentModelRuntime<
|
||||
{ id: string; model?: typeof fallback },
|
||||
typeof fallback
|
||||
>({
|
||||
getContextModel: (ctx) => ctx.model,
|
||||
updateStatus: (ctx) => {
|
||||
statuses.push(ctx.id);
|
||||
},
|
||||
});
|
||||
assert.equal(runtime.get({ id: "fallback", model: fallback }), fallback);
|
||||
runtime.setCurrentModel(selected, { id: "direct" });
|
||||
assert.equal(runtime.get({ id: "fallback", model: fallback }), selected);
|
||||
runtime.onModelSelect({ model: undefined }, { id: "event" });
|
||||
assert.equal(runtime.get({ id: "fallback", model: fallback }), fallback);
|
||||
assert.deepEqual(statuses, ["direct", "event"]);
|
||||
});
|
||||
|
||||
test("Current model update runtime stores selections and refreshes status", () => {
|
||||
const selected = { provider: "selected", id: "s", name: "Selected" };
|
||||
let stored: typeof selected | undefined;
|
||||
const statuses: string[] = [];
|
||||
const runtime = createCurrentModelUpdateRuntime<
|
||||
{ id: string },
|
||||
typeof selected
|
||||
>({
|
||||
setCurrentModel: (model) => {
|
||||
stored = model;
|
||||
},
|
||||
updateStatus: (ctx) => {
|
||||
statuses.push(ctx.id);
|
||||
},
|
||||
});
|
||||
runtime.setCurrentModel(selected, { id: "direct" });
|
||||
assert.equal(stored, selected);
|
||||
runtime.onModelSelect({ model: selected }, { id: "event" });
|
||||
assert.deepEqual(statuses, ["direct", "event"]);
|
||||
});
|
||||
|
||||
test("Model helpers match models, detect thinking levels, and expose constants", () => {
|
||||
assert.deepEqual(THINKING_LEVELS, [
|
||||
"off",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
]);
|
||||
assert.equal(
|
||||
modelsMatch(createModelTestModel(), createModelTestModel()),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
modelsMatch(
|
||||
createModelTestModel(),
|
||||
createModelTestModel("anthropic", "gpt-5"),
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(getCanonicalModelId(createModelTestModel()), "openai/gpt-5");
|
||||
assert.equal(isThinkingLevel("high"), true);
|
||||
assert.equal(isThinkingLevel("impossible"), false);
|
||||
});
|
||||
|
||||
test("Model helpers parse scoped model pattern lists and CLI flags", () => {
|
||||
assert.deepEqual(parseTelegramScopedModelPatternList("a, b:high, "), [
|
||||
"a",
|
||||
"b:high",
|
||||
]);
|
||||
assert.deepEqual(
|
||||
parseTelegramCliScopedModelPatterns(["--models", "a,b:high"]),
|
||||
["a", "b:high"],
|
||||
);
|
||||
assert.deepEqual(parseTelegramCliScopedModelPatterns(["--models=a, b "]), [
|
||||
"a",
|
||||
"b",
|
||||
]);
|
||||
assert.equal(
|
||||
parseTelegramCliScopedModelPatterns(["--models", " "]),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
parseTelegramCliScopedModelPatterns(["--other", "a"]),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("Model helpers resolve scoped model patterns and sort current models first", () => {
|
||||
const models: MenuModel[] = [
|
||||
createModelTestModel("openai", "gpt-5", { name: "GPT 5" }),
|
||||
createModelTestModel("openai", "gpt-5-latest", { name: "GPT 5 Latest" }),
|
||||
createModelTestModel("anthropic", "claude-sonnet-20250101", {
|
||||
name: "Claude Sonnet",
|
||||
}),
|
||||
];
|
||||
const resolved = resolveScopedModelPatterns(
|
||||
["gpt-5:high", "anthropic/*:low"],
|
||||
models,
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolved.map((entry) => ({
|
||||
id: entry.model.id,
|
||||
thinking: entry.thinkingLevel,
|
||||
})),
|
||||
[
|
||||
{ id: "gpt-5", thinking: "high" },
|
||||
{ id: "claude-sonnet-20250101", thinking: "low" },
|
||||
],
|
||||
);
|
||||
const sorted = sortScopedModels(resolved, models[0]);
|
||||
assert.equal(sorted[0]?.model.id, "gpt-5");
|
||||
});
|
||||
|
||||
test("Pending model-switch store owns selection state helpers", () => {
|
||||
const store = createPendingModelSwitchStore<{ id: string }>();
|
||||
assert.equal(store.has(), false);
|
||||
assert.equal(store.get(), undefined);
|
||||
store.set({ id: "next" });
|
||||
assert.equal(store.has(), true);
|
||||
assert.deepEqual(store.get(), { id: "next" });
|
||||
store.clear();
|
||||
assert.equal(store.has(), false);
|
||||
});
|
||||
|
||||
test("In-flight model switch is allowed only for active Telegram turns with abort support", () => {
|
||||
assert.equal(
|
||||
canRestartTelegramTurnForModelSwitch({
|
||||
isIdle: false,
|
||||
hasActiveTelegramTurn: true,
|
||||
hasAbortHandler: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
canRestartTelegramTurnForModelSwitch({
|
||||
isIdle: true,
|
||||
hasActiveTelegramTurn: true,
|
||||
hasAbortHandler: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
canRestartTelegramTurnForModelSwitch({
|
||||
isIdle: false,
|
||||
hasActiveTelegramTurn: false,
|
||||
hasAbortHandler: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
canRestartTelegramTurnForModelSwitch({
|
||||
isIdle: false,
|
||||
hasActiveTelegramTurn: true,
|
||||
hasAbortHandler: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("Pending model switch abort waits until no tool executions remain", () => {
|
||||
assert.equal(
|
||||
shouldTriggerPendingTelegramModelSwitchAbort({
|
||||
hasPendingModelSwitch: true,
|
||||
hasActiveTelegramTurn: true,
|
||||
hasAbortHandler: true,
|
||||
activeToolExecutions: 0,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldTriggerPendingTelegramModelSwitchAbort({
|
||||
hasPendingModelSwitch: true,
|
||||
hasActiveTelegramTurn: true,
|
||||
hasAbortHandler: true,
|
||||
activeToolExecutions: 1,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldTriggerPendingTelegramModelSwitchAbort({
|
||||
hasPendingModelSwitch: false,
|
||||
hasActiveTelegramTurn: true,
|
||||
hasAbortHandler: true,
|
||||
activeToolExecutions: 0,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("Model-switch continuation restart queues before abort when state is present", () => {
|
||||
const events: string[] = [];
|
||||
assert.equal(
|
||||
restartTelegramModelSwitchContinuation({
|
||||
activeTurn: { id: 1 },
|
||||
abort: () => {
|
||||
events.push("abort");
|
||||
},
|
||||
selection: createModelTestSelection(),
|
||||
queueContinuation: (turn, selection) => {
|
||||
events.push(`queue:${turn.id}:${selection.model.id}`);
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(events, ["queue:1:gpt-5", "abort"]);
|
||||
assert.equal(
|
||||
restartTelegramModelSwitchContinuation({
|
||||
activeTurn: undefined,
|
||||
abort: () => {},
|
||||
selection: createModelTestSelection(),
|
||||
queueContinuation: () => {
|
||||
events.push("unexpected");
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("Model-switch controller centralizes pending abort and continuation queueing", () => {
|
||||
const events: string[] = [];
|
||||
let pendingSelection: { model: { provider: string; id: string } } | undefined;
|
||||
const activeTurn = createModelTestTurn({
|
||||
chatId: 1,
|
||||
replyToMessageId: 2,
|
||||
sourceMessageIds: [2],
|
||||
queueOrder: 3,
|
||||
laneOrder: 3,
|
||||
historyText: "previous",
|
||||
statusSummary: "previous",
|
||||
});
|
||||
const controller = createTelegramModelSwitchController({
|
||||
isIdle: () => false,
|
||||
getPendingModelSwitch: () => pendingSelection,
|
||||
setPendingModelSwitch: (selection) => {
|
||||
pendingSelection = selection;
|
||||
},
|
||||
getActiveTurn: () => activeTurn,
|
||||
getAbortHandler: () => {
|
||||
return () => {
|
||||
events.push("abort");
|
||||
};
|
||||
},
|
||||
hasAbortHandler: () => true,
|
||||
getActiveToolExecutions: () => 0,
|
||||
queueContinuation: (turn, selection) => {
|
||||
events.push(`queue:${turn.replyToMessageId}:${selection.model.id}`);
|
||||
},
|
||||
updateStatus: () => {
|
||||
events.push("status");
|
||||
},
|
||||
});
|
||||
assert.equal(controller.canOfferInFlightSwitch({}), true);
|
||||
controller.stagePendingSwitch(createModelTestSelection(), {});
|
||||
assert.deepEqual(events, ["status"]);
|
||||
assert.equal(controller.triggerPendingAbort({}), true);
|
||||
assert.deepEqual(events, ["status", "queue:2:gpt-5", "abort"]);
|
||||
assert.equal(pendingSelection, undefined);
|
||||
});
|
||||
|
||||
test("Model-switch controller runtime binds continuation queue construction", () => {
|
||||
const appended: PendingTelegramTurn[] = [];
|
||||
let pending: { model: { provider: string; id: string } } | undefined =
|
||||
createModelTestSelection();
|
||||
let aborted = false;
|
||||
const activeTurn = createModelTestTurn();
|
||||
const controller = createTelegramModelSwitchControllerRuntime({
|
||||
isIdle: () => false,
|
||||
getPendingModelSwitch: () => pending,
|
||||
setPendingModelSwitch: (selection) => {
|
||||
pending = selection;
|
||||
},
|
||||
getActiveTurn: () => activeTurn,
|
||||
getAbortHandler: () => {
|
||||
return () => {
|
||||
aborted = true;
|
||||
};
|
||||
},
|
||||
hasAbortHandler: () => true,
|
||||
getActiveToolExecutions: () => 0,
|
||||
telegramPrefix: "[telegram]",
|
||||
allocateItemOrder: () => 5,
|
||||
allocateControlOrder: () => 6,
|
||||
appendQueuedItem: (item) => {
|
||||
appended.push(item);
|
||||
},
|
||||
updateStatus: () => {},
|
||||
});
|
||||
assert.equal(controller.triggerPendingAbort(undefined), true);
|
||||
assert.equal(aborted, true);
|
||||
assert.equal(pending, undefined);
|
||||
assert.equal(appended[0]?.queueOrder, 5);
|
||||
assert.equal(appended[0]?.queueLane, "control");
|
||||
});
|
||||
|
||||
test("Model-switch continuation turn stays control-lane and resume-oriented", () => {
|
||||
const createContinuationTurn =
|
||||
createTelegramModelSwitchContinuationTurnBuilder({
|
||||
allocateItemOrder: () => 5,
|
||||
allocateControlOrder: () => 6,
|
||||
});
|
||||
const builtTurn = createContinuationTurn({
|
||||
turn: { chatId: 1, replyToMessageId: 2 },
|
||||
selection: createModelTestSelection(),
|
||||
});
|
||||
assert.equal(builtTurn.queueOrder, 5);
|
||||
assert.equal(builtTurn.laneOrder, 6);
|
||||
const sourceTarget = { chatId: 1, threadId: 42 };
|
||||
const turn = buildTelegramModelSwitchContinuationTurn({
|
||||
turn: { chatId: 1, replyToMessageId: 2, target: sourceTarget },
|
||||
selection: createModelTestSelection(createModelTestModel(), "high"),
|
||||
telegramPrefix: "[telegram]",
|
||||
queueOrder: 3,
|
||||
laneOrder: 4,
|
||||
});
|
||||
assert.equal(turn.kind, "prompt");
|
||||
assert.equal(turn.queueLane, "control");
|
||||
assert.equal(turn.queueOrder, 3);
|
||||
assert.equal(turn.laneOrder, 4);
|
||||
assert.equal(turn.chatId, 1);
|
||||
assert.equal(turn.replyToMessageId, 2);
|
||||
assert.deepEqual(turn.target, { chatId: 1, threadId: 42 });
|
||||
assert.notEqual(turn.target, sourceTarget);
|
||||
assert.deepEqual(turn.sourceMessageIds, []);
|
||||
assert.equal(
|
||||
turn.historyText,
|
||||
"Continue interrupted Telegram request on openai/gpt-5",
|
||||
);
|
||||
assert.equal(turn.statusSummary, "↻ continue on gpt-5");
|
||||
assert.match(String(turn.content[0]?.type), /text/);
|
||||
assert.match(
|
||||
String((turn.content[0] as { text?: string } | undefined)?.text ?? ""),
|
||||
/Continue the interrupted previous Telegram request/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Model-switch continuation queue runtime builds and appends control-lane turns", () => {
|
||||
const appended: PendingTelegramTurn[] = [];
|
||||
const queueContinuation = createTelegramModelSwitchContinuationQueueRuntime({
|
||||
allocateItemOrder: () => 3,
|
||||
allocateControlOrder: () => 4,
|
||||
appendQueuedItem: (item) => {
|
||||
appended.push(item);
|
||||
},
|
||||
});
|
||||
queueContinuation(
|
||||
createModelTestTurn(),
|
||||
createModelTestSelection(),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(appended[0]?.queueOrder, 3);
|
||||
assert.equal(appended[0]?.laneOrder, 4);
|
||||
assert.equal(appended[0]?.queueLane, "control");
|
||||
assert.match(
|
||||
String((appended[0]?.content[0] as { text?: string } | undefined)?.text),
|
||||
/^\[telegram\] Continue/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Model-switch continuation queue creates and appends continuation turns", () => {
|
||||
const appended: Array<{ item: PendingTelegramTurn; ctx: { id: string } }> =
|
||||
[];
|
||||
const queueContinuation = createTelegramModelSwitchContinuationQueue({
|
||||
createContinuationTurn: ({ turn, selection }) => ({
|
||||
...turn,
|
||||
kind: "prompt" as const,
|
||||
queueOrder: 1,
|
||||
queueLane: "control" as const,
|
||||
laneOrder: 2,
|
||||
sourceMessageIds: [],
|
||||
queuedAttachments: [],
|
||||
content: [{ type: "text" as const, text: selection.model.id }],
|
||||
historyText: "continue",
|
||||
statusSummary: "continue",
|
||||
}),
|
||||
appendQueuedItem: (item, ctx: { id: string }) => {
|
||||
appended.push({ item, ctx });
|
||||
},
|
||||
});
|
||||
queueContinuation(createModelTestTurn(), createModelTestSelection(), {
|
||||
id: "ctx",
|
||||
});
|
||||
assert.equal(appended[0]?.item.content[0]?.type, "text");
|
||||
assert.equal(appended[0]?.item.content[0]?.text, "gpt-5");
|
||||
assert.deepEqual(appended[0]?.ctx, { id: "ctx" });
|
||||
});
|
||||
|
||||
test("Continuation prompt stays Telegram-scoped and resume-oriented", () => {
|
||||
const text = buildTelegramModelSwitchContinuationText(
|
||||
"[telegram]",
|
||||
createModelTestModel(),
|
||||
"high",
|
||||
);
|
||||
assert.match(text, /^\[telegram\]/);
|
||||
assert.match(text, /Continue the interrupted previous Telegram request/);
|
||||
assert.match(text, /openai\/gpt-5/);
|
||||
assert.match(text, /thinking level \(high\)/);
|
||||
});
|
||||
@@ -0,0 +1,833 @@
|
||||
/**
|
||||
* Regression tests for the Telegram outbound attachments domain
|
||||
* Covers outbound attachment queueing and delivery behavior in one domain-level suite
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
createTelegramQueuedOutboundAttachmentSender,
|
||||
deliverTelegramGuestCachedAttachment,
|
||||
getTelegramOutboundAttachmentByteLimitFromEnv,
|
||||
queueTelegramOutboundAttachments,
|
||||
registerTelegramOutboundAttachmentTool,
|
||||
registerTelegramOutboundMessageTool,
|
||||
sendQueuedTelegramOutboundAttachments,
|
||||
sendTelegramOutboundFiles,
|
||||
sendTelegramOutboundMessage,
|
||||
TELEGRAM_OUTBOUND_ATTACHMENT_DEFAULT_MAX_BYTES,
|
||||
type TelegramOutboundAttachmentQueueTargetView,
|
||||
type TelegramQueuedOutboundAttachmentTurnView,
|
||||
} from "../lib/outbound-attachments.ts";
|
||||
import type { ExtensionAPI } from "../lib/pi.ts";
|
||||
import { createTelegramThreadTarget } from "../lib/target.ts";
|
||||
|
||||
function createAttachmentQueueTarget(
|
||||
queuedAttachments: TelegramOutboundAttachmentQueueTargetView["queuedAttachments"] = [],
|
||||
): TelegramOutboundAttachmentQueueTargetView {
|
||||
return { queuedAttachments };
|
||||
}
|
||||
|
||||
function createAttachmentTurn(
|
||||
queuedAttachments = [{ path: "/tmp/a.png", fileName: "a.png" }],
|
||||
): TelegramQueuedOutboundAttachmentTurnView {
|
||||
return { chatId: 1, replyToMessageId: 2, queuedAttachments };
|
||||
}
|
||||
|
||||
type RegisteredAttachmentTool = {
|
||||
name?: string;
|
||||
execute: (
|
||||
toolCallId: string,
|
||||
params: {
|
||||
paths: string[];
|
||||
chat_id?: number;
|
||||
thread_id?: number;
|
||||
caption?: string;
|
||||
},
|
||||
) => Promise<{ details: { paths: string[]; chatId?: number } }>;
|
||||
};
|
||||
|
||||
type RegisteredAnyTool = {
|
||||
name?: string;
|
||||
execute: (
|
||||
toolCallId: string,
|
||||
params: Record<string, unknown>,
|
||||
) => Promise<unknown>;
|
||||
};
|
||||
|
||||
test("Outbound attachment byte-limit helpers own the outbound file default", () => {
|
||||
assert.equal(
|
||||
TELEGRAM_OUTBOUND_ATTACHMENT_DEFAULT_MAX_BYTES,
|
||||
50 * 1024 * 1024,
|
||||
);
|
||||
assert.equal(
|
||||
getTelegramOutboundAttachmentByteLimitFromEnv(
|
||||
{ PI_TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES: "12345" },
|
||||
["PI_TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES"],
|
||||
99,
|
||||
),
|
||||
12345,
|
||||
);
|
||||
assert.equal(
|
||||
getTelegramOutboundAttachmentByteLimitFromEnv(
|
||||
{
|
||||
PI_TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES: "0",
|
||||
TELEGRAM_MAX_ATTACHMENT_SIZE_BYTES: "bad",
|
||||
},
|
||||
[
|
||||
"PI_TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES",
|
||||
"TELEGRAM_MAX_ATTACHMENT_SIZE_BYTES",
|
||||
],
|
||||
99,
|
||||
),
|
||||
99,
|
||||
);
|
||||
});
|
||||
|
||||
test("Outbound attachment tool registration delegates queueing", async () => {
|
||||
let tool: RegisteredAttachmentTool | undefined;
|
||||
const api = {
|
||||
registerTool: (definition: RegisteredAttachmentTool) => {
|
||||
tool = definition;
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
const activeTurn = createAttachmentQueueTarget();
|
||||
registerTelegramOutboundAttachmentTool(api, {
|
||||
maxAttachmentsPerTurn: 2,
|
||||
getActiveTurn: () => activeTurn,
|
||||
statPath: async () => ({ isFile: () => true }),
|
||||
});
|
||||
assert.equal(tool?.name, "telegram_attach");
|
||||
assert.ok(tool);
|
||||
const result = await tool.execute("tool-call", { paths: ["/tmp/report.md"] });
|
||||
assert.deepEqual(activeTurn.queuedAttachments, [
|
||||
{ path: "/tmp/report.md", fileName: "report.md" },
|
||||
]);
|
||||
assert.deepEqual(result.details.paths, ["/tmp/report.md"]);
|
||||
});
|
||||
|
||||
test("Outbound attachment tool sends immediately when no Telegram turn is active", async () => {
|
||||
let tool: RegisteredAttachmentTool | undefined;
|
||||
const sent: string[] = [];
|
||||
const api = {
|
||||
registerTool: (definition: RegisteredAttachmentTool) => {
|
||||
tool = definition;
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
registerTelegramOutboundAttachmentTool(api, {
|
||||
maxAttachmentsPerTurn: 2,
|
||||
getActiveTurn: () => undefined,
|
||||
getDefaultChatId: () => 77,
|
||||
canSendDirect: () => true,
|
||||
sendMultipart: async (method, fields, fileField, _filePath, fileName) => {
|
||||
sent.push(
|
||||
`${method}:${fields.chat_id}:${fields.message_thread_id ?? "none"}:${fields.caption}:${fileField}:${fileName}`,
|
||||
);
|
||||
},
|
||||
statPath: async () => ({ isFile: () => true, size: 1 }),
|
||||
});
|
||||
const result = await tool?.execute("tool-call", {
|
||||
paths: ["/tmp/report.md"],
|
||||
caption: "done",
|
||||
});
|
||||
assert.deepEqual(sent, ["sendDocument:77:none:done:document:report.md"]);
|
||||
assert.deepEqual(result?.details, { paths: ["/tmp/report.md"], chatId: 77 });
|
||||
});
|
||||
|
||||
test("Outbound attachment tool sends to assigned thread target by default", async () => {
|
||||
let tool: RegisteredAttachmentTool | undefined;
|
||||
const sent: Array<{ chatId?: string; threadId?: string }> = [];
|
||||
const api = {
|
||||
registerTool: (definition: RegisteredAttachmentTool) => {
|
||||
tool = definition;
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
registerTelegramOutboundAttachmentTool(api, {
|
||||
maxAttachmentsPerTurn: 2,
|
||||
getActiveTurn: () => undefined,
|
||||
getDefaultChatId: () => 7,
|
||||
getDefaultTarget: () => createTelegramThreadTarget(-1007, 42),
|
||||
canSendDirect: () => true,
|
||||
sendMultipart: async (_method, fields) => {
|
||||
sent.push({ chatId: fields.chat_id, threadId: fields.message_thread_id });
|
||||
},
|
||||
statPath: async () => ({ isFile: () => true, size: 1 }),
|
||||
});
|
||||
await tool?.execute("tool-call", { paths: ["/tmp/report.md"] });
|
||||
assert.deepEqual(sent, [{ chatId: "-1007", threadId: "42" }]);
|
||||
});
|
||||
|
||||
test("Outbound attachment tool sends explicit thread target immediately", async () => {
|
||||
let tool: RegisteredAttachmentTool | undefined;
|
||||
const sent: string[] = [];
|
||||
const api = {
|
||||
registerTool: (definition: RegisteredAttachmentTool) => {
|
||||
tool = definition;
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
registerTelegramOutboundAttachmentTool(api, {
|
||||
maxAttachmentsPerTurn: 2,
|
||||
getActiveTurn: () => undefined,
|
||||
canSendDirect: () => true,
|
||||
sendMultipart: async (_method, fields) => {
|
||||
sent.push(`${fields.chat_id}:${fields.message_thread_id}`);
|
||||
},
|
||||
statPath: async () => ({ isFile: () => true, size: 1 }),
|
||||
});
|
||||
await tool?.execute("tool-call", {
|
||||
paths: ["/tmp/report.md"],
|
||||
chat_id: -1007,
|
||||
thread_id: 42,
|
||||
});
|
||||
assert.deepEqual(sent, ["-1007:42"]);
|
||||
});
|
||||
|
||||
test("Outbound message tool sends direct Telegram markdown with parsed buttons", async () => {
|
||||
const tools = new Map<string, RegisteredAnyTool>();
|
||||
const sent: Array<{
|
||||
chatId: number;
|
||||
markdown: string;
|
||||
replyMarkup?: unknown;
|
||||
target?: unknown;
|
||||
}> = [];
|
||||
const api = {
|
||||
registerTool: (definition: RegisteredAnyTool) => {
|
||||
if (definition.name) tools.set(definition.name, definition);
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
registerTelegramOutboundMessageTool(api, {
|
||||
getDefaultChatId: () => 7,
|
||||
canSendDirect: () => true,
|
||||
planMessage: (markdown) => ({
|
||||
markdown: markdown
|
||||
.replace(/<!-- telegram_button: Continue -->/, "")
|
||||
.trim(),
|
||||
replyMarkup: {
|
||||
inline_keyboard: [[{ text: "Continue", callback_data: "button:1" }]],
|
||||
},
|
||||
}),
|
||||
sendMarkdownMessage: async (chatId, markdown, options) => {
|
||||
sent.push({
|
||||
chatId,
|
||||
markdown,
|
||||
replyMarkup: options?.replyMarkup,
|
||||
target: options?.target,
|
||||
});
|
||||
return 9;
|
||||
},
|
||||
});
|
||||
await tools.get("telegram_message")?.execute("tool-call", {
|
||||
text: "**hello**\n\n<!-- telegram_button: Continue -->",
|
||||
});
|
||||
assert.deepEqual(sent, [
|
||||
{
|
||||
chatId: 7,
|
||||
markdown: "**hello**",
|
||||
replyMarkup: {
|
||||
inline_keyboard: [[{ text: "Continue", callback_data: "button:1" }]],
|
||||
},
|
||||
target: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("Outbound message tool sends explicit thread target", async () => {
|
||||
const tools = new Map<string, RegisteredAnyTool>();
|
||||
const sent: Array<{ chatId: number; target?: unknown }> = [];
|
||||
const api = {
|
||||
registerTool: (definition: RegisteredAnyTool) => {
|
||||
if (definition.name) tools.set(definition.name, definition);
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
registerTelegramOutboundMessageTool(api, {
|
||||
getDefaultChatId: () => 7,
|
||||
canSendDirect: () => true,
|
||||
planMessage: (markdown) => ({ markdown }),
|
||||
sendMarkdownMessage: async (chatId, _markdown, options) => {
|
||||
sent.push({ chatId, target: options?.target });
|
||||
return 9;
|
||||
},
|
||||
});
|
||||
await tools.get("telegram_message")?.execute("tool-call", {
|
||||
text: "hello",
|
||||
chat_id: -1007,
|
||||
thread_id: 42,
|
||||
});
|
||||
assert.deepEqual(sent, [
|
||||
{ chatId: -1007, target: { chatId: -1007, threadId: 42 } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("Direct outbound message carries internal thread target", async () => {
|
||||
const target = createTelegramThreadTarget(-1007, 42);
|
||||
const sent: Array<{ chatId: number; target?: unknown }> = [];
|
||||
await sendTelegramOutboundMessage({
|
||||
text: "hello",
|
||||
chatId: -1007,
|
||||
target,
|
||||
canSendDirect: () => true,
|
||||
planMessage: (markdown) => ({ markdown }),
|
||||
sendMarkdownMessage: async (chatId, _markdown, options) => {
|
||||
sent.push({ chatId, target: options?.target });
|
||||
return 1;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(sent, [{ chatId: -1007, target }]);
|
||||
});
|
||||
|
||||
test("Direct outbound message defaults to assigned thread target", async () => {
|
||||
const target = createTelegramThreadTarget(-1007, 42);
|
||||
const sent: Array<{ chatId: number; target?: unknown }> = [];
|
||||
await sendTelegramOutboundMessage({
|
||||
text: "hello",
|
||||
getDefaultChatId: () => 7,
|
||||
getDefaultTarget: () => target,
|
||||
canSendDirect: () => true,
|
||||
planMessage: (markdown) => ({ markdown }),
|
||||
sendMarkdownMessage: async (chatId, _markdown, options) => {
|
||||
sent.push({ chatId, target: options?.target });
|
||||
return 1;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(sent, [{ chatId: -1007, target }]);
|
||||
});
|
||||
|
||||
test("Direct outbound files carry internal thread target", async () => {
|
||||
const sentFields: Array<Record<string, string>> = [];
|
||||
await sendTelegramOutboundFiles({
|
||||
paths: ["/tmp/report.md"],
|
||||
chatId: -1007,
|
||||
target: createTelegramThreadTarget(-1007, 42),
|
||||
maxAttachmentsPerTurn: 1,
|
||||
canSendDirect: () => true,
|
||||
statPath: async () => ({ isFile: () => true, size: 1 }),
|
||||
sendMultipart: async (_method, fields) => {
|
||||
sentFields.push(fields);
|
||||
},
|
||||
});
|
||||
assert.equal(sentFields[0]?.message_thread_id, "42");
|
||||
});
|
||||
|
||||
test("Direct outbound files accept explicit tool thread target", async () => {
|
||||
const sentFields: Array<Record<string, string>> = [];
|
||||
await sendTelegramOutboundFiles({
|
||||
paths: ["/tmp/report.md"],
|
||||
chatId: -1007,
|
||||
threadId: 42,
|
||||
maxAttachmentsPerTurn: 1,
|
||||
canSendDirect: () => true,
|
||||
statPath: async () => ({ isFile: () => true, size: 1 }),
|
||||
sendMultipart: async (_method, fields) => {
|
||||
sentFields.push(fields);
|
||||
},
|
||||
});
|
||||
assert.equal(sentFields[0]?.message_thread_id, "42");
|
||||
});
|
||||
|
||||
test("Direct Telegram tools require local polling lock ownership", async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
queueTelegramOutboundAttachments({
|
||||
activeTurn: undefined,
|
||||
paths: ["/tmp/report.md"],
|
||||
maxAttachmentsPerTurn: 2,
|
||||
sendMultipart: async () => undefined,
|
||||
getDefaultChatId: () => 77,
|
||||
canSendDirect: () => false,
|
||||
statPath: async () => ({ isFile: () => true, size: 1 }),
|
||||
}),
|
||||
{
|
||||
message:
|
||||
/requires this Pi instance to own \/telegram-connect or be registered/,
|
||||
},
|
||||
);
|
||||
await assert.rejects(() => toolsMessageWithoutOwnership(), {
|
||||
message:
|
||||
/requires this Pi instance to own \/telegram-connect or be registered/,
|
||||
});
|
||||
});
|
||||
|
||||
async function toolsMessageWithoutOwnership(): Promise<unknown> {
|
||||
let tool: RegisteredAnyTool | undefined;
|
||||
const api = {
|
||||
registerTool: (definition: RegisteredAnyTool) => {
|
||||
tool = definition;
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
registerTelegramOutboundMessageTool(api, {
|
||||
getDefaultChatId: () => 7,
|
||||
canSendDirect: () => false,
|
||||
planMessage: (markdown) => ({ markdown }),
|
||||
sendMarkdownMessage: async () => 9,
|
||||
});
|
||||
return tool?.execute("tool-call", { text: "hello" });
|
||||
}
|
||||
|
||||
test("Outbound attachment queueing adds files to the active Telegram turn", async () => {
|
||||
const activeTurn = createAttachmentQueueTarget();
|
||||
const result = await queueTelegramOutboundAttachments({
|
||||
activeTurn,
|
||||
paths: ["/tmp/demo.txt"],
|
||||
maxAttachmentsPerTurn: 2,
|
||||
statPath: async () => ({ isFile: () => true }),
|
||||
});
|
||||
assert.deepEqual(activeTurn.queuedAttachments, [
|
||||
{ path: "/tmp/demo.txt", fileName: "demo.txt" },
|
||||
]);
|
||||
assert.deepEqual(result.details.paths, ["/tmp/demo.txt"]);
|
||||
assert.equal(result.content[0]?.text, "\nQueued 1 Telegram attachment(s).");
|
||||
});
|
||||
|
||||
test("Outbound attachment queueing admits exactly one Guest Mode file", async () => {
|
||||
const activeTurn = {
|
||||
...createAttachmentQueueTarget(),
|
||||
guestQueryId: "guest-query",
|
||||
};
|
||||
await queueTelegramOutboundAttachments({
|
||||
activeTurn,
|
||||
paths: ["/tmp/demo.txt"],
|
||||
maxAttachmentsPerTurn: 2,
|
||||
statPath: async () => ({ isFile: () => true }),
|
||||
});
|
||||
assert.deepEqual(activeTurn.queuedAttachments, [
|
||||
{ path: "/tmp/demo.txt", fileName: "demo.txt" },
|
||||
]);
|
||||
|
||||
let statCalled = false;
|
||||
await assert.rejects(
|
||||
() =>
|
||||
queueTelegramOutboundAttachments({
|
||||
activeTurn,
|
||||
paths: ["/tmp/second.txt"],
|
||||
maxAttachmentsPerTurn: 2,
|
||||
statPath: async () => {
|
||||
statCalled = true;
|
||||
return { isFile: () => true };
|
||||
},
|
||||
}),
|
||||
{
|
||||
message:
|
||||
"Telegram Guest Mode supports one attachment per reply; no attachment was queued",
|
||||
},
|
||||
);
|
||||
assert.equal(statCalled, false);
|
||||
assert.equal(activeTurn.queuedAttachments.length, 1);
|
||||
});
|
||||
|
||||
test("Guest attachment staging builds cached media results and always deletes staging messages", async () => {
|
||||
const cases = [
|
||||
{
|
||||
path: "/tmp/report.txt",
|
||||
response: { message_id: 11, document: { file_id: "doc-id" } },
|
||||
expectedMethod: "sendDocument",
|
||||
expectedResult: {
|
||||
type: "document",
|
||||
id: "attachment-1",
|
||||
title: "report.txt",
|
||||
document_file_id: "doc-id",
|
||||
caption: "caption",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/tmp/photo.jpg",
|
||||
response: {
|
||||
message_id: 12,
|
||||
photo: [
|
||||
{ file_id: "small", file_size: 10 },
|
||||
{ file_id: "large", file_size: 20 },
|
||||
],
|
||||
},
|
||||
expectedMethod: "sendPhoto",
|
||||
expectedResult: {
|
||||
type: "photo",
|
||||
id: "attachment-1",
|
||||
photo_file_id: "large",
|
||||
caption: "caption",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/tmp/audio.mp3",
|
||||
response: { message_id: 13, audio: { file_id: "audio-id" } },
|
||||
expectedMethod: "sendAudio",
|
||||
expectedResult: {
|
||||
type: "audio",
|
||||
id: "attachment-1",
|
||||
audio_file_id: "audio-id",
|
||||
caption: "caption",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/tmp/voice.ogg",
|
||||
response: { message_id: 14, voice: { file_id: "voice-id" } },
|
||||
expectedMethod: "sendVoice",
|
||||
expectedResult: {
|
||||
type: "voice",
|
||||
id: "attachment-1",
|
||||
voice_file_id: "voice-id",
|
||||
title: "voice.ogg",
|
||||
caption: "caption",
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const item of cases) {
|
||||
const events: unknown[] = [];
|
||||
await deliverTelegramGuestCachedAttachment({
|
||||
guestQueryId: "guest-1",
|
||||
stagingChatId: 42,
|
||||
attachment: { path: item.path, fileName: item.path.split("/").at(-1)! },
|
||||
caption: "caption",
|
||||
sendMultipart: async (method, fields, fileField) => {
|
||||
events.push(["upload", method, fields, fileField]);
|
||||
return item.response;
|
||||
},
|
||||
answerGuestQuery: async (guestQueryId, result) => {
|
||||
events.push(["answer", guestQueryId, result]);
|
||||
},
|
||||
deleteMessage: async (chatId, messageId) => {
|
||||
events.push(["delete", chatId, messageId]);
|
||||
},
|
||||
});
|
||||
assert.deepEqual(events, [
|
||||
[
|
||||
"upload",
|
||||
item.expectedMethod,
|
||||
{ chat_id: "42" },
|
||||
item.expectedMethod.replace("send", "").toLowerCase(),
|
||||
],
|
||||
["answer", "guest-1", item.expectedResult],
|
||||
["delete", 42, item.response.message_id],
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test("Guest attachment staging cleans up extraction and answer failures", async () => {
|
||||
for (const phase of ["extract", "answer"] as const) {
|
||||
const events: string[] = [];
|
||||
await assert.rejects(
|
||||
() =>
|
||||
deliverTelegramGuestCachedAttachment({
|
||||
guestQueryId: "guest-1",
|
||||
stagingChatId: 42,
|
||||
attachment: { path: "/tmp/report.txt", fileName: "report.txt" },
|
||||
sendMultipart: async () => ({
|
||||
message_id: 20,
|
||||
...(phase === "answer" ? { document: { file_id: "doc-id" } } : {}),
|
||||
}),
|
||||
answerGuestQuery: async () => {
|
||||
events.push("answer");
|
||||
throw new Error("answer failed");
|
||||
},
|
||||
deleteMessage: async () => {
|
||||
events.push("delete");
|
||||
},
|
||||
}),
|
||||
phase === "extract" ? /no document file_id/ : /answer failed/,
|
||||
);
|
||||
assert.deepEqual(events, phase === "extract" ? ["delete"] : ["answer", "delete"]);
|
||||
}
|
||||
});
|
||||
|
||||
test("Guest attachment staging falls back to one text answer before media answer", async () => {
|
||||
const events: string[] = [];
|
||||
await deliverTelegramGuestCachedAttachment({
|
||||
guestQueryId: "guest-1",
|
||||
stagingChatId: 42,
|
||||
attachment: { path: "/tmp/report.txt", fileName: "report.txt" },
|
||||
fallbackText: "fallback",
|
||||
sendMultipart: async () => ({ message_id: 20 }),
|
||||
answerGuestQuery: async () => {
|
||||
events.push("unexpected:media");
|
||||
},
|
||||
answerGuestText: async (guestQueryId, text) => {
|
||||
events.push(`text:${guestQueryId}:${text}`);
|
||||
},
|
||||
deleteMessage: async () => {
|
||||
events.push("delete");
|
||||
},
|
||||
});
|
||||
assert.deepEqual(events, ["text:guest-1:fallback", "delete"]);
|
||||
});
|
||||
|
||||
test("Guest attachment staging records cleanup failure without retrying the answer", async () => {
|
||||
const events: string[] = [];
|
||||
await deliverTelegramGuestCachedAttachment({
|
||||
guestQueryId: "guest-1",
|
||||
stagingChatId: 42,
|
||||
attachment: { path: "/tmp/report.txt", fileName: "report.txt" },
|
||||
sendMultipart: async () => ({
|
||||
message_id: 20,
|
||||
document: { file_id: "doc-id" },
|
||||
}),
|
||||
answerGuestQuery: async () => {
|
||||
events.push("answer");
|
||||
},
|
||||
deleteMessage: async () => {
|
||||
events.push("delete");
|
||||
throw new Error("cleanup failed");
|
||||
},
|
||||
recordRuntimeEvent: (category, error, details) => {
|
||||
events.push(`${category}:${(error as Error).message}:${details?.phase}`);
|
||||
},
|
||||
});
|
||||
assert.deepEqual(events, [
|
||||
"answer",
|
||||
"delete",
|
||||
"attachment:cleanup failed:guest-staging-cleanup",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Outbound attachment queueing uses the domain stat fallback", async () => {
|
||||
const tempDir = await mkdtemp(
|
||||
join(tmpdir(), "pi-telegram-attachment-queue-"),
|
||||
);
|
||||
const filePath = join(tempDir, "demo.txt");
|
||||
await writeFile(filePath, "demo", "utf8");
|
||||
const activeTurn = createAttachmentQueueTarget();
|
||||
const result = await queueTelegramOutboundAttachments({
|
||||
activeTurn,
|
||||
paths: [filePath],
|
||||
maxAttachmentsPerTurn: 1,
|
||||
});
|
||||
assert.deepEqual(result.details.paths, [filePath]);
|
||||
});
|
||||
|
||||
test("Outbound attachment queueing rejects oversized files", async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
queueTelegramOutboundAttachments({
|
||||
activeTurn: createAttachmentQueueTarget(),
|
||||
paths: ["/tmp/large.bin"],
|
||||
maxAttachmentsPerTurn: 1,
|
||||
maxAttachmentSizeBytes: 10,
|
||||
statPath: async () => ({ isFile: () => true, size: 11 }),
|
||||
}),
|
||||
{
|
||||
message:
|
||||
"Attachment exceeds size limit (11 bytes > 10 bytes): /tmp/large.bin",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("Outbound attachment queueing stays atomic when a later file is rejected", async () => {
|
||||
const activeTurn = createAttachmentQueueTarget();
|
||||
await assert.rejects(
|
||||
() =>
|
||||
queueTelegramOutboundAttachments({
|
||||
activeTurn,
|
||||
paths: ["/tmp/ok.txt", "/tmp/large.bin"],
|
||||
maxAttachmentsPerTurn: 2,
|
||||
maxAttachmentSizeBytes: 10,
|
||||
statPath: async (path) => ({
|
||||
isFile: () => true,
|
||||
size: path.endsWith("large.bin") ? 11 : 1,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
message:
|
||||
"Attachment exceeds size limit (11 bytes > 10 bytes): /tmp/large.bin",
|
||||
},
|
||||
);
|
||||
assert.deepEqual(activeTurn.queuedAttachments, []);
|
||||
});
|
||||
|
||||
test("Outbound attachment queueing rejects missing turns, non-files, and full queues", async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
queueTelegramOutboundAttachments({
|
||||
activeTurn: undefined,
|
||||
paths: ["/tmp/demo.txt"],
|
||||
maxAttachmentsPerTurn: 1,
|
||||
statPath: async () => ({ isFile: () => true }),
|
||||
}),
|
||||
{ message: /active Telegram turn/ },
|
||||
);
|
||||
await assert.rejects(
|
||||
() =>
|
||||
queueTelegramOutboundAttachments({
|
||||
activeTurn: createAttachmentQueueTarget(),
|
||||
paths: ["/tmp/demo.txt"],
|
||||
maxAttachmentsPerTurn: 1,
|
||||
statPath: async () => ({ isFile: () => false }),
|
||||
}),
|
||||
{ message: "Not a file: /tmp/demo.txt" },
|
||||
);
|
||||
await assert.rejects(
|
||||
() =>
|
||||
queueTelegramOutboundAttachments({
|
||||
activeTurn: createAttachmentQueueTarget([
|
||||
{ path: "/tmp/a.txt", fileName: "a.txt" },
|
||||
]),
|
||||
paths: ["/tmp/demo.txt"],
|
||||
maxAttachmentsPerTurn: 1,
|
||||
statPath: async () => ({ isFile: () => true }),
|
||||
}),
|
||||
{ message: "Attachment limit reached (1)" },
|
||||
);
|
||||
});
|
||||
|
||||
test("Outbound attachment delivery includes reply parameters for uploads", async () => {
|
||||
const sentFields: Array<Record<string, string>> = [];
|
||||
await sendQueuedTelegramOutboundAttachments(createAttachmentTurn(), {
|
||||
sendMultipart: async (_method, fields) => {
|
||||
sentFields.push(fields);
|
||||
},
|
||||
sendTextReply: async () => undefined,
|
||||
});
|
||||
assert.deepEqual(sentFields, [
|
||||
{
|
||||
chat_id: "1",
|
||||
reply_parameters: JSON.stringify({
|
||||
message_id: 2,
|
||||
allow_sending_without_reply: true,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("Outbound attachment delivery includes thread target for uploads", async () => {
|
||||
const sentFields: Array<Record<string, string>> = [];
|
||||
await sendQueuedTelegramOutboundAttachments(
|
||||
{
|
||||
...createAttachmentTurn(),
|
||||
target: createTelegramThreadTarget(1, 42),
|
||||
},
|
||||
{
|
||||
sendMultipart: async (_method, fields) => {
|
||||
sentFields.push(fields);
|
||||
},
|
||||
sendTextReply: async () => undefined,
|
||||
},
|
||||
);
|
||||
assert.equal(sentFields[0]?.message_thread_id, "42");
|
||||
});
|
||||
|
||||
test("Outbound attachment delivery chooses photo vs document methods from file paths", async () => {
|
||||
const sent: Array<string> = [];
|
||||
await sendQueuedTelegramOutboundAttachments(
|
||||
createAttachmentTurn([
|
||||
{ path: "/tmp/a.png", fileName: "a.png" },
|
||||
{ path: "/tmp/b.txt", fileName: "b.txt" },
|
||||
]),
|
||||
{
|
||||
sendMultipart: async (
|
||||
method,
|
||||
_fields,
|
||||
fileField,
|
||||
_filePath,
|
||||
fileName,
|
||||
) => {
|
||||
sent.push(`${method}:${fileField}:${fileName}`);
|
||||
},
|
||||
sendTextReply: async () => undefined,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(sent, [
|
||||
"sendPhoto:photo:a.png",
|
||||
"sendDocument:document:b.txt",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Outbound attachment delivery uses the domain stat fallback for size checks", async () => {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "pi-telegram-attachment-"));
|
||||
const filePath = join(tempDir, "large.txt");
|
||||
await writeFile(filePath, "too large", "utf8");
|
||||
const replies: string[] = [];
|
||||
await sendQueuedTelegramOutboundAttachments(
|
||||
createAttachmentTurn([{ path: filePath, fileName: "large.txt" }]),
|
||||
{
|
||||
sendMultipart: async () => {
|
||||
throw new Error("unexpected upload");
|
||||
},
|
||||
sendTextReply: async (_chatId, _replyToMessageId, text) => {
|
||||
replies.push(text);
|
||||
},
|
||||
maxAttachmentSizeBytes: 4,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(replies, [
|
||||
"Failed to send attachment large.txt: Attachment exceeds size limit (9 bytes > 4 bytes)",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Outbound attachment delivery checks attachment sizes before upload", async () => {
|
||||
const replies: string[] = [];
|
||||
const sent: string[] = [];
|
||||
await sendQueuedTelegramOutboundAttachments(createAttachmentTurn(), {
|
||||
maxAttachmentSizeBytes: 10,
|
||||
statPath: async () => ({ size: 11 }),
|
||||
sendMultipart: async () => {
|
||||
sent.push("sent");
|
||||
},
|
||||
sendTextReply: async (_chatId, _replyToMessageId, text) => {
|
||||
replies.push(text);
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(sent, []);
|
||||
assert.deepEqual(replies, [
|
||||
"Failed to send attachment a.png: Attachment exceeds size limit (11 bytes > 10 bytes)",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Outbound attachment delivery reports per-file failures via text replies", async () => {
|
||||
const replies: Array<{ text: string; target?: unknown }> = [];
|
||||
const runtimeEvents: string[] = [];
|
||||
const target = createTelegramThreadTarget(1, 42);
|
||||
await sendQueuedTelegramOutboundAttachments(
|
||||
{ ...createAttachmentTurn(), target },
|
||||
{
|
||||
sendMultipart: async () => {
|
||||
throw new Error("upload failed");
|
||||
},
|
||||
sendTextReply: async (_chatId, _replyToMessageId, text, options) => {
|
||||
replies.push({ text, target: options?.target });
|
||||
return undefined;
|
||||
},
|
||||
recordRuntimeEvent: (category, error, details) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
runtimeEvents.push(`${category}:${message}:${details?.fileName}`);
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.deepEqual(replies, [
|
||||
{ text: "Failed to send attachment a.png: upload failed", target },
|
||||
]);
|
||||
assert.deepEqual(runtimeEvents, ["attachment:upload failed:a.png"]);
|
||||
});
|
||||
|
||||
test("Outbound attachment sender runtime binds delivery ports", async () => {
|
||||
const sent: string[] = [];
|
||||
const sendQueuedAttachments = createTelegramQueuedOutboundAttachmentSender({
|
||||
sendMultipart: async (method, _fields, fileField, _filePath, fileName) => {
|
||||
sent.push(`${method}:${fileField}:${fileName}`);
|
||||
},
|
||||
sendTextReply: async () => undefined,
|
||||
statPath: async () => ({ size: 1 }),
|
||||
});
|
||||
await sendQueuedAttachments(createAttachmentTurn());
|
||||
assert.deepEqual(sent, ["sendPhoto:photo:a.png"]);
|
||||
});
|
||||
|
||||
test("Outbound attachment sender runtime applies the default outbound size limit", async () => {
|
||||
const replies: string[] = [];
|
||||
const sendQueuedAttachments = createTelegramQueuedOutboundAttachmentSender({
|
||||
sendMultipart: async () => {
|
||||
throw new Error("unexpected upload");
|
||||
},
|
||||
sendTextReply: async (_chatId, _replyToMessageId, text) => {
|
||||
replies.push(text);
|
||||
},
|
||||
statPath: async () => ({
|
||||
size: TELEGRAM_OUTBOUND_ATTACHMENT_DEFAULT_MAX_BYTES + 1,
|
||||
}),
|
||||
});
|
||||
await sendQueuedAttachments(createAttachmentTurn());
|
||||
assert.deepEqual(replies, [
|
||||
`Failed to send attachment a.png: Attachment exceeds size limit (${TELEGRAM_OUTBOUND_ATTACHMENT_DEFAULT_MAX_BYTES + 1} bytes > ${TELEGRAM_OUTBOUND_ATTACHMENT_DEFAULT_MAX_BYTES} bytes)`,
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Regression tests for Telegram outbound button helpers
|
||||
* Exercises assistant-authored button markup extraction, action storage, callback handling, and prompt-turn construction
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
createTelegramButtonActionStore,
|
||||
createTelegramButtonPromptTurn,
|
||||
handleTelegramButtonCallbackQuery,
|
||||
planTelegramButtonReply,
|
||||
} from "../lib/outbound-buttons.ts";
|
||||
|
||||
test("Button reply planner strips telegram_button markup and registers actions", () => {
|
||||
const actions: unknown[] = [];
|
||||
const plan = planTelegramButtonReply(
|
||||
[
|
||||
"Visible answer.",
|
||||
"",
|
||||
'<!-- telegram_button label="Run" -->',
|
||||
"Run the workflow.",
|
||||
"-->",
|
||||
"",
|
||||
"Tail.",
|
||||
].join("\n"),
|
||||
{
|
||||
registerAction: (action) => {
|
||||
actions.push(action);
|
||||
return `btn:${actions.length}`;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(plan.markdown, "Visible answer.\n\nTail.");
|
||||
assert.deepEqual(actions, [{ text: "Run", prompt: "Run the workflow." }]);
|
||||
assert.deepEqual(plan.replyMarkup, {
|
||||
inline_keyboard: [[{ text: "Run", callback_data: "btn:1" }]],
|
||||
});
|
||||
});
|
||||
|
||||
test("Button action store resolves registered actions once and expires old entries", () => {
|
||||
const store = createTelegramButtonActionStore();
|
||||
const callbackData = store.register({ text: "Run", prompt: "Do it." });
|
||||
|
||||
assert.deepEqual(store.resolve(callbackData), {
|
||||
text: "Run",
|
||||
prompt: "Do it.",
|
||||
});
|
||||
assert.equal(store.resolve(callbackData), undefined);
|
||||
assert.equal(store.resolve("other:callback"), undefined);
|
||||
|
||||
const expiringStore = createTelegramButtonActionStore({ ttlMs: -1 });
|
||||
const expiredCallbackData = expiringStore.register({
|
||||
text: "Expired",
|
||||
prompt: "Too late.",
|
||||
});
|
||||
assert.equal(expiringStore.resolve(expiredCallbackData), undefined);
|
||||
});
|
||||
|
||||
test("Button prompt turn preserves prompt text and queue metadata", () => {
|
||||
const turn = createTelegramButtonPromptTurn({
|
||||
chatId: 10,
|
||||
replyToMessageId: 20,
|
||||
queueOrder: 30,
|
||||
action: { text: "Run", prompt: "Run this now." },
|
||||
target: { chatId: 10, threadId: 40 },
|
||||
});
|
||||
|
||||
assert.equal(turn.kind, "prompt");
|
||||
assert.equal(turn.chatId, 10);
|
||||
assert.deepEqual(turn.target, { chatId: 10, threadId: 40 });
|
||||
assert.equal(turn.replyToMessageId, 20);
|
||||
assert.equal(turn.queueLane, "priority");
|
||||
assert.deepEqual(turn.sourceMessageIds, [20]);
|
||||
assert.deepEqual(turn.content, [
|
||||
{ type: "text", text: "[telegram] Run this now." },
|
||||
]);
|
||||
assert.equal(turn.historyText, "Run this now.");
|
||||
assert.equal(turn.statusSummary, "Run");
|
||||
});
|
||||
|
||||
test("Button callback handler enqueues owned actions and consumes expired buttons", async () => {
|
||||
const answered: string[] = [];
|
||||
const enqueued: unknown[] = [];
|
||||
const handled = await handleTelegramButtonCallbackQuery(
|
||||
{
|
||||
id: "q1",
|
||||
data: "tgbtn:live",
|
||||
message: { message_id: 2, chat: { id: 1 } },
|
||||
},
|
||||
"ctx",
|
||||
{
|
||||
resolveAction: () => ({ text: "Run", prompt: "Run it." }),
|
||||
answerCallbackQuery: async (_id, text) => {
|
||||
answered.push(text ?? "");
|
||||
},
|
||||
enqueueButtonPrompt: (query, action, ctx) => {
|
||||
enqueued.push({ query, action, ctx });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(answered, ["Queued."]);
|
||||
assert.equal(enqueued.length, 1);
|
||||
|
||||
const expired = await handleTelegramButtonCallbackQuery(
|
||||
{ id: "q2", data: "tgbtn:expired" },
|
||||
"ctx",
|
||||
{
|
||||
resolveAction: () => undefined,
|
||||
answerCallbackQuery: async (_id, text) => {
|
||||
answered.push(text ?? "");
|
||||
},
|
||||
enqueueButtonPrompt: () => {
|
||||
throw new Error("must not enqueue expired buttons");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(expired, true);
|
||||
assert.deepEqual(answered, ["Queued.", "Button action expired."]);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Regression tests for Telegram outbound markup helpers
|
||||
* Exercises top-level assistant action comment parsing, stripping, and voice reply planning
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
collectTopLevelHtmlComments,
|
||||
parseTelegramCommentAttributes,
|
||||
planTelegramVoiceReply,
|
||||
stripTelegramCommentMarkupForDelivery,
|
||||
stripTelegramCommentMarkupForPreview,
|
||||
} from "../lib/outbound-markup.ts";
|
||||
|
||||
test("Markup collector ignores comments inside fenced code", () => {
|
||||
const markdown = [
|
||||
"```",
|
||||
"<!-- telegram_voice: literal -->",
|
||||
"```",
|
||||
"",
|
||||
"<!-- telegram_voice: real -->",
|
||||
].join("\n");
|
||||
|
||||
const { comments } = collectTopLevelHtmlComments(markdown);
|
||||
|
||||
assert.equal(comments.length, 1);
|
||||
assert.equal(comments[0]?.content.trim(), "telegram_voice: real");
|
||||
});
|
||||
|
||||
test("Markup stripping removes closed and partial top-level comments", () => {
|
||||
assert.equal(
|
||||
stripTelegramCommentMarkupForDelivery(
|
||||
"Visible\n\n<!-- telegram_button: Hidden -->\n\nTail",
|
||||
),
|
||||
"Visible\n\nTail",
|
||||
);
|
||||
assert.equal(
|
||||
stripTelegramCommentMarkupForPreview("Visible\n\n<!-- telegram_voice"),
|
||||
"Visible",
|
||||
);
|
||||
});
|
||||
|
||||
test("Comment attribute parser supports quoted and unquoted values", () => {
|
||||
assert.deepEqual(
|
||||
parseTelegramCommentAttributes(
|
||||
'label="Run now" prompt=continue lang=ru rate=+20%',
|
||||
),
|
||||
{
|
||||
label: "Run now",
|
||||
prompt: "continue",
|
||||
lang: "ru",
|
||||
rate: "+20%",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("Voice reply planner extracts multiple voice replies and cleans markdown", () => {
|
||||
const plan = planTelegramVoiceReply(
|
||||
[
|
||||
"Visible answer.",
|
||||
"",
|
||||
"<!-- telegram_voice lang=ru rate=+20% -->",
|
||||
"Первый ответ.",
|
||||
"-->",
|
||||
"",
|
||||
'<!-- telegram_voice lang=en text="Second answer." -->',
|
||||
"",
|
||||
"Tail.",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
assert.equal(plan.markdown, "Visible answer.\n\nTail.");
|
||||
assert.equal(plan.voiceText, "Первый ответ.\n\nSecond answer.");
|
||||
assert.deepEqual(plan.voiceReplies, [
|
||||
{ text: "Первый ответ.", lang: "ru", rate: "+20%" },
|
||||
{ text: "Second answer.", lang: "en" },
|
||||
]);
|
||||
assert.equal(plan.lang, "en");
|
||||
assert.equal(plan.rate, "+20%");
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Regression tests for Telegram outbound voice delivery helpers
|
||||
* Exercises direct voice-sender ownership after extraction from outbound.ts
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { createTelegramVoiceReplySender } from "../lib/outbound-voice.ts";
|
||||
import { createTelegramThreadTarget } from "../lib/target.ts";
|
||||
import {
|
||||
clearTelegramVoiceSynthesisProviders,
|
||||
registerTelegramVoiceSynthesisProvider,
|
||||
} from "../lib/voice.ts";
|
||||
|
||||
test.beforeEach(() => {
|
||||
clearTelegramVoiceSynthesisProviders();
|
||||
});
|
||||
|
||||
test("Outbound voice sender uploads provider opus result with reply markup and transcript", async () => {
|
||||
registerTelegramVoiceSynthesisProvider(
|
||||
async () => ({ audioPath: "/tmp/direct.opus", transcriptText: "spoken" }),
|
||||
{ id: "direct-test" },
|
||||
);
|
||||
const uploads: unknown[] = [];
|
||||
const actions: unknown[] = [];
|
||||
const sendVoice = createTelegramVoiceReplySender({
|
||||
execCommand: async () => ({
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
killed: false,
|
||||
}),
|
||||
sendRecordVoiceAction: async (chatId) => {
|
||||
actions.push(chatId);
|
||||
},
|
||||
sendMultipart: async (...args) => {
|
||||
uploads.push(args);
|
||||
},
|
||||
});
|
||||
|
||||
await sendVoice({ chatId: 1, replyToMessageId: 2 }, "hello", {
|
||||
replyMarkup: { inline_keyboard: [] },
|
||||
replyToPrompt: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(actions, [1]);
|
||||
assert.deepEqual(uploads, [
|
||||
[
|
||||
"sendVoice",
|
||||
{
|
||||
chat_id: "1",
|
||||
caption: "spoken",
|
||||
reply_parameters: JSON.stringify({
|
||||
message_id: 2,
|
||||
allow_sending_without_reply: true,
|
||||
}),
|
||||
reply_markup: JSON.stringify({ inline_keyboard: [] }),
|
||||
},
|
||||
"voice",
|
||||
"/tmp/direct.opus",
|
||||
"direct.opus",
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test("Outbound voice sender uploads voice into thread target", async () => {
|
||||
registerTelegramVoiceSynthesisProvider(async () => "/tmp/direct.ogg", {
|
||||
id: "thread-test",
|
||||
});
|
||||
const uploads: unknown[] = [];
|
||||
const sendVoice = createTelegramVoiceReplySender({
|
||||
execCommand: async () => ({
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
killed: false,
|
||||
}),
|
||||
sendMultipart: async (...args) => {
|
||||
uploads.push(args);
|
||||
},
|
||||
});
|
||||
|
||||
await sendVoice(
|
||||
{
|
||||
chatId: -1007,
|
||||
replyToMessageId: 2,
|
||||
target: createTelegramThreadTarget(-1007, 42),
|
||||
},
|
||||
"hello",
|
||||
);
|
||||
|
||||
const fields = (uploads[0] as unknown[])[1] as Record<string, string>;
|
||||
assert.equal(fields.chat_id, "-1007");
|
||||
assert.equal(fields.message_thread_id, "42");
|
||||
assert.equal(
|
||||
fields.reply_parameters,
|
||||
JSON.stringify({
|
||||
message_id: 2,
|
||||
allow_sending_without_reply: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("Outbound voice sender records and throws when every source fails", async () => {
|
||||
const events: Array<{ category: string; message: string; phase?: unknown }> =
|
||||
[];
|
||||
const sendVoice = createTelegramVoiceReplySender({
|
||||
execCommand: async () => ({
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
killed: false,
|
||||
}),
|
||||
sendMultipart: async () => {},
|
||||
recordRuntimeEvent: (category, error, details) => {
|
||||
events.push({
|
||||
category,
|
||||
message: (error as Error).message,
|
||||
phase: details?.phase,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
sendVoice({ chatId: 1, replyToMessageId: 2 }, "hello"),
|
||||
/every voice synthesis provider and outbound voice handler failed/,
|
||||
);
|
||||
assert.deepEqual(events, [
|
||||
{
|
||||
category: "voice",
|
||||
message:
|
||||
"Failed to send voice reply: every voice synthesis provider and outbound voice handler failed.",
|
||||
phase: "send",
|
||||
},
|
||||
]);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Regression tests for Telegram message ownership helpers
|
||||
* Covers live message-id to target/instance ownership used by multi-instance bus routing
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
createTelegramBusMessageOwnershipRuntime,
|
||||
createTelegramMessageOwnershipStore,
|
||||
} from "../lib/ownership.ts";
|
||||
import { createTelegramThreadTarget } from "../lib/target.ts";
|
||||
|
||||
test("Bus ownership runtime scopes follower authority to registration generation", () => {
|
||||
let followers = [
|
||||
{
|
||||
instanceId: "follower-a",
|
||||
connectedAtMs: 10,
|
||||
registrationGeneration: "generation-a",
|
||||
},
|
||||
];
|
||||
const runtime = createTelegramBusMessageOwnershipRuntime({
|
||||
instanceId: "leader",
|
||||
getProfileKey: () => "default",
|
||||
listFollowers: () => followers,
|
||||
});
|
||||
runtime.recordRouted({
|
||||
chatId: 7,
|
||||
messageId: 9,
|
||||
target: { chatId: 7, threadId: 11 },
|
||||
instanceId: "follower-a",
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
runtime.isOwnedByFollower({
|
||||
chatId: 7,
|
||||
messageId: 9,
|
||||
follower: followers[0]!,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
followers = [
|
||||
{
|
||||
instanceId: "follower-a",
|
||||
connectedAtMs: 20,
|
||||
registrationGeneration: "generation-b",
|
||||
},
|
||||
];
|
||||
assert.equal(runtime.store.get(7, 9), undefined);
|
||||
assert.equal(
|
||||
runtime.isOwnedByFollower({
|
||||
chatId: 7,
|
||||
messageId: 9,
|
||||
follower: followers[0]!,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("Message ownership records default private targets", () => {
|
||||
const store = createTelegramMessageOwnershipStore();
|
||||
const record = store.record({
|
||||
chatId: 7,
|
||||
messageId: 9,
|
||||
instanceId: "instance-a",
|
||||
now: 100,
|
||||
});
|
||||
assert.deepEqual(record, {
|
||||
chatId: 7,
|
||||
messageId: 9,
|
||||
target: { chatId: 7 },
|
||||
instanceId: "instance-a",
|
||||
createdAt: 100,
|
||||
updatedAt: 100,
|
||||
});
|
||||
assert.deepEqual(store.get(7, 9), record);
|
||||
});
|
||||
|
||||
test("Message ownership preserves createdAt when ownership is refreshed", () => {
|
||||
const store = createTelegramMessageOwnershipStore();
|
||||
store.record({
|
||||
chatId: -1007,
|
||||
messageId: 11,
|
||||
target: createTelegramThreadTarget(-1007, 42),
|
||||
instanceId: "instance-a",
|
||||
now: 100,
|
||||
});
|
||||
const refreshed = store.record({
|
||||
chatId: -1007,
|
||||
messageId: 11,
|
||||
target: createTelegramThreadTarget(-1007, 43),
|
||||
instanceId: "instance-b",
|
||||
now: 150,
|
||||
});
|
||||
assert.deepEqual(refreshed, {
|
||||
chatId: -1007,
|
||||
messageId: 11,
|
||||
target: createTelegramThreadTarget(-1007, 43),
|
||||
instanceId: "instance-b",
|
||||
createdAt: 100,
|
||||
updatedAt: 150,
|
||||
});
|
||||
});
|
||||
|
||||
test("Message ownership can forget a whole target", () => {
|
||||
const store = createTelegramMessageOwnershipStore();
|
||||
store.record({
|
||||
chatId: -1007,
|
||||
messageId: 1,
|
||||
target: createTelegramThreadTarget(-1007, 42),
|
||||
instanceId: "instance-a",
|
||||
now: 1,
|
||||
});
|
||||
store.record({
|
||||
chatId: -1007,
|
||||
messageId: 2,
|
||||
target: createTelegramThreadTarget(-1007, 42),
|
||||
instanceId: "instance-a",
|
||||
now: 2,
|
||||
});
|
||||
store.record({
|
||||
chatId: -1007,
|
||||
messageId: 3,
|
||||
target: createTelegramThreadTarget(-1007, 43),
|
||||
instanceId: "instance-b",
|
||||
now: 3,
|
||||
});
|
||||
assert.equal(store.forgetTarget(createTelegramThreadTarget(-1007, 42)), 2);
|
||||
assert.equal(store.get(-1007, 1), undefined);
|
||||
assert.equal(store.get(-1007, 2), undefined);
|
||||
assert.equal(store.get(-1007, 3)?.instanceId, "instance-b");
|
||||
});
|
||||
|
||||
test("Message ownership isolates bot profiles and rejects stale follower generations", () => {
|
||||
let profileKey = "work";
|
||||
let liveGeneration = "follower-a:100";
|
||||
const store = createTelegramMessageOwnershipStore({
|
||||
getProfileKey: () => profileKey,
|
||||
isOwnerGenerationLive: (record) =>
|
||||
record.ownerGeneration === liveGeneration,
|
||||
});
|
||||
store.record({
|
||||
chatId: 7,
|
||||
messageId: 9,
|
||||
instanceId: "follower-a",
|
||||
ownerGeneration: "follower-a:100",
|
||||
now: 100,
|
||||
});
|
||||
assert.equal(store.get(7, 9)?.instanceId, "follower-a");
|
||||
|
||||
profileKey = "personal";
|
||||
assert.equal(store.get(7, 9), undefined);
|
||||
store.record({
|
||||
chatId: 7,
|
||||
messageId: 9,
|
||||
instanceId: "follower-b",
|
||||
ownerGeneration: "follower-b:200",
|
||||
now: 200,
|
||||
});
|
||||
liveGeneration = "follower-b:200";
|
||||
assert.equal(store.get(7, 9)?.instanceId, "follower-b");
|
||||
|
||||
profileKey = "work";
|
||||
liveGeneration = "follower-a:101";
|
||||
assert.equal(store.get(7, 9), undefined);
|
||||
assert.equal(store.entries().length, 2);
|
||||
});
|
||||
|
||||
test("Message ownership prunes by age and record count", () => {
|
||||
const store = createTelegramMessageOwnershipStore();
|
||||
store.record({ chatId: 7, messageId: 1, instanceId: "a", now: 10 });
|
||||
store.record({ chatId: 7, messageId: 2, instanceId: "a", now: 20 });
|
||||
store.record({ chatId: 7, messageId: 3, instanceId: "a", now: 30 });
|
||||
assert.equal(store.prune({ now: 40, maxAgeMs: 25 }), 1);
|
||||
assert.equal(store.get(7, 1), undefined);
|
||||
assert.equal(store.prune({ now: 40, maxRecords: 1 }), 1);
|
||||
assert.deepEqual(
|
||||
store.entries().map((record) => record.messageId),
|
||||
[3],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Regression tests for Telegram bridge path resolution
|
||||
* Guards agent-dir detection for Pi-compatible runtimes and path derivation helpers.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
resolveAgentDir,
|
||||
resolveTelegramConfigPath,
|
||||
resolveTelegramLocksPath,
|
||||
resolveTelegramTempDir,
|
||||
resolveTelegramRuntimeLogPath,
|
||||
} from "../lib/paths.ts";
|
||||
|
||||
await test("resolveAgentDir", async (t) => {
|
||||
await t.test("returns PI_CODING_AGENT_DIR when env is set", () => {
|
||||
assert.equal(
|
||||
resolveAgentDir({
|
||||
env: { PI_CODING_AGENT_DIR: "/custom/agent/dir" },
|
||||
execPath: "/usr/bin/omp",
|
||||
argv: ["omp"],
|
||||
}),
|
||||
resolve("/custom/agent/dir"),
|
||||
);
|
||||
});
|
||||
|
||||
await t.test("returns ~/.omp/agent for OMP-compatible runtimes", () => {
|
||||
assert.equal(
|
||||
resolveAgentDir({ env: {}, execPath: "/home/user/.local/bin/omp" }),
|
||||
join(homedir(), ".omp", "agent"),
|
||||
);
|
||||
assert.equal(
|
||||
resolveAgentDir({
|
||||
env: {},
|
||||
execPath: "/usr/bin/node",
|
||||
argv: ["node", "omp"],
|
||||
}),
|
||||
join(homedir(), ".omp", "agent"),
|
||||
);
|
||||
});
|
||||
|
||||
await t.test(
|
||||
"returns ~/.pi/agent as fallback when no env and no OMP runtime",
|
||||
() => {
|
||||
assert.equal(
|
||||
resolveAgentDir({ env: {}, execPath: "/usr/bin/node", argv: ["node"] }),
|
||||
join(homedir(), ".pi", "agent"),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
await test("resolveTelegramConfigPath", () => {
|
||||
assert.ok(
|
||||
resolveTelegramConfigPath().endsWith("telegram.json"),
|
||||
"config path ends with telegram.json",
|
||||
);
|
||||
});
|
||||
|
||||
await test("resolveTelegramLocksPath", () => {
|
||||
assert.ok(
|
||||
resolveTelegramLocksPath().endsWith("locks.json"),
|
||||
"locks path ends with locks.json",
|
||||
);
|
||||
});
|
||||
|
||||
await test("resolveTelegramTempDir", () => {
|
||||
assert.ok(
|
||||
resolveTelegramTempDir().endsWith("/tmp/telegram"),
|
||||
"temp dir ends with /tmp/telegram",
|
||||
);
|
||||
});
|
||||
|
||||
await test("resolveTelegramRuntimeLogPath", () => {
|
||||
assert.ok(
|
||||
resolveTelegramRuntimeLogPath().endsWith("/tmp/telegram/logs.jsonl"),
|
||||
"runtime log path ends with logs.jsonl",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Regression tests for the pi SDK adapter boundary
|
||||
* Covers narrow bridge-facing helpers over concrete pi context contracts
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
canStartPollingInExtensionContext,
|
||||
compactExtensionContext,
|
||||
createExtensionApiRuntimePorts,
|
||||
createScopedModelPatternPersister,
|
||||
type ExtensionContext,
|
||||
getExtensionContextCwd,
|
||||
formatPollingStartBlockedByRunMode,
|
||||
getExtensionContextMode,
|
||||
getExtensionContextModel,
|
||||
hasExtensionContextPendingMessages,
|
||||
isExtensionContextIdle,
|
||||
isExtensionContextPassiveRunMode,
|
||||
} from "../lib/pi.ts";
|
||||
|
||||
type PiRuntimeApiHarness = Parameters<
|
||||
typeof createExtensionApiRuntimePorts
|
||||
>[0] & {
|
||||
events: string[];
|
||||
};
|
||||
|
||||
type PiRuntimeModel = Parameters<PiRuntimeApiHarness["setModel"]>[0];
|
||||
|
||||
function createHarnessModel(id: string): PiRuntimeModel {
|
||||
return { id } as PiRuntimeModel;
|
||||
}
|
||||
|
||||
function getHarnessModelId(model: PiRuntimeModel): string {
|
||||
return String(Reflect.get(Object(model), "id"));
|
||||
}
|
||||
|
||||
test("Pi context mode helpers feature-detect passive run modes", () => {
|
||||
assert.equal(getExtensionContextMode({ mode: "print" }), "print");
|
||||
assert.equal(getExtensionContextMode({ mode: "bogus" }), undefined);
|
||||
assert.equal(isExtensionContextPassiveRunMode({ mode: "print" }), true);
|
||||
assert.equal(isExtensionContextPassiveRunMode({ mode: "json" }), true);
|
||||
assert.equal(isExtensionContextPassiveRunMode({ mode: "rpc" }), false);
|
||||
assert.equal(isExtensionContextPassiveRunMode({}), false);
|
||||
assert.equal(canStartPollingInExtensionContext({ mode: "tui" }), true);
|
||||
assert.equal(canStartPollingInExtensionContext({ mode: "rpc" }), true);
|
||||
assert.equal(canStartPollingInExtensionContext({ mode: "json" }), false);
|
||||
assert.equal(canStartPollingInExtensionContext({ mode: "print" }), false);
|
||||
assert.equal(canStartPollingInExtensionContext({}), true);
|
||||
assert.equal(
|
||||
formatPollingStartBlockedByRunMode({ mode: "json" }),
|
||||
"Telegram polling is unavailable in Pi json mode. Use /telegram-connect from a long-lived Pi session.",
|
||||
);
|
||||
});
|
||||
|
||||
test("Pi API runtime ports bind methods without losing receiver context", async () => {
|
||||
const api: PiRuntimeApiHarness = {
|
||||
events: [],
|
||||
sendUserMessage(content, options) {
|
||||
this.events.push(
|
||||
`send:${String(content)}:${options?.deliverAs ?? "default"}`,
|
||||
);
|
||||
},
|
||||
async exec(command, args) {
|
||||
this.events.push(`exec:${command}:${args.join(",")}`);
|
||||
return { stdout: "ok", stderr: "", code: 0, killed: false };
|
||||
},
|
||||
getCommands() {
|
||||
this.events.push("commands");
|
||||
return [];
|
||||
},
|
||||
getThinkingLevel() {
|
||||
this.events.push("get-thinking");
|
||||
return "high";
|
||||
},
|
||||
setThinkingLevel(level) {
|
||||
this.events.push(`thinking:${String(level)}`);
|
||||
},
|
||||
async setModel(model) {
|
||||
this.events.push(`model:${getHarnessModelId(model)}`);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const runtime = createExtensionApiRuntimePorts(api);
|
||||
runtime.sendUserMessage("hello", { deliverAs: "followUp" });
|
||||
assert.deepEqual(await runtime.exec("cmd", ["arg"]), {
|
||||
stdout: "ok",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
killed: false,
|
||||
});
|
||||
assert.deepEqual(runtime.getCommands(), []);
|
||||
assert.equal(runtime.getThinkingLevel(), "high");
|
||||
runtime.setThinkingLevel("low");
|
||||
assert.equal(await runtime.setModel(createHarnessModel("gpt-5")), true);
|
||||
assert.deepEqual(api.events, [
|
||||
"send:hello:followUp",
|
||||
"exec:cmd:arg",
|
||||
"commands",
|
||||
"get-thinking",
|
||||
"thinking:low",
|
||||
"model:gpt-5",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Pi scoped model persister invalidates cached inputs without clearing live menus", async () => {
|
||||
const events: string[] = [];
|
||||
const persist = createScopedModelPatternPersister({
|
||||
createSettingsManager: (cwd) => ({
|
||||
reload: async () => {},
|
||||
flush: async () => {
|
||||
events.push("flush");
|
||||
},
|
||||
getEnabledModels: () => undefined,
|
||||
setEnabledModels: (patterns) => {
|
||||
events.push(`set:${cwd}:${patterns?.join(",") ?? "all"}`);
|
||||
},
|
||||
}),
|
||||
clearCachedModelMenuInputs: () => {
|
||||
events.push("clear-cache");
|
||||
},
|
||||
});
|
||||
await persist(["openai/gpt-5"], { cwd: "/tmp/project" } as ExtensionContext);
|
||||
assert.deepEqual(events, [
|
||||
"set:/tmp/project:openai/gpt-5",
|
||||
"flush",
|
||||
"clear-cache",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Pi context helpers expose model, idle, pending-message, and compact adapters", () => {
|
||||
const model = { provider: "openai", id: "gpt-5", name: "GPT-5" };
|
||||
const events: string[] = [];
|
||||
const ctx = {
|
||||
model,
|
||||
isIdle: () => true,
|
||||
hasPendingMessages: () => false,
|
||||
cwd: "/tmp/project",
|
||||
compact: (callbacks: { onComplete: () => void }) => {
|
||||
events.push("compact");
|
||||
callbacks.onComplete();
|
||||
},
|
||||
} as unknown as ExtensionContext;
|
||||
compactExtensionContext(ctx, {
|
||||
onComplete: () => {
|
||||
events.push("complete");
|
||||
},
|
||||
onError: () => {
|
||||
events.push("error");
|
||||
},
|
||||
});
|
||||
assert.equal(getExtensionContextModel(ctx), model);
|
||||
assert.equal(getExtensionContextCwd(ctx), "/tmp/project");
|
||||
assert.equal(isExtensionContextIdle(ctx), true);
|
||||
assert.equal(hasExtensionContextPendingMessages(ctx), false);
|
||||
assert.deepEqual(events, ["compact", "complete"]);
|
||||
});
|
||||
@@ -0,0 +1,920 @@
|
||||
/**
|
||||
* Regression tests for the Telegram polling runtime domain
|
||||
* Covers polling request helpers, stop conditions, and the long-poll loop runtime in one suite
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
applyTelegramThreadCapability,
|
||||
buildTelegramInitialSyncRequest,
|
||||
buildTelegramLongPollRequest,
|
||||
createTelegramPollingActivityReader,
|
||||
createTelegramPollingController,
|
||||
createTelegramPollingControllerRuntime,
|
||||
createTelegramPollingControllerState,
|
||||
createTelegramPollLoopRunner,
|
||||
createTelegramThreadAwarePollingPorts,
|
||||
createTelegramThreadCapabilityStateRuntime,
|
||||
createTelegramThreadTargetObservationBinding,
|
||||
getLatestTelegramUpdateId,
|
||||
isTelegramGetUpdatesConflictError,
|
||||
isTelegramPollingControllerActive,
|
||||
runTelegramPollLoop,
|
||||
shouldStartTelegramPolling,
|
||||
shouldStopTelegramPolling,
|
||||
sleepTelegramPollingRetry,
|
||||
startTelegramPollingRuntime,
|
||||
stopTelegramPollingRuntime,
|
||||
TELEGRAM_ALLOWED_UPDATES,
|
||||
} from "../lib/polling.ts";
|
||||
|
||||
const TEST_CONTEXT = "ctx";
|
||||
|
||||
test("Polling helpers build the initial sync request", () => {
|
||||
assert.deepEqual(buildTelegramInitialSyncRequest(), {
|
||||
offset: -1,
|
||||
limit: 1,
|
||||
timeout: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("Polling helpers build long-poll requests with and without lastUpdateId", () => {
|
||||
assert.deepEqual(buildTelegramLongPollRequest(), {
|
||||
offset: undefined,
|
||||
limit: 10,
|
||||
timeout: 30,
|
||||
allowed_updates: TELEGRAM_ALLOWED_UPDATES,
|
||||
});
|
||||
assert.deepEqual(buildTelegramLongPollRequest(41), {
|
||||
offset: 42,
|
||||
limit: 10,
|
||||
timeout: 30,
|
||||
allowed_updates: TELEGRAM_ALLOWED_UPDATES,
|
||||
});
|
||||
});
|
||||
|
||||
test("Polling helpers extract the latest update id", () => {
|
||||
assert.equal(getLatestTelegramUpdateId([]), undefined);
|
||||
assert.equal(
|
||||
getLatestTelegramUpdateId([{ update_id: 1 }, { update_id: 7 }]),
|
||||
7,
|
||||
);
|
||||
});
|
||||
|
||||
test("Thread target observation binding supports late runtime composition", async () => {
|
||||
const events: string[] = [];
|
||||
const binding = createTelegramThreadTargetObservationBinding<string>();
|
||||
|
||||
await binding.handle("before");
|
||||
binding.set(async (ctx) => {
|
||||
events.push(ctx);
|
||||
});
|
||||
await binding.handle("after");
|
||||
|
||||
assert.deepEqual(events, ["after"]);
|
||||
});
|
||||
|
||||
test("Thread capability state runtime owns transition flags", () => {
|
||||
const state = createTelegramThreadCapabilityStateRuntime();
|
||||
|
||||
assert.equal(state.isBusPollingStarted(), false);
|
||||
assert.equal(state.isTopicModeUnavailable(), false);
|
||||
assert.equal(state.shouldForceFreshLeaderThread(), false);
|
||||
|
||||
state.setBusPollingStarted(true);
|
||||
state.setTopicModeUnavailable(true);
|
||||
state.setForceFreshLeaderThread(true);
|
||||
|
||||
assert.equal(state.isBusPollingStarted(), true);
|
||||
assert.equal(state.isTopicModeUnavailable(), true);
|
||||
assert.equal(state.shouldForceFreshLeaderThread(), true);
|
||||
});
|
||||
|
||||
test("Thread-aware polling blocks follower takeover during Threaded Mode downgrade", async () => {
|
||||
const events: Array<{ category: string; details?: Record<string, unknown> }> = [];
|
||||
const callApi = async <TResponse,>(): Promise<TResponse> => ({}) as TResponse;
|
||||
const store = {
|
||||
async load() {},
|
||||
async persist() {},
|
||||
getBotState() {
|
||||
return { threadMode: "disabled" as const };
|
||||
},
|
||||
setBotState() {},
|
||||
list() {
|
||||
return [
|
||||
{
|
||||
status: "active",
|
||||
target: { chatId: 42, threadId: 7 },
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
const ports = createTelegramThreadAwarePollingPorts({
|
||||
getAllowedUserId: () => 42,
|
||||
callApi,
|
||||
topicTargetStore: store,
|
||||
isBusConfigured: () => true,
|
||||
isBusRuntimeEnabled: () => false,
|
||||
isTopicModeUnavailableError: () => false,
|
||||
getPollingStartedWithTelegramBus: () => false,
|
||||
setPollingStartedWithTelegramBus() {},
|
||||
setForceFreshLeaderThreadOnNextStart() {},
|
||||
setTopicModeUnavailable() {},
|
||||
startClassicPolling() {},
|
||||
async stopClassicPolling() {},
|
||||
async startBusLeaderPolling() {},
|
||||
async stopBusLeaderPolling() {},
|
||||
startLeaderHealth() {},
|
||||
stopLeaderHealth() {},
|
||||
registerFollowerWithLeader: async () => true,
|
||||
stopFollowerRegistration() {},
|
||||
recordEvent(category, _error, details) {
|
||||
events.push({ category, details });
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
ports.registerFollowerWithOwner?.(TEST_CONTEXT, { pid: 1 }),
|
||||
/current leader remains the classic polling owner/u,
|
||||
);
|
||||
assert.deepEqual(events, [
|
||||
{
|
||||
category: "bus",
|
||||
details: {
|
||||
phase: "follower-register-thread-mode-disabled",
|
||||
reason: "active-thread-bindings-present",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("Thread capability downgrade retries classic restore after failure", async () => {
|
||||
let state: {
|
||||
threadMode?: "enabled" | "disabled" | "unknown";
|
||||
updatedAtMs?: number;
|
||||
lastReconcileAction?: string;
|
||||
} = {
|
||||
threadMode: "enabled",
|
||||
lastReconcileAction: "capability-monitor-enabled",
|
||||
};
|
||||
let pollingStartedWithBus = true;
|
||||
let classicStarts = 0;
|
||||
let persisted = 0;
|
||||
const events: Array<{ category: string; details?: Record<string, unknown> }> = [];
|
||||
const store = {
|
||||
async load() {},
|
||||
async persist() {
|
||||
persisted += 1;
|
||||
},
|
||||
getBotState() {
|
||||
return state;
|
||||
},
|
||||
setBotState(next: typeof state) {
|
||||
state = { ...state, ...next };
|
||||
},
|
||||
list() {
|
||||
return [
|
||||
{
|
||||
status: "active",
|
||||
target: { chatId: 42, threadId: 7 },
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
const deps = {
|
||||
getAllowedUserId: () => 42,
|
||||
callApi: async <TResponse,>(): Promise<TResponse> => ({}) as TResponse,
|
||||
topicTargetStore: store,
|
||||
isBusConfigured: () => true,
|
||||
ownsLock: () => true,
|
||||
getPollingStartedWithTelegramBus: () => pollingStartedWithBus,
|
||||
setPollingStartedWithTelegramBus(started: boolean) {
|
||||
pollingStartedWithBus = started;
|
||||
},
|
||||
setTopicModeUnavailable() {},
|
||||
stopFollowerRegistration() {},
|
||||
startClassicPolling() {
|
||||
classicStarts += 1;
|
||||
if (classicStarts === 1) throw new Error("classic unavailable");
|
||||
},
|
||||
async stopClassicPolling() {},
|
||||
async startBusPolling() {},
|
||||
async stopBusPolling() {},
|
||||
startLeaderHealth() {},
|
||||
stopLeaderHealth() {},
|
||||
isTopicModeUnavailableError: () => false,
|
||||
updateStatus() {},
|
||||
recordEvent(category: string, _error: unknown, details?: Record<string, unknown>) {
|
||||
events.push({ category, details });
|
||||
},
|
||||
};
|
||||
|
||||
await applyTelegramThreadCapability(
|
||||
TEST_CONTEXT,
|
||||
false,
|
||||
"capability-monitor-disabled-confirmed",
|
||||
deps,
|
||||
);
|
||||
assert.equal(classicStarts, 1);
|
||||
assert.equal(
|
||||
state.lastReconcileAction,
|
||||
"capability-monitor-disabled-confirmed-classic-restore-failed",
|
||||
);
|
||||
assert.equal(pollingStartedWithBus, false);
|
||||
|
||||
await applyTelegramThreadCapability(
|
||||
TEST_CONTEXT,
|
||||
false,
|
||||
"capability-monitor-disabled-confirmed",
|
||||
deps,
|
||||
);
|
||||
assert.equal(classicStarts, 2);
|
||||
assert.equal(state.lastReconcileAction, "capability-monitor-disabled-confirmed");
|
||||
assert.equal(persisted >= 3, true);
|
||||
assert.equal(events[0].details?.phase, "capability-monitor-disabled-confirmed-classic-restore");
|
||||
});
|
||||
|
||||
test("Thread-aware polling still allows classic takeover path without thread bindings", async () => {
|
||||
const callApi = async <TResponse,>(): Promise<TResponse> => ({}) as TResponse;
|
||||
const store = {
|
||||
async load() {},
|
||||
async persist() {},
|
||||
getBotState() {
|
||||
return { threadMode: "disabled" as const };
|
||||
},
|
||||
setBotState() {},
|
||||
list() {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
const ports = createTelegramThreadAwarePollingPorts({
|
||||
getAllowedUserId: () => 42,
|
||||
callApi,
|
||||
topicTargetStore: store,
|
||||
isBusConfigured: () => true,
|
||||
isBusRuntimeEnabled: () => false,
|
||||
isTopicModeUnavailableError: () => false,
|
||||
getPollingStartedWithTelegramBus: () => false,
|
||||
setPollingStartedWithTelegramBus() {},
|
||||
setForceFreshLeaderThreadOnNextStart() {},
|
||||
setTopicModeUnavailable() {},
|
||||
startClassicPolling() {},
|
||||
async stopClassicPolling() {},
|
||||
async startBusLeaderPolling() {},
|
||||
async stopBusLeaderPolling() {},
|
||||
startLeaderHealth() {},
|
||||
stopLeaderHealth() {},
|
||||
registerFollowerWithLeader: async () => true,
|
||||
stopFollowerRegistration() {},
|
||||
recordEvent() {},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
await ports.registerFollowerWithOwner?.(TEST_CONTEXT, { pid: 1 }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("Polling helpers start only when a bot token exists and polling is idle", () => {
|
||||
assert.equal(
|
||||
shouldStartTelegramPolling({
|
||||
hasBotToken: true,
|
||||
hasPollingPromise: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldStartTelegramPolling({
|
||||
hasBotToken: false,
|
||||
hasPollingPromise: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldStartTelegramPolling({
|
||||
hasBotToken: true,
|
||||
hasPollingPromise: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("Polling runtime starts and stops polling through state ports", async () => {
|
||||
const events: string[] = [];
|
||||
let pollingPromise: Promise<void> | undefined;
|
||||
let pollingController: AbortController | undefined;
|
||||
let finishPollLoop: (() => void) | undefined;
|
||||
const deps = {
|
||||
hasBotToken: () => true,
|
||||
getPollingPromise: () => pollingPromise,
|
||||
setPollingPromise: (promise: Promise<void> | undefined) => {
|
||||
pollingPromise = promise;
|
||||
events.push(`promise:${promise ? "set" : "clear"}`);
|
||||
},
|
||||
getPollingController: () => pollingController,
|
||||
setPollingController: (controller: AbortController | undefined) => {
|
||||
pollingController = controller;
|
||||
events.push(`controller:${controller ? "set" : "clear"}`);
|
||||
},
|
||||
stopTypingLoop: () => {
|
||||
events.push("typing:stop");
|
||||
},
|
||||
runPollLoop: async (_ctx: string, signal: AbortSignal) => {
|
||||
events.push(`run:${signal.aborted}`);
|
||||
await new Promise<void>((resolve) => {
|
||||
finishPollLoop = resolve;
|
||||
});
|
||||
},
|
||||
updateStatus: (ctx: string) => {
|
||||
events.push(`status:${ctx}`);
|
||||
},
|
||||
};
|
||||
startTelegramPollingRuntime("ctx", deps);
|
||||
assert.equal(!!pollingPromise, true);
|
||||
assert.equal(!!pollingController, true);
|
||||
const stopPromise = stopTelegramPollingRuntime(deps);
|
||||
assert.equal(pollingController?.signal.aborted, true);
|
||||
assert.equal(!!pollingController, true);
|
||||
finishPollLoop?.();
|
||||
await stopPromise;
|
||||
assert.deepEqual(events, [
|
||||
"controller:set",
|
||||
"run:false",
|
||||
"promise:set",
|
||||
"status:ctx",
|
||||
"typing:stop",
|
||||
"promise:clear",
|
||||
"controller:clear",
|
||||
"status:ctx",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Polling runtime still aborts and settles when typing cleanup fails", async () => {
|
||||
const events: string[] = [];
|
||||
let pollingPromise: Promise<void> | undefined;
|
||||
let pollingController: AbortController | undefined;
|
||||
let finishPollLoop: (() => void) | undefined;
|
||||
const deps = {
|
||||
hasBotToken: () => true,
|
||||
getPollingPromise: () => pollingPromise,
|
||||
setPollingPromise: (promise: Promise<void> | undefined) => {
|
||||
pollingPromise = promise;
|
||||
events.push(`promise:${promise ? "set" : "clear"}`);
|
||||
},
|
||||
getPollingController: () => pollingController,
|
||||
setPollingController: (controller: AbortController | undefined) => {
|
||||
pollingController = controller;
|
||||
events.push(`controller:${controller ? "set" : "clear"}`);
|
||||
},
|
||||
stopTypingLoop: () => {
|
||||
events.push("typing:throw");
|
||||
throw new Error("typing cleanup failed");
|
||||
},
|
||||
runPollLoop: async (_ctx: string, signal: AbortSignal) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
finishPollLoop = () => {
|
||||
events.push(`run-finish:${signal.aborted}`);
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
},
|
||||
updateStatus: () => {},
|
||||
recordRuntimeEvent: (
|
||||
category: string,
|
||||
error: unknown,
|
||||
details?: Record<string, unknown>,
|
||||
) => {
|
||||
events.push(
|
||||
`${category}:${error instanceof Error ? error.message : String(error)}:${details?.phase}`,
|
||||
);
|
||||
},
|
||||
};
|
||||
startTelegramPollingRuntime("ctx", deps);
|
||||
const stopPromise = stopTelegramPollingRuntime(deps);
|
||||
assert.equal(pollingController?.signal.aborted, true);
|
||||
finishPollLoop?.();
|
||||
await stopPromise;
|
||||
assert.deepEqual(events, [
|
||||
"controller:set",
|
||||
"promise:set",
|
||||
"typing:throw",
|
||||
"polling:typing cleanup failed:typing-stop",
|
||||
"run-finish:true",
|
||||
"promise:clear",
|
||||
"controller:clear",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Polling runtime ignores stale-context status failures during cleanup", async () => {
|
||||
let pollingPromise: Promise<void> | undefined;
|
||||
let pollingController: AbortController | undefined;
|
||||
let statusCalls = 0;
|
||||
const runtimeEvents: string[] = [];
|
||||
const deps = {
|
||||
hasBotToken: () => true,
|
||||
getPollingPromise: () => pollingPromise,
|
||||
setPollingPromise: (promise: Promise<void> | undefined) => {
|
||||
pollingPromise = promise;
|
||||
},
|
||||
getPollingController: () => pollingController,
|
||||
setPollingController: (controller: AbortController | undefined) => {
|
||||
pollingController = controller;
|
||||
},
|
||||
stopTypingLoop: () => {},
|
||||
runPollLoop: async () => {},
|
||||
updateStatus: () => {
|
||||
statusCalls += 1;
|
||||
if (statusCalls > 1) throw new Error("stale ctx");
|
||||
},
|
||||
recordRuntimeEvent: (
|
||||
category: string,
|
||||
error: unknown,
|
||||
details?: Record<string, unknown>,
|
||||
) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
runtimeEvents.push(`${category}:${message}:${details?.phase}`);
|
||||
},
|
||||
};
|
||||
startTelegramPollingRuntime("ctx", deps);
|
||||
await pollingPromise;
|
||||
assert.equal(statusCalls, 2);
|
||||
assert.equal(pollingPromise, undefined);
|
||||
assert.equal(pollingController, undefined);
|
||||
assert.deepEqual(runtimeEvents, ["polling:stale ctx:status-update"]);
|
||||
});
|
||||
|
||||
test("Polling runtime ignores stale-context status failures during start", () => {
|
||||
let pollingPromise: Promise<void> | undefined;
|
||||
let pollingController: AbortController | undefined;
|
||||
const runtimeEvents: string[] = [];
|
||||
const deps = {
|
||||
hasBotToken: () => true,
|
||||
getPollingPromise: () => pollingPromise,
|
||||
setPollingPromise: (promise: Promise<void> | undefined) => {
|
||||
pollingPromise = promise;
|
||||
},
|
||||
getPollingController: () => pollingController,
|
||||
setPollingController: (controller: AbortController | undefined) => {
|
||||
pollingController = controller;
|
||||
},
|
||||
stopTypingLoop: () => {},
|
||||
runPollLoop: async () => {},
|
||||
updateStatus: () => {
|
||||
throw new Error("stale ctx");
|
||||
},
|
||||
recordRuntimeEvent: (
|
||||
category: string,
|
||||
error: unknown,
|
||||
details?: Record<string, unknown>,
|
||||
) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
runtimeEvents.push(`${category}:${message}:${details?.phase}`);
|
||||
},
|
||||
};
|
||||
|
||||
assert.doesNotThrow(() => startTelegramPollingRuntime("ctx", deps));
|
||||
assert.equal(!!pollingPromise, true);
|
||||
assert.equal(!!pollingController, true);
|
||||
assert.deepEqual(runtimeEvents, ["polling:stale ctx:status-update"]);
|
||||
});
|
||||
|
||||
test("Polling controller owns polling promise and abort-controller state", async () => {
|
||||
const events: string[] = [];
|
||||
let finishPollLoop: (() => void) | undefined;
|
||||
const state = createTelegramPollingControllerState();
|
||||
const isPollingActive = createTelegramPollingActivityReader(state);
|
||||
const controller = createTelegramPollingController({
|
||||
state,
|
||||
hasBotToken: () => true,
|
||||
stopTypingLoop: () => {
|
||||
events.push("typing:stop");
|
||||
},
|
||||
runPollLoop: async (_ctx: string, signal: AbortSignal) => {
|
||||
events.push(`run:${signal.aborted}`);
|
||||
await new Promise<void>((resolve) => {
|
||||
finishPollLoop = resolve;
|
||||
});
|
||||
},
|
||||
updateStatus: (ctx: string) => {
|
||||
events.push(`status:${ctx}`);
|
||||
},
|
||||
});
|
||||
controller.start("ctx");
|
||||
assert.equal(controller.isActive(), true);
|
||||
assert.equal(isTelegramPollingControllerActive(state), true);
|
||||
assert.equal(isPollingActive(), true);
|
||||
controller.start("ctx");
|
||||
const stopPromise = controller.stop();
|
||||
finishPollLoop?.();
|
||||
await stopPromise;
|
||||
assert.equal(controller.isActive(), false);
|
||||
assert.equal(isTelegramPollingControllerActive(state), false);
|
||||
assert.equal(isPollingActive(), false);
|
||||
assert.deepEqual(events, [
|
||||
"run:false",
|
||||
"status:ctx",
|
||||
"typing:stop",
|
||||
"status:ctx",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Polling controller runtime binds loop runner and controller state", async () => {
|
||||
const events: string[] = [];
|
||||
const state = createTelegramPollingControllerState();
|
||||
const controller = createTelegramPollingControllerRuntime({
|
||||
state,
|
||||
getConfig: () => ({ botToken: "123:abc" }),
|
||||
hasBotToken: () => true,
|
||||
deleteWebhook: async () => {
|
||||
events.push("deleteWebhook");
|
||||
},
|
||||
getUpdates: async () => {
|
||||
throw new DOMException("stop", "AbortError");
|
||||
},
|
||||
persistConfig: async () => {
|
||||
events.push("persist");
|
||||
},
|
||||
handleUpdate: async () => {
|
||||
events.push("handle");
|
||||
},
|
||||
stopTypingLoop: () => {
|
||||
events.push("typing:stop");
|
||||
},
|
||||
updateStatus: (_ctx: string, message?: string) => {
|
||||
events.push(`status:${message ?? "ok"}`);
|
||||
},
|
||||
});
|
||||
controller.start("ctx");
|
||||
assert.equal(controller.isActive(), true);
|
||||
await controller.stop();
|
||||
assert.equal(controller.isActive(), false);
|
||||
assert.deepEqual(events, [
|
||||
"deleteWebhook",
|
||||
"status:ok",
|
||||
"typing:stop",
|
||||
"status:ok",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Polling helpers stop only for abort conditions", () => {
|
||||
assert.equal(shouldStopTelegramPolling(true, new Error("ignored")), true);
|
||||
assert.equal(
|
||||
shouldStopTelegramPolling(false, new DOMException("aborted", "AbortError")),
|
||||
true,
|
||||
);
|
||||
assert.equal(shouldStopTelegramPolling(false, new Error("network")), false);
|
||||
});
|
||||
|
||||
test("Poll loop runner binds config, status, and transport ports", async () => {
|
||||
const config: { botToken: string; lastUpdateId?: number } = {
|
||||
botToken: "123:abc",
|
||||
lastUpdateId: 5,
|
||||
};
|
||||
const events: string[] = [];
|
||||
let calls = 0;
|
||||
const runPollLoop = createTelegramPollLoopRunner({
|
||||
getConfig: () => config,
|
||||
deleteWebhook: async () => {
|
||||
events.push("deleteWebhook");
|
||||
},
|
||||
getUpdates: async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) return [{ update_id: 6 }];
|
||||
throw new DOMException("stop", "AbortError");
|
||||
},
|
||||
persistConfig: async () => {
|
||||
events.push(`persist:${config.lastUpdateId}`);
|
||||
},
|
||||
handleUpdate: async (update, ctx: string) => {
|
||||
events.push(`handle:${ctx}:${update.update_id}`);
|
||||
},
|
||||
updateStatus: (ctx, message) => {
|
||||
events.push(`status:${ctx}:${message ?? "ok"}`);
|
||||
},
|
||||
sleep: async () => {
|
||||
events.push("sleep");
|
||||
},
|
||||
});
|
||||
await runPollLoop("ctx", new AbortController().signal);
|
||||
assert.deepEqual(events, ["deleteWebhook", "handle:ctx:6", "persist:6"]);
|
||||
});
|
||||
|
||||
test("Poll loop runner ignores stale-context status failures while retrying", async () => {
|
||||
const config = { botToken: "123:abc", lastUpdateId: 1 };
|
||||
const events: string[] = [];
|
||||
const runtimeEvents: string[] = [];
|
||||
let calls = 0;
|
||||
const runPollLoop = createTelegramPollLoopRunner({
|
||||
getConfig: () => config,
|
||||
deleteWebhook: async () => {},
|
||||
getUpdates: async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) throw new Error("network down");
|
||||
throw new DOMException("stop", "AbortError");
|
||||
},
|
||||
persistConfig: async () => {},
|
||||
handleUpdate: async () => {},
|
||||
updateStatus: (_ctx: string, message?: string) => {
|
||||
events.push(`status:${message ?? "ok"}`);
|
||||
throw new Error("stale ctx");
|
||||
},
|
||||
sleep: async (ms) => {
|
||||
events.push(`sleep:${ms}`);
|
||||
},
|
||||
recordRuntimeEvent: (category, error, details) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
runtimeEvents.push(`${category}:${message}:${details?.phase}`);
|
||||
},
|
||||
});
|
||||
await runPollLoop("ctx", new AbortController().signal);
|
||||
assert.deepEqual(events, ["status:network down", "sleep:3000", "status:ok"]);
|
||||
assert.deepEqual(runtimeEvents, [
|
||||
"polling:network down:loop",
|
||||
"polling:stale ctx:status-update",
|
||||
"polling:stale ctx:status-update",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Poll loop initializes lastUpdateId and processes updates", async () => {
|
||||
const handled: number[] = [];
|
||||
const config: { botToken: string; lastUpdateId?: number } = {
|
||||
botToken: "123:abc",
|
||||
};
|
||||
let getUpdatesCalls = 0;
|
||||
let persistCount = 0;
|
||||
const signal = new AbortController().signal;
|
||||
await runTelegramPollLoop({
|
||||
ctx: TEST_CONTEXT,
|
||||
signal,
|
||||
config,
|
||||
deleteWebhook: async () => {},
|
||||
getUpdates: async () => {
|
||||
getUpdatesCalls += 1;
|
||||
if (getUpdatesCalls === 1) {
|
||||
return [{ update_id: 5 }];
|
||||
}
|
||||
if (getUpdatesCalls === 2) {
|
||||
return [{ update_id: 6 }, { update_id: 7 }];
|
||||
}
|
||||
throw new DOMException("stop", "AbortError");
|
||||
},
|
||||
persistConfig: async () => {
|
||||
persistCount += 1;
|
||||
},
|
||||
handleUpdate: async (update) => {
|
||||
handled.push(update.update_id);
|
||||
},
|
||||
onErrorStatus: () => {},
|
||||
onStatusReset: () => {},
|
||||
sleep: async () => {},
|
||||
});
|
||||
assert.equal(config.lastUpdateId, 7);
|
||||
assert.deepEqual(handled, [6, 7]);
|
||||
assert.equal(persistCount, 3);
|
||||
});
|
||||
|
||||
test("Poll loop persists long-poll offsets only after handling updates", async () => {
|
||||
const config = { botToken: "123:abc", lastUpdateId: 5 };
|
||||
const handled: number[] = [];
|
||||
const persisted: number[] = [];
|
||||
let calls = 0;
|
||||
await runTelegramPollLoop({
|
||||
ctx: TEST_CONTEXT,
|
||||
signal: new AbortController().signal,
|
||||
config,
|
||||
deleteWebhook: async () => {},
|
||||
getUpdates: async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) return [{ update_id: 6 }];
|
||||
throw new DOMException("stop", "AbortError");
|
||||
},
|
||||
persistConfig: async () => {
|
||||
persisted.push(config.lastUpdateId ?? -1);
|
||||
},
|
||||
handleUpdate: async (update) => {
|
||||
handled.push(update.update_id);
|
||||
throw new Error("handler failed");
|
||||
},
|
||||
onErrorStatus: () => {},
|
||||
onStatusReset: () => {},
|
||||
sleep: async () => {},
|
||||
});
|
||||
assert.deepEqual(handled, [6]);
|
||||
assert.equal(config.lastUpdateId, 5);
|
||||
assert.deepEqual(persisted, []);
|
||||
});
|
||||
|
||||
test("Poll loop does not readmit an update after offset persistence fails", async () => {
|
||||
const config = { botToken: "123:abc", lastUpdateId: 5 };
|
||||
const handled: number[] = [];
|
||||
let getUpdatesCalls = 0;
|
||||
let persistCalls = 0;
|
||||
await runTelegramPollLoop({
|
||||
ctx: TEST_CONTEXT,
|
||||
signal: new AbortController().signal,
|
||||
config,
|
||||
deleteWebhook: async () => {},
|
||||
getUpdates: async () => {
|
||||
getUpdatesCalls += 1;
|
||||
if (getUpdatesCalls <= 2) return [{ update_id: 6 }];
|
||||
throw new DOMException("stop", "AbortError");
|
||||
},
|
||||
persistConfig: async () => {
|
||||
persistCalls += 1;
|
||||
if (persistCalls === 1) throw new Error("config commit failed");
|
||||
},
|
||||
handleUpdate: async (update) => {
|
||||
handled.push(update.update_id);
|
||||
},
|
||||
onErrorStatus: () => {},
|
||||
onStatusReset: () => {},
|
||||
sleep: async () => {},
|
||||
});
|
||||
|
||||
assert.deepEqual(handled, [6]);
|
||||
assert.equal(persistCalls, 2);
|
||||
assert.equal(config.lastUpdateId, 6);
|
||||
});
|
||||
|
||||
test("Poll loop skips repeatedly failing updates after the configured threshold", async () => {
|
||||
const config = { botToken: "123:abc", lastUpdateId: 5 };
|
||||
const persisted: number[] = [];
|
||||
const statusMessages: string[] = [];
|
||||
const runtimeEvents: string[] = [];
|
||||
let calls = 0;
|
||||
await runTelegramPollLoop({
|
||||
ctx: TEST_CONTEXT,
|
||||
signal: new AbortController().signal,
|
||||
config,
|
||||
maxUpdateFailures: 2,
|
||||
deleteWebhook: async () => {},
|
||||
getUpdates: async () => {
|
||||
calls += 1;
|
||||
if (calls <= 2) return [{ update_id: 6 }];
|
||||
throw new DOMException("stop", "AbortError");
|
||||
},
|
||||
persistConfig: async () => {
|
||||
persisted.push(config.lastUpdateId ?? -1);
|
||||
},
|
||||
handleUpdate: async () => {
|
||||
throw new Error("handler failed");
|
||||
},
|
||||
onErrorStatus: (message) => {
|
||||
statusMessages.push(message);
|
||||
},
|
||||
onStatusReset: () => {
|
||||
statusMessages.push("reset");
|
||||
},
|
||||
sleep: async (ms) => {
|
||||
statusMessages.push(`sleep:${ms}`);
|
||||
},
|
||||
recordRuntimeEvent: (category, error, details) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
runtimeEvents.push(
|
||||
`${category}:${message}:${details?.phase}:${details?.failureCount}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
assert.equal(config.lastUpdateId, 6);
|
||||
assert.deepEqual(persisted, [6]);
|
||||
assert.deepEqual(statusMessages, [
|
||||
"handler failed",
|
||||
"sleep:3000",
|
||||
"reset",
|
||||
"skipping Telegram update 6 after 2 failures: handler failed",
|
||||
]);
|
||||
assert.deepEqual(runtimeEvents, [
|
||||
"polling:handler failed:handleUpdate:1",
|
||||
"polling:handler failed:handleUpdate:2",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Polling retry sleep resolves immediately when aborted", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await sleepTelegramPollingRetry(3000, controller.signal);
|
||||
});
|
||||
|
||||
test("Poll loop stops without status reset when aborted during retry sleep", async () => {
|
||||
const config = { botToken: "123:abc", lastUpdateId: 1 };
|
||||
const controller = new AbortController();
|
||||
const statusMessages: string[] = [];
|
||||
let calls = 0;
|
||||
await runTelegramPollLoop({
|
||||
ctx: TEST_CONTEXT,
|
||||
signal: controller.signal,
|
||||
config,
|
||||
deleteWebhook: async () => {},
|
||||
getUpdates: async () => {
|
||||
calls += 1;
|
||||
throw new Error("network down");
|
||||
},
|
||||
persistConfig: async () => {},
|
||||
handleUpdate: async () => {},
|
||||
onErrorStatus: (message) => {
|
||||
statusMessages.push(`error:${message}`);
|
||||
},
|
||||
onStatusReset: () => {
|
||||
statusMessages.push("unexpected:reset");
|
||||
},
|
||||
sleep: async (_ms, signal) => {
|
||||
assert.equal(signal, controller.signal);
|
||||
controller.abort();
|
||||
},
|
||||
});
|
||||
assert.equal(calls, 1);
|
||||
assert.deepEqual(statusMessages, ["error:network down"]);
|
||||
});
|
||||
|
||||
test("Poll loop suppresses getUpdates conflicts while another long poll drains", async () => {
|
||||
const config = { botToken: "123:abc", lastUpdateId: 1 };
|
||||
const statusMessages: string[] = [];
|
||||
const runtimeEvents: string[] = [];
|
||||
let calls = 0;
|
||||
await runTelegramPollLoop({
|
||||
ctx: TEST_CONTEXT,
|
||||
signal: new AbortController().signal,
|
||||
config,
|
||||
deleteWebhook: async () => {},
|
||||
getUpdates: async () => {
|
||||
calls += 1;
|
||||
if (calls <= 4) {
|
||||
throw new Error(
|
||||
"Telegram API getUpdates failed: HTTP 409: Conflict: terminated by other getUpdates request; make sure that only one bot instance is running",
|
||||
);
|
||||
}
|
||||
throw new DOMException("stop", "AbortError");
|
||||
},
|
||||
persistConfig: async () => {},
|
||||
handleUpdate: async () => {},
|
||||
onErrorStatus: (message) => {
|
||||
statusMessages.push(`error:${message}`);
|
||||
},
|
||||
onStatusReset: () => {
|
||||
statusMessages.push("reset");
|
||||
},
|
||||
sleep: async (ms) => {
|
||||
statusMessages.push(`sleep:${ms}`);
|
||||
},
|
||||
recordRuntimeEvent: (category, error, details) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
runtimeEvents.push(`${category}:${message}:${details?.phase}`);
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
isTelegramGetUpdatesConflictError(
|
||||
new Error("HTTP 409: Conflict: terminated by other getUpdates request"),
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(statusMessages, [
|
||||
"sleep:1000",
|
||||
"sleep:1000",
|
||||
"sleep:3000",
|
||||
"sleep:3000",
|
||||
]);
|
||||
assert.equal(runtimeEvents.length, 4);
|
||||
});
|
||||
|
||||
test("Poll loop reports retryable errors and sleeps before retrying", async () => {
|
||||
const config = { botToken: "123:abc", lastUpdateId: 1 };
|
||||
const statusMessages: string[] = [];
|
||||
const runtimeEvents: string[] = [];
|
||||
let calls = 0;
|
||||
await runTelegramPollLoop({
|
||||
ctx: TEST_CONTEXT,
|
||||
signal: new AbortController().signal,
|
||||
config,
|
||||
deleteWebhook: async () => {},
|
||||
getUpdates: async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
throw new Error("network down");
|
||||
}
|
||||
throw new DOMException("stop", "AbortError");
|
||||
},
|
||||
persistConfig: async () => {},
|
||||
handleUpdate: async () => {},
|
||||
onErrorStatus: (message) => {
|
||||
statusMessages.push(`error:${message}`);
|
||||
},
|
||||
onStatusReset: () => {
|
||||
statusMessages.push("reset");
|
||||
},
|
||||
sleep: async (ms) => {
|
||||
statusMessages.push(`sleep:${ms}`);
|
||||
},
|
||||
recordRuntimeEvent: (category, error, details) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
runtimeEvents.push(`${category}:${message}:${details?.phase}`);
|
||||
},
|
||||
});
|
||||
assert.deepEqual(statusMessages, [
|
||||
"error:network down",
|
||||
"sleep:3000",
|
||||
"reset",
|
||||
]);
|
||||
assert.deepEqual(runtimeEvents, ["polling:network down:loop"]);
|
||||
});
|
||||
@@ -0,0 +1,472 @@
|
||||
/**
|
||||
* Regression tests for the Telegram preview domain
|
||||
* Covers native rich draft previews, safe-prefix selection, and finalization behavior
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
allocateTelegramDraftId,
|
||||
buildTelegramPreviewFinalText,
|
||||
clearTelegramPreview,
|
||||
createTelegramAssistantPreviewRuntime,
|
||||
createTelegramNativeMarkdownPreviewFinalizer,
|
||||
createTelegramPreviewControllerRuntime,
|
||||
createTelegramPreviewRuntimeState,
|
||||
finalizeTelegramPreview,
|
||||
flushTelegramPreview,
|
||||
getSafeTelegramRichMarkdownDraftPrefix,
|
||||
shouldUseTelegramDraftPreview,
|
||||
type TelegramPreviewRuntimeState,
|
||||
} from "../lib/preview.ts";
|
||||
import { createTelegramThreadTarget } from "../lib/target.ts";
|
||||
|
||||
function createPreviewRuntimeHarness(state?: TelegramPreviewRuntimeState) {
|
||||
let previewState = state;
|
||||
let draftSupport: "unknown" | "supported" = "unknown";
|
||||
let nextDraftId = 10;
|
||||
const events: string[] = [];
|
||||
return {
|
||||
events,
|
||||
getState: () => previewState,
|
||||
getDraftSupport: () => draftSupport,
|
||||
deps: {
|
||||
getState: () => previewState,
|
||||
setState: (nextState: TelegramPreviewRuntimeState | undefined) => {
|
||||
previewState = nextState;
|
||||
},
|
||||
maxMessageLength: 100,
|
||||
getDraftSupport: () => draftSupport,
|
||||
setDraftSupport: (support: "unknown" | "supported") => {
|
||||
draftSupport = support;
|
||||
},
|
||||
allocateDraftId: () => nextDraftId++,
|
||||
sendDraft: async (
|
||||
chatId: number,
|
||||
draftId: number,
|
||||
text?: string,
|
||||
_options?: { message_thread_id?: number },
|
||||
) => {
|
||||
events.push(`draft:${chatId}:${draftId}:${text}`);
|
||||
},
|
||||
canSend: undefined as undefined | (() => boolean),
|
||||
recordRuntimeEvent: (
|
||||
category: string,
|
||||
_error: unknown,
|
||||
details?: Record<string, unknown>,
|
||||
) => {
|
||||
events.push(`${category}:${details?.phase}`);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("Preview helpers create draft-only state and allocate draft ids", () => {
|
||||
assert.deepEqual(createTelegramPreviewRuntimeState(), {
|
||||
mode: "draft",
|
||||
pendingText: "",
|
||||
lastSentText: "",
|
||||
});
|
||||
assert.equal(allocateTelegramDraftId(0, 2), 1);
|
||||
assert.equal(allocateTelegramDraftId(1, 2), 2);
|
||||
assert.equal(allocateTelegramDraftId(2, 2), 1);
|
||||
});
|
||||
|
||||
test("Preview final text prefers pending text then last sent draft text", () => {
|
||||
assert.equal(
|
||||
buildTelegramPreviewFinalText({
|
||||
mode: "draft",
|
||||
pendingText: " final ",
|
||||
lastSentText: "old",
|
||||
}),
|
||||
"final",
|
||||
);
|
||||
assert.equal(
|
||||
buildTelegramPreviewFinalText({
|
||||
mode: "draft",
|
||||
pendingText: " ",
|
||||
lastSentText: " old ",
|
||||
}),
|
||||
"old",
|
||||
);
|
||||
assert.equal(
|
||||
buildTelegramPreviewFinalText({
|
||||
mode: "draft",
|
||||
pendingText: " ",
|
||||
lastSentText: " ",
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("Preview helpers always use native rich drafts", () => {
|
||||
assert.equal(
|
||||
shouldUseTelegramDraftPreview({ draftSupport: "unknown" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldUseTelegramDraftPreview({
|
||||
draftSupport: "supported",
|
||||
snapshot: { text: "ok" },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("Native Markdown draft prefix keeps only structurally closed Markdown", () => {
|
||||
assert.equal(
|
||||
getSafeTelegramRichMarkdownDraftPrefix("**Bold** and *italic*", 100),
|
||||
"**Bold** and *italic*",
|
||||
);
|
||||
assert.equal(
|
||||
getSafeTelegramRichMarkdownDraftPrefix("**Bold** and *ita", 100),
|
||||
"**Bold** and",
|
||||
);
|
||||
assert.equal(
|
||||
getSafeTelegramRichMarkdownDraftPrefix("**Heading still streaming", 100),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
getSafeTelegramRichMarkdownDraftPrefix("Before\n\n```ts\nconst x = 1", 100),
|
||||
"Before",
|
||||
);
|
||||
assert.equal(
|
||||
getSafeTelegramRichMarkdownDraftPrefix(
|
||||
"Before\n\n```ts\nconst x = 1\n```",
|
||||
100,
|
||||
),
|
||||
"Before\n\n```ts\nconst x = 1\n```",
|
||||
);
|
||||
assert.equal(
|
||||
getSafeTelegramRichMarkdownDraftPrefix(
|
||||
"[OpenAI](https://openai.com) and [half",
|
||||
100,
|
||||
),
|
||||
"[OpenAI](https://openai.com) and",
|
||||
);
|
||||
assert.equal(
|
||||
getSafeTelegramRichMarkdownDraftPrefix("Block math:\n\n$$\nx^2", 100),
|
||||
"Block math:",
|
||||
);
|
||||
assert.equal(
|
||||
getSafeTelegramRichMarkdownDraftPrefix(
|
||||
"<!-- telegram_button label=Ok",
|
||||
100,
|
||||
),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("Preview runtime sends only safe native markdown draft prefixes", async () => {
|
||||
const harness = createPreviewRuntimeHarness({
|
||||
mode: "draft",
|
||||
draftId: 10,
|
||||
pendingText: "**Bold** and *ita",
|
||||
lastSentText: "",
|
||||
});
|
||||
await flushTelegramPreview(7, harness.deps);
|
||||
assert.deepEqual(harness.events, ["draft:7:10:**Bold** and"]);
|
||||
assert.equal(harness.getState()?.lastSentText, "**Bold** and");
|
||||
assert.equal(harness.getDraftSupport(), "supported");
|
||||
});
|
||||
|
||||
test("Preview runtime sends draft previews into thread target", async () => {
|
||||
const harness = createPreviewRuntimeHarness({
|
||||
mode: "draft",
|
||||
draftId: 10,
|
||||
pendingText: "thread draft",
|
||||
lastSentText: "",
|
||||
});
|
||||
const sentOptions: unknown[] = [];
|
||||
harness.deps.sendDraft = async (
|
||||
_chatId: number,
|
||||
_draftId: number,
|
||||
_text?: string,
|
||||
options?: { message_thread_id?: number },
|
||||
) => {
|
||||
sentOptions.push(options);
|
||||
};
|
||||
await flushTelegramPreview(7, harness.deps, {
|
||||
target: createTelegramThreadTarget(7, 42),
|
||||
});
|
||||
assert.deepEqual(sentOptions, [{ message_thread_id: 42 }]);
|
||||
});
|
||||
|
||||
test("Preview runtime clears thread drafts in thread target", async () => {
|
||||
const harness = createPreviewRuntimeHarness({
|
||||
mode: "draft",
|
||||
draftId: 10,
|
||||
pendingText: "new draft",
|
||||
lastSentText: "",
|
||||
});
|
||||
const sentOptions: unknown[] = [];
|
||||
harness.deps.sendDraft = async (
|
||||
_chatId: number,
|
||||
_draftId: number,
|
||||
_text?: string,
|
||||
options?: { message_thread_id?: number },
|
||||
) => {
|
||||
sentOptions.push(options);
|
||||
};
|
||||
await clearTelegramPreview(7, harness.deps, {
|
||||
target: createTelegramThreadTarget(7, 42),
|
||||
});
|
||||
assert.deepEqual(sentOptions, [{ message_thread_id: 42 }]);
|
||||
});
|
||||
|
||||
test("Preview runtime does not send thinking placeholder for unsafe draft tails", async () => {
|
||||
const harness = createPreviewRuntimeHarness({
|
||||
mode: "draft",
|
||||
pendingText: "<!-- telegram_button label=Ok",
|
||||
lastSentText: "",
|
||||
});
|
||||
await flushTelegramPreview(7, harness.deps);
|
||||
assert.deepEqual(harness.events, []);
|
||||
assert.equal(harness.getState()?.draftId, undefined);
|
||||
assert.equal(harness.getState()?.lastSentText, "");
|
||||
});
|
||||
|
||||
test("Preview runtime skips unchanged unsafe draft tails", async () => {
|
||||
const harness = createPreviewRuntimeHarness({
|
||||
mode: "draft",
|
||||
pendingText: "**Bold** and *ita",
|
||||
lastSentText: "**Bold** and",
|
||||
});
|
||||
await flushTelegramPreview(7, harness.deps);
|
||||
assert.deepEqual(harness.events, []);
|
||||
});
|
||||
|
||||
test("Preview runtime records draft failures without plain fallback", async () => {
|
||||
const harness = createPreviewRuntimeHarness({
|
||||
mode: "draft",
|
||||
draftId: 10,
|
||||
pendingText: "abcdef",
|
||||
lastSentText: "",
|
||||
});
|
||||
harness.deps.sendDraft = async () => {
|
||||
throw new Error("draft rejected partial markdown");
|
||||
};
|
||||
await flushTelegramPreview(7, harness.deps);
|
||||
assert.deepEqual(harness.events, ["preview:draft"]);
|
||||
assert.equal(harness.getState()?.mode, "draft");
|
||||
assert.equal(harness.getDraftSupport(), "unknown");
|
||||
});
|
||||
|
||||
test("Preview runtime serializes overlapping flush requests", async () => {
|
||||
const harness = createPreviewRuntimeHarness({
|
||||
mode: "draft",
|
||||
draftId: 44,
|
||||
pendingText: "first",
|
||||
lastSentText: "",
|
||||
});
|
||||
let releaseDraft: (() => void) | undefined;
|
||||
harness.deps.sendDraft = async (chatId, draftId, text) => {
|
||||
harness.events.push(`draft:${chatId}:${draftId}:${text}`);
|
||||
if (!releaseDraft) {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseDraft = resolve;
|
||||
});
|
||||
}
|
||||
};
|
||||
const firstFlush = flushTelegramPreview(7, harness.deps);
|
||||
await Promise.resolve();
|
||||
const state = harness.getState();
|
||||
assert.ok(state);
|
||||
state.pendingText = "second";
|
||||
const secondFlush = flushTelegramPreview(7, harness.deps);
|
||||
releaseDraft?.();
|
||||
await Promise.all([firstFlush, secondFlush]);
|
||||
assert.deepEqual(harness.events, ["draft:7:44:first", "draft:7:44:second"]);
|
||||
assert.equal(harness.getState()?.lastSentText, "second");
|
||||
});
|
||||
|
||||
test("Preview runtime clears active rich draft on explicit clear", async () => {
|
||||
const harness = createPreviewRuntimeHarness({
|
||||
mode: "draft",
|
||||
draftId: 10,
|
||||
pendingText: "new draft",
|
||||
lastSentText: "",
|
||||
});
|
||||
await clearTelegramPreview(7, harness.deps);
|
||||
assert.deepEqual(harness.events, ["draft:7:10:undefined"]);
|
||||
assert.equal(harness.getState(), undefined);
|
||||
});
|
||||
|
||||
test("Preview runtime optional send gate clears without sending new content", async () => {
|
||||
const harness = createPreviewRuntimeHarness({
|
||||
mode: "draft",
|
||||
pendingText: "**hello**",
|
||||
lastSentText: "",
|
||||
});
|
||||
harness.deps.canSend = () => false;
|
||||
await flushTelegramPreview(7, harness.deps);
|
||||
assert.deepEqual(harness.events, []);
|
||||
assert.equal(harness.getState(), undefined);
|
||||
});
|
||||
|
||||
test("Native Markdown finalizer waits for active draft flush before final reply", async () => {
|
||||
const harness = createPreviewRuntimeHarness({
|
||||
mode: "draft",
|
||||
draftId: 10,
|
||||
pendingText: "draft body",
|
||||
lastSentText: "",
|
||||
});
|
||||
const releases: Array<() => void> = [];
|
||||
harness.deps.sendDraft = async (chatId, draftId, text) => {
|
||||
harness.events.push(`draft-start:${chatId}:${draftId}:${text}`);
|
||||
await new Promise<void>((resolve) => {
|
||||
releases.push(resolve);
|
||||
});
|
||||
harness.events.push(`draft-finish:${chatId}:${draftId}:${text}`);
|
||||
};
|
||||
const finalizeMarkdown = createTelegramNativeMarkdownPreviewFinalizer({
|
||||
getState: harness.deps.getState,
|
||||
clear: (chatId) => clearTelegramPreview(chatId, harness.deps),
|
||||
discard: () => harness.deps.setState(undefined),
|
||||
sendMarkdownReply: async (chatId, replyToMessageId, markdown) => {
|
||||
harness.events.push(
|
||||
`final:${chatId}:${replyToMessageId ?? "none"}:${markdown}`,
|
||||
);
|
||||
return 88;
|
||||
},
|
||||
});
|
||||
const flush = flushTelegramPreview(7, harness.deps);
|
||||
await Promise.resolve();
|
||||
const finalize = finalizeMarkdown(7, "final body", 55);
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(harness.events, ["draft-start:7:10:draft body"]);
|
||||
releases.shift()?.();
|
||||
await Promise.all([flush, finalize]);
|
||||
assert.deepEqual(harness.events, [
|
||||
"draft-start:7:10:draft body",
|
||||
"draft-finish:7:10:draft body",
|
||||
"final:7:55:final body",
|
||||
]);
|
||||
assert.equal(harness.getState(), undefined);
|
||||
});
|
||||
|
||||
test("Native Markdown finalizer stops when preview generation changes during flush", async () => {
|
||||
const harness = createPreviewRuntimeHarness({
|
||||
mode: "draft",
|
||||
draftId: 10,
|
||||
pendingText: "old draft",
|
||||
lastSentText: "",
|
||||
});
|
||||
let release: (() => void) | undefined;
|
||||
harness.deps.sendDraft = async () =>
|
||||
new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let finalSends = 0;
|
||||
const finalizeMarkdown = createTelegramNativeMarkdownPreviewFinalizer({
|
||||
getState: harness.deps.getState,
|
||||
clear: (chatId) => clearTelegramPreview(chatId, harness.deps),
|
||||
discard: () => harness.deps.setState(undefined),
|
||||
sendMarkdownReply: async () => {
|
||||
finalSends += 1;
|
||||
return 88;
|
||||
},
|
||||
});
|
||||
const flush = flushTelegramPreview(7, harness.deps);
|
||||
await Promise.resolve();
|
||||
const finalize = finalizeMarkdown(7, "old final", 55);
|
||||
await Promise.resolve();
|
||||
harness.deps.setState({
|
||||
mode: "draft",
|
||||
draftId: 11,
|
||||
pendingText: "new draft",
|
||||
lastSentText: "",
|
||||
});
|
||||
release?.();
|
||||
|
||||
assert.equal(await finalize, false);
|
||||
await flush;
|
||||
assert.equal(finalSends, 0);
|
||||
assert.equal(harness.getState()?.draftId, 11);
|
||||
});
|
||||
|
||||
test("Plain preview finalization does not send fallback messages", async () => {
|
||||
const harness = createPreviewRuntimeHarness({
|
||||
mode: "draft",
|
||||
draftId: 10,
|
||||
pendingText: "final body",
|
||||
lastSentText: "final",
|
||||
});
|
||||
assert.equal(await finalizeTelegramPreview(7, harness.deps), false);
|
||||
assert.deepEqual(harness.events, ["draft:7:10:final body"]);
|
||||
assert.equal(harness.getState(), undefined);
|
||||
});
|
||||
|
||||
test("Assistant preview runtime finalizes previous markdown through native reply sender", async () => {
|
||||
const events: string[] = [];
|
||||
const runtime = createTelegramAssistantPreviewRuntime<{
|
||||
role: string;
|
||||
text?: string;
|
||||
}>({
|
||||
getActiveTurn: () => ({
|
||||
chatId: 7,
|
||||
replyToMessageId: 24,
|
||||
target: createTelegramThreadTarget(7, 42),
|
||||
}),
|
||||
isAssistantMessage: (message) => message.role === "assistant",
|
||||
getMessageText: (message) => message.text ?? "",
|
||||
maxMessageLength: 100,
|
||||
sendDraft: async () => {},
|
||||
sendMarkdownReply: async (chatId, replyToMessageId, markdown, options) => {
|
||||
events.push(
|
||||
`native-final:${chatId}:${replyToMessageId ?? "none"}:${markdown}:${
|
||||
options?.target?.threadId ?? "private"
|
||||
}`,
|
||||
);
|
||||
return 99;
|
||||
},
|
||||
});
|
||||
runtime.setState({
|
||||
mode: "draft",
|
||||
pendingText: "**previous**",
|
||||
lastSentText: "**previous**",
|
||||
});
|
||||
await runtime.onMessageStart({ message: { role: "assistant" } });
|
||||
assert.deepEqual(events, ["native-final:7:24:**previous**:42"]);
|
||||
assert.equal(runtime.getState()?.pendingText, "");
|
||||
});
|
||||
|
||||
test("Assistant preview runtime suppresses text preview for voice-tagged turns", async () => {
|
||||
const events: string[] = [];
|
||||
const activeTurn = { chatId: 7, voiceReplyPreferred: true };
|
||||
const runtime = createTelegramAssistantPreviewRuntime<{
|
||||
role: string;
|
||||
text?: string;
|
||||
}>({
|
||||
getActiveTurn: () => activeTurn,
|
||||
isAssistantMessage: (message) => message.role === "assistant",
|
||||
getMessageText: (message) => message.text ?? "",
|
||||
maxMessageLength: 100,
|
||||
sendDraft: async () => {
|
||||
events.push("draft");
|
||||
},
|
||||
sendMarkdownReply: async () => undefined,
|
||||
});
|
||||
await runtime.onMessageStart({ message: { role: "assistant" } });
|
||||
assert.equal(runtime.getState(), undefined);
|
||||
await runtime.onMessageUpdate({
|
||||
message: { role: "assistant", text: "hello" },
|
||||
});
|
||||
assert.equal(runtime.getState(), undefined);
|
||||
assert.deepEqual(events, []);
|
||||
});
|
||||
|
||||
test("Preview controller runtime binds Bot API draft transport", async () => {
|
||||
const events: string[] = [];
|
||||
const runtime = createTelegramPreviewControllerRuntime({
|
||||
sendDraft: async (chatId, draftId, text) => {
|
||||
events.push(`draft:${chatId}:${draftId}:${text}`);
|
||||
},
|
||||
maxDraftId: 10,
|
||||
});
|
||||
runtime.resetState();
|
||||
runtime.setPendingText("hello".repeat(60));
|
||||
await runtime.flush(7);
|
||||
assert.deepEqual(events, [`draft:7:1:${"hello".repeat(60)}`]);
|
||||
});
|
||||
@@ -0,0 +1,736 @@
|
||||
/**
|
||||
* Process-level shutdown regressions for the Telegram extension
|
||||
* Zones: process lifecycle, telegram polling, shutdown cleanup
|
||||
* Verifies session shutdown releases or unrefs runtime handles so headless processes can exit
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const REPO_ROOT = fileURLToPath(new URL("..", import.meta.url));
|
||||
const PI_CLI_AVAILABLE = (() => {
|
||||
const result = spawnSync("pi", ["--version"], { stdio: "ignore" });
|
||||
return !result.error && result.status === 0;
|
||||
})();
|
||||
|
||||
function runNodeScript(
|
||||
script: string,
|
||||
options: { env?: NodeJS.ProcessEnv; timeoutMs?: number } = {},
|
||||
): Promise<{
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}> {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
["--experimental-strip-types", "--input-type=module", "-e", script],
|
||||
{
|
||||
cwd: REPO_ROOT,
|
||||
env: { ...process.env, ...options.env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
reject(
|
||||
new Error(
|
||||
`Child process did not exit. stdout=${stdout} stderr=${stderr}`,
|
||||
),
|
||||
);
|
||||
}, options.timeoutMs ?? 5000);
|
||||
timeout.unref?.();
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
clearTimeout(timeout);
|
||||
resolve({ code, signal, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForFileText(
|
||||
path: string,
|
||||
predicate: (text: string) => boolean,
|
||||
timeoutMs = 3000,
|
||||
): Promise<string> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const text = await readFile(path, "utf8").catch(() => "");
|
||||
if (predicate(text)) return text;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
const text = await readFile(path, "utf8").catch(() => "");
|
||||
assert.fail(`Timed out waiting for ${path}. Current text: ${text}`);
|
||||
}
|
||||
|
||||
function runPiPrint(
|
||||
args: string[],
|
||||
options: { env?: NodeJS.ProcessEnv; timeoutMs?: number } = {},
|
||||
): Promise<{
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}> {
|
||||
const child = spawn("pi", args, {
|
||||
cwd: REPO_ROOT,
|
||||
env: { ...process.env, ...options.env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
reject(
|
||||
new Error(`pi -p did not exit. stdout=${stdout} stderr=${stderr}`),
|
||||
);
|
||||
}, options.timeoutMs ?? 10_000);
|
||||
timeout.unref?.();
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
clearTimeout(timeout);
|
||||
resolve({ code, signal, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function createPiPrintFixtureExtension(tempDir: string): Promise<string> {
|
||||
const fixturePath = join(tempDir, "fixture-provider.ts");
|
||||
await writeFile(
|
||||
fixturePath,
|
||||
`import { appendFileSync, writeFileSync } from "node:fs";\n` +
|
||||
`import { join } from "node:path";\n` +
|
||||
`import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";\n\n` +
|
||||
`export default function (pi) {\n` +
|
||||
` const agentDir = process.env.PI_CODING_AGENT_DIR;\n` +
|
||||
` if (process.env.PI_TELEGRAM_TEST_LOCK_MODE === "owner" && agentDir) {\n` +
|
||||
` writeFileSync(join(agentDir, "locks.json"), JSON.stringify({ "@llblab/pi-telegram": { pid: process.pid } }) + "\\n");\n` +
|
||||
` }\n` +
|
||||
` pi.on("session_start", (_event, ctx) => {\n` +
|
||||
` const forcedMode = process.env.PI_TELEGRAM_TEST_CTX_MODE;\n` +
|
||||
` if (forcedMode && ctx.mode === undefined) ctx.mode = forcedMode;\n` +
|
||||
` const methodMarker = process.env.PI_TELEGRAM_TEST_METHOD_MARKER;\n` +
|
||||
` if (methodMarker) appendFileSync(methodMarker, "session-mode:" + String(ctx.mode) + "\\n");\n` +
|
||||
` });\n` +
|
||||
` globalThis.fetch = async (input, init = {}) => {\n` +
|
||||
` const method = String(input).split("/").at(-1);\n` +
|
||||
` const methodMarker = process.env.PI_TELEGRAM_TEST_METHOD_MARKER;\n` +
|
||||
` if (methodMarker) appendFileSync(methodMarker, method + "\\n");\n` +
|
||||
` if (method === "deleteWebhook") return { json: async () => ({ ok: true, result: true }) };\n` +
|
||||
` if (method === "sendChatAction") return { json: async () => ({ ok: true, result: true }) };\n` +
|
||||
` if (method === "sendMessage") {\n` +
|
||||
` const marker = process.env.PI_TELEGRAM_TEST_SEND_MARKER;\n` +
|
||||
` if (marker) writeFileSync(marker, String(init.body ?? "") + "\\n");\n` +
|
||||
` return { json: async () => ({ ok: true, result: { message_id: 100 } }) };\n` +
|
||||
` }\n` +
|
||||
` if (method === "getUpdates") {\n` +
|
||||
` const signal = init.signal;\n` +
|
||||
` return new Promise((_, reject) => {\n` +
|
||||
` const abort = () => reject(new DOMException("stop", "AbortError"));\n` +
|
||||
` if (signal?.aborted) { abort(); return; }\n` +
|
||||
` signal?.addEventListener("abort", abort, { once: true });\n` +
|
||||
` });\n` +
|
||||
` }\n` +
|
||||
` throw new Error("Unexpected Telegram API method: " + method);\n` +
|
||||
` };\n` +
|
||||
` pi.registerProvider("fixture", {\n` +
|
||||
` name: "Fixture Provider", baseUrl: "http://fixture.local", apiKey: "fixture", api: "fixture-stream",\n` +
|
||||
` models: [{ id: "echo", name: "Echo", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 8000, maxTokens: 1000 }],\n` +
|
||||
` streamSimple(model) {\n` +
|
||||
` const stream = createAssistantMessageEventStream();\n` +
|
||||
` queueMicrotask(() => {\n` +
|
||||
` const output = { role: "assistant", content: [], api: model.api, provider: model.provider, model: model.id, usage: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0, totalTokens: 3, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, stopReason: "stop", timestamp: Date.now() };\n` +
|
||||
` const text = "fixture ok";\n` +
|
||||
` output.content.push({ type: "text", text: "" });\n` +
|
||||
` stream.push({ type: "start", partial: output });\n` +
|
||||
` stream.push({ type: "text_start", contentIndex: 0, partial: output });\n` +
|
||||
` output.content[0].text = text;\n` +
|
||||
` stream.push({ type: "text_delta", contentIndex: 0, delta: text, partial: output });\n` +
|
||||
` stream.push({ type: "text_end", contentIndex: 0, content: text, partial: output });\n` +
|
||||
` stream.push({ type: "done", reason: "stop", message: output });\n` +
|
||||
` stream.end();\n` +
|
||||
` });\n` +
|
||||
` return stream;\n` +
|
||||
` },\n` +
|
||||
` });\n` +
|
||||
`}\n`,
|
||||
"utf8",
|
||||
);
|
||||
return fixturePath;
|
||||
}
|
||||
|
||||
async function createPiPrintAgentDir(
|
||||
config: Record<string, unknown>,
|
||||
locks: Record<string, unknown> = {},
|
||||
): Promise<string> {
|
||||
const agentDir = await mkdtemp(join(tmpdir(), "pi-telegram-pi-print-"));
|
||||
await writeFile(
|
||||
join(agentDir, "telegram.json"),
|
||||
JSON.stringify(config, null, "\t") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(agentDir, "locks.json"),
|
||||
JSON.stringify(locks, null, "\t") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
return agentDir;
|
||||
}
|
||||
|
||||
async function runPiPrintWithTelegram(
|
||||
agentDir: string,
|
||||
fixtureExtension: string,
|
||||
env: NodeJS.ProcessEnv = {},
|
||||
) {
|
||||
return runPiPrint(
|
||||
[
|
||||
"-p",
|
||||
"--offline",
|
||||
"--no-extensions",
|
||||
"--no-skills",
|
||||
"--no-prompt-templates",
|
||||
"--no-themes",
|
||||
"--no-context-files",
|
||||
"--no-session",
|
||||
"--extension",
|
||||
fixtureExtension,
|
||||
"--extension",
|
||||
join(REPO_ROOT, "index.ts"),
|
||||
"--provider",
|
||||
"fixture",
|
||||
"--model",
|
||||
"echo",
|
||||
"Say OK.",
|
||||
],
|
||||
{ env: { PI_CODING_AGENT_DIR: agentDir, ...env } },
|
||||
);
|
||||
}
|
||||
|
||||
test("Child process sharing the agent dir does not poll while parent owns Telegram lock", async () => {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "pi-telegram-process-lock-"));
|
||||
const markerPath = join(tempDir, "telegram-methods.log");
|
||||
const stopPath = join(tempDir, "stop");
|
||||
const agentDir = await createPiPrintAgentDir({
|
||||
botToken: "123:abc",
|
||||
allowedUserId: 77,
|
||||
lastUpdateId: 0,
|
||||
});
|
||||
const extensionUrl = new URL("../index.ts", import.meta.url).href;
|
||||
const parentScript = `
|
||||
import { appendFileSync, existsSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const agentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
const markerPath = process.env.PI_TELEGRAM_TEST_METHOD_MARKER;
|
||||
const stopPath = process.env.PI_TELEGRAM_TEST_STOP_PATH;
|
||||
const cwd = "/repo/parent-owner";
|
||||
writeFileSync(
|
||||
join(agentDir, "locks.json"),
|
||||
JSON.stringify({ "@llblab/pi-telegram": { pid: process.pid, cwd } }) + "\\n",
|
||||
);
|
||||
globalThis.fetch = async (input, init = {}) => {
|
||||
const method = String(input).split("/").at(-1);
|
||||
appendFileSync(markerPath, "parent:" + method + "\\n");
|
||||
if (method === "deleteWebhook") {
|
||||
return { json: async () => ({ ok: true, result: true }) };
|
||||
}
|
||||
if (method === "getUpdates") {
|
||||
const signal = init.signal;
|
||||
return new Promise((_, reject) => {
|
||||
const abort = () => reject(new DOMException("stop", "AbortError"));
|
||||
if (signal?.aborted) { abort(); return; }
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
});
|
||||
}
|
||||
throw new Error("Unexpected Telegram API method: " + method);
|
||||
};
|
||||
const handlers = new Map();
|
||||
const pi = {
|
||||
on: (event, handler) => handlers.set(event, handler),
|
||||
registerCommand: () => {},
|
||||
registerTool: () => {},
|
||||
sendUserMessage: () => {},
|
||||
getCommands: () => [],
|
||||
getThinkingLevel: () => "medium",
|
||||
};
|
||||
const extension = (await import(${JSON.stringify(extensionUrl)})).default;
|
||||
extension(pi);
|
||||
const ctx = {
|
||||
cwd,
|
||||
hasUI: false,
|
||||
ui: {
|
||||
theme: { fg: (_token, text) => text },
|
||||
setStatus: () => {},
|
||||
notify: () => {},
|
||||
},
|
||||
isIdle: () => true,
|
||||
hasPendingMessages: () => false,
|
||||
abort: () => {},
|
||||
};
|
||||
await handlers.get("session_start")?.({}, ctx);
|
||||
const timer = setInterval(async () => {
|
||||
if (!existsSync(stopPath)) return;
|
||||
clearInterval(timer);
|
||||
await handlers.get("session_shutdown")?.({}, ctx);
|
||||
process.exit(0);
|
||||
}, 20);
|
||||
setTimeout(() => process.exit(2), 5000).unref?.();
|
||||
`;
|
||||
const parent = spawn(
|
||||
process.execPath,
|
||||
["--experimental-strip-types", "--input-type=module", "-e", parentScript],
|
||||
{
|
||||
cwd: REPO_ROOT,
|
||||
env: {
|
||||
...process.env,
|
||||
PI_CODING_AGENT_DIR: agentDir,
|
||||
PI_TELEGRAM_TEST_METHOD_MARKER: markerPath,
|
||||
PI_TELEGRAM_TEST_STOP_PATH: stopPath,
|
||||
},
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
},
|
||||
);
|
||||
let parentStderr = "";
|
||||
parent.stderr.setEncoding("utf8");
|
||||
parent.stderr.on("data", (chunk) => {
|
||||
parentStderr += chunk;
|
||||
});
|
||||
const parentExit = new Promise<{ code: number | null }>((resolve) => {
|
||||
parent.on("exit", (code) => resolve({ code }));
|
||||
});
|
||||
try {
|
||||
await waitForFileText(markerPath, (text) =>
|
||||
text.includes("parent:getUpdates"),
|
||||
);
|
||||
const childScript = `
|
||||
import { appendFileSync } from "node:fs";
|
||||
const markerPath = process.env.PI_TELEGRAM_TEST_METHOD_MARKER;
|
||||
globalThis.fetch = async (input) => {
|
||||
const method = String(input).split("/").at(-1);
|
||||
appendFileSync(markerPath, "child:" + method + "\\n");
|
||||
return { json: async () => ({ ok: true, result: true }) };
|
||||
};
|
||||
const handlers = new Map();
|
||||
const pi = {
|
||||
on: (event, handler) => handlers.set(event, handler),
|
||||
registerCommand: () => {},
|
||||
registerTool: () => {},
|
||||
sendUserMessage: () => {},
|
||||
getCommands: () => [],
|
||||
getThinkingLevel: () => "medium",
|
||||
};
|
||||
const extension = (await import(${JSON.stringify(extensionUrl)})).default;
|
||||
extension(pi);
|
||||
const ctx = {
|
||||
cwd: "/repo/parent-owner",
|
||||
hasUI: false,
|
||||
ui: {
|
||||
theme: { fg: (_token, text) => text },
|
||||
setStatus: () => {},
|
||||
notify: () => {},
|
||||
},
|
||||
isIdle: () => true,
|
||||
hasPendingMessages: () => false,
|
||||
abort: () => {},
|
||||
};
|
||||
await handlers.get("session_start")?.({}, ctx);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
`;
|
||||
const child = await runNodeScript(childScript, {
|
||||
env: {
|
||||
PI_CODING_AGENT_DIR: agentDir,
|
||||
PI_TELEGRAM_TEST_METHOD_MARKER: markerPath,
|
||||
},
|
||||
});
|
||||
assert.equal(child.code, 0, child.stderr);
|
||||
const methods = await readFile(markerPath, "utf8");
|
||||
assert.match(methods, /parent:getUpdates/);
|
||||
assert.doesNotMatch(methods, /child:getUpdates/);
|
||||
} finally {
|
||||
let parentError: unknown;
|
||||
try {
|
||||
await writeFile(stopPath, "stop", "utf8");
|
||||
const timeout = new Promise<never>((_resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
parent.kill("SIGKILL");
|
||||
reject(new Error(`Parent did not exit. stderr=${parentStderr}`));
|
||||
}, 2000);
|
||||
timer.unref?.();
|
||||
});
|
||||
const { code } = await Promise.race([parentExit, timeout]);
|
||||
if (code !== 0)
|
||||
parentError = new Error(
|
||||
`Parent exited with ${code}. stderr=${parentStderr}`,
|
||||
);
|
||||
} catch (error) {
|
||||
parentError = error;
|
||||
}
|
||||
await rm(agentDir, { recursive: true, force: true });
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
if (parentError) throw parentError;
|
||||
}
|
||||
});
|
||||
|
||||
test("Direct Telegram tools refuse delivery from a non-owner process", async () => {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "pi-telegram-direct-owner-"));
|
||||
const markerPath = join(tempDir, "telegram-methods.log");
|
||||
const attachmentPath = join(tempDir, "demo.txt");
|
||||
await writeFile(attachmentPath, "demo", "utf8");
|
||||
const agentDir = await createPiPrintAgentDir(
|
||||
{
|
||||
botToken: "123:abc",
|
||||
allowedUserId: 77,
|
||||
lastUpdateId: 0,
|
||||
},
|
||||
{
|
||||
"@llblab/pi-telegram": {
|
||||
pid: process.pid,
|
||||
cwd: "/repo/live-owner",
|
||||
},
|
||||
},
|
||||
);
|
||||
const extensionUrl = new URL("../index.ts", import.meta.url).href;
|
||||
try {
|
||||
const script = `
|
||||
import { appendFileSync } from "node:fs";
|
||||
const markerPath = process.env.PI_TELEGRAM_TEST_METHOD_MARKER;
|
||||
const attachmentPath = process.env.PI_TELEGRAM_TEST_ATTACHMENT_PATH;
|
||||
globalThis.fetch = async (input) => {
|
||||
const method = String(input).split("/").at(-1);
|
||||
appendFileSync(markerPath, method + "\\n");
|
||||
return { json: async () => ({ ok: true, result: { message_id: 100 } }) };
|
||||
};
|
||||
const handlers = new Map();
|
||||
const tools = new Map();
|
||||
const pi = {
|
||||
on: (event, handler) => handlers.set(event, handler),
|
||||
registerCommand: () => {},
|
||||
registerTool: (definition) => tools.set(definition.name, definition),
|
||||
sendUserMessage: () => {},
|
||||
getCommands: () => [],
|
||||
getThinkingLevel: () => "medium",
|
||||
};
|
||||
const extension = (await import(${JSON.stringify(extensionUrl)})).default;
|
||||
extension(pi);
|
||||
const ctx = {
|
||||
cwd: "/repo/non-owner",
|
||||
hasUI: false,
|
||||
ui: {
|
||||
theme: { fg: (_token, text) => text },
|
||||
setStatus: () => {},
|
||||
notify: () => {},
|
||||
},
|
||||
isIdle: () => true,
|
||||
hasPendingMessages: () => false,
|
||||
abort: () => {},
|
||||
};
|
||||
await handlers.get("session_start")?.({}, ctx);
|
||||
const failures = [];
|
||||
for (const [name, params] of [
|
||||
["telegram_message", { text: "hello" }],
|
||||
["telegram_attach", { paths: [attachmentPath] }],
|
||||
]) {
|
||||
try {
|
||||
await tools.get(name)?.execute("tool-call", params);
|
||||
failures.push(name + ":unexpected-success");
|
||||
} catch (error) {
|
||||
failures.push(name + ":" + error.message);
|
||||
}
|
||||
}
|
||||
await handlers.get("session_shutdown")?.({}, ctx);
|
||||
console.log(failures.join("\\n"));
|
||||
`;
|
||||
const result = await runNodeScript(script, {
|
||||
env: {
|
||||
PI_CODING_AGENT_DIR: agentDir,
|
||||
PI_TELEGRAM_TEST_METHOD_MARKER: markerPath,
|
||||
PI_TELEGRAM_TEST_ATTACHMENT_PATH: attachmentPath,
|
||||
},
|
||||
});
|
||||
assert.equal(result.code, 0, result.stderr);
|
||||
assert.match(
|
||||
result.stdout,
|
||||
/telegram_message:Telegram direct delivery requires this Pi instance to own \/telegram-connect/,
|
||||
);
|
||||
assert.match(
|
||||
result.stdout,
|
||||
/telegram_attach:Telegram direct delivery requires this Pi instance to own \/telegram-connect/,
|
||||
);
|
||||
await assert.rejects(() => readFile(markerPath, "utf8"), {
|
||||
code: "ENOENT",
|
||||
});
|
||||
} finally {
|
||||
await rm(agentDir, { recursive: true, force: true });
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
"pi -p with Telegram extension and no active lock exits",
|
||||
{ skip: !PI_CLI_AVAILABLE },
|
||||
async () => {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "pi-telegram-pi-print-"));
|
||||
const fixtureExtension = await createPiPrintFixtureExtension(tempDir);
|
||||
const agentDir = await createPiPrintAgentDir({
|
||||
botToken: "123:abc",
|
||||
allowedUserId: 77,
|
||||
lastUpdateId: 0,
|
||||
});
|
||||
try {
|
||||
const result = await runPiPrintWithTelegram(agentDir, fixtureExtension);
|
||||
assert.equal(result.code, 0, result.stderr);
|
||||
assert.equal(result.signal, null, result.stderr);
|
||||
assert.match(result.stdout, /fixture ok/);
|
||||
} finally {
|
||||
await rm(agentDir, { recursive: true, force: true });
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"pi -p with Telegram-owned lock stays passive and does not poll",
|
||||
{ skip: !PI_CLI_AVAILABLE },
|
||||
async () => {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "pi-telegram-pi-print-"));
|
||||
const fixtureExtension = await createPiPrintFixtureExtension(tempDir);
|
||||
const markerPath = join(tempDir, "telegram-methods.log");
|
||||
const agentDir = await createPiPrintAgentDir({
|
||||
botToken: "123:abc",
|
||||
allowedUserId: 77,
|
||||
lastUpdateId: 0,
|
||||
});
|
||||
try {
|
||||
const result = await runPiPrintWithTelegram(agentDir, fixtureExtension, {
|
||||
PI_TELEGRAM_TEST_CTX_MODE: "print",
|
||||
PI_TELEGRAM_TEST_LOCK_MODE: "owner",
|
||||
PI_TELEGRAM_TEST_METHOD_MARKER: markerPath,
|
||||
});
|
||||
assert.equal(result.code, 0, result.stderr);
|
||||
assert.equal(result.signal, null, result.stderr);
|
||||
assert.match(result.stdout, /fixture ok/);
|
||||
const methods = await readFile(markerPath, "utf8").catch(() => "");
|
||||
assert.doesNotMatch(methods, /deleteWebhook/);
|
||||
assert.doesNotMatch(methods, /getUpdates/);
|
||||
} finally {
|
||||
await rm(agentDir, { recursive: true, force: true });
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"pi -p with proactive config and Telegram-owned lock exits",
|
||||
{ skip: !PI_CLI_AVAILABLE },
|
||||
async () => {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "pi-telegram-pi-print-"));
|
||||
const fixtureExtension = await createPiPrintFixtureExtension(tempDir);
|
||||
const agentDir = await createPiPrintAgentDir({
|
||||
botToken: "123:abc",
|
||||
allowedUserId: 77,
|
||||
lastUpdateId: 0,
|
||||
proactivePush: true,
|
||||
});
|
||||
try {
|
||||
const result = await runPiPrintWithTelegram(agentDir, fixtureExtension, {
|
||||
PI_TELEGRAM_TEST_LOCK_MODE: "owner",
|
||||
});
|
||||
assert.equal(result.code, 0, result.stderr);
|
||||
assert.equal(result.signal, null, result.stderr);
|
||||
assert.match(result.stdout, /fixture ok/);
|
||||
} finally {
|
||||
await rm(agentDir, { recursive: true, force: true });
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"pi -p non-owner skips proactive Telegram result",
|
||||
{ skip: !PI_CLI_AVAILABLE },
|
||||
async () => {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "pi-telegram-pi-print-"));
|
||||
const fixtureExtension = await createPiPrintFixtureExtension(tempDir);
|
||||
const markerPath = join(tempDir, "send-marker.json");
|
||||
const agentDir = await createPiPrintAgentDir(
|
||||
{
|
||||
botToken: "123:abc",
|
||||
allowedUserId: 77,
|
||||
lastUpdateId: 0,
|
||||
proactivePush: true,
|
||||
},
|
||||
{
|
||||
"@llblab/pi-telegram": {
|
||||
pid: process.pid,
|
||||
cwd: "/repo/another-live-owner",
|
||||
},
|
||||
},
|
||||
);
|
||||
try {
|
||||
const result = await runPiPrintWithTelegram(agentDir, fixtureExtension, {
|
||||
PI_TELEGRAM_TEST_SEND_MARKER: markerPath,
|
||||
});
|
||||
assert.equal(result.code, 0, result.stderr);
|
||||
assert.equal(result.signal, null, result.stderr);
|
||||
assert.match(result.stdout, /fixture ok/);
|
||||
await assert.rejects(() => readFile(markerPath, "utf8"), {
|
||||
code: "ENOENT",
|
||||
});
|
||||
} finally {
|
||||
await rm(agentDir, { recursive: true, force: true });
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("Extension session shutdown without active lock lets process exit", async () => {
|
||||
const extensionUrl = new URL("../index.ts", import.meta.url).href;
|
||||
const result = await runNodeScript(`
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const agentDir = await mkdtemp(join(tmpdir(), "pi-telegram-process-shutdown-"));
|
||||
process.env.PI_CODING_AGENT_DIR = agentDir;
|
||||
await writeFile(
|
||||
join(agentDir, "telegram.json"),
|
||||
JSON.stringify({ botToken: "123:abc", allowedUserId: 77, lastUpdateId: 0 }) + "\\n",
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(agentDir, "locks.json"), "{}\\n", "utf8");
|
||||
|
||||
const handlers = new Map();
|
||||
const pi = {
|
||||
on: (event, handler) => handlers.set(event, handler),
|
||||
registerCommand: () => {},
|
||||
registerTool: () => {},
|
||||
sendUserMessage: () => {},
|
||||
getCommands: () => [],
|
||||
getThinkingLevel: () => "medium",
|
||||
};
|
||||
const extension = (await import(${JSON.stringify(extensionUrl)})).default;
|
||||
extension(pi);
|
||||
const ctx = {
|
||||
cwd: "/repo/process-shutdown-no-lock",
|
||||
hasUI: false,
|
||||
ui: {
|
||||
theme: { fg: (_token, text) => text },
|
||||
setStatus: () => {},
|
||||
notify: () => {},
|
||||
},
|
||||
isIdle: () => true,
|
||||
hasPendingMessages: () => false,
|
||||
abort: () => {},
|
||||
};
|
||||
await handlers.get("session_start")?.({}, ctx);
|
||||
await handlers.get("session_shutdown")?.({}, ctx);
|
||||
await rm(agentDir, { recursive: true, force: true });
|
||||
`);
|
||||
|
||||
assert.equal(result.code, 0, result.stderr);
|
||||
assert.equal(result.signal, null, result.stderr);
|
||||
});
|
||||
|
||||
test("Extension session shutdown lets an active polling owner process exit", async () => {
|
||||
const extensionUrl = new URL("../index.ts", import.meta.url).href;
|
||||
const result = await runNodeScript(`
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const agentDir = await mkdtemp(join(tmpdir(), "pi-telegram-process-shutdown-"));
|
||||
process.env.PI_CODING_AGENT_DIR = agentDir;
|
||||
const cwd = "/repo/process-shutdown";
|
||||
await writeFile(
|
||||
join(agentDir, "telegram.json"),
|
||||
JSON.stringify({ botToken: "123:abc", allowedUserId: 77, lastUpdateId: 0 }) + "\\n",
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(agentDir, "locks.json"),
|
||||
JSON.stringify({ "@llblab/pi-telegram": { pid: process.pid, cwd } }) + "\\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
globalThis.fetch = async (input, init = {}) => {
|
||||
const method = String(input).split("/").at(-1);
|
||||
if (method === "deleteWebhook") {
|
||||
return { json: async () => ({ ok: true, result: true }) };
|
||||
}
|
||||
if (method === "getUpdates") {
|
||||
const signal = init.signal;
|
||||
return new Promise((_, reject) => {
|
||||
const abort = () => reject(new DOMException("stop", "AbortError"));
|
||||
if (signal?.aborted) {
|
||||
abort();
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
});
|
||||
}
|
||||
throw new Error("Unexpected Telegram API method: " + method);
|
||||
};
|
||||
|
||||
const handlers = new Map();
|
||||
const pi = {
|
||||
on: (event, handler) => handlers.set(event, handler),
|
||||
registerCommand: () => {},
|
||||
registerTool: () => {},
|
||||
sendUserMessage: () => {},
|
||||
getCommands: () => [],
|
||||
getThinkingLevel: () => "medium",
|
||||
};
|
||||
const extension = (await import(${JSON.stringify(extensionUrl)})).default;
|
||||
extension(pi);
|
||||
const ctx = {
|
||||
cwd,
|
||||
hasUI: false,
|
||||
ui: {
|
||||
theme: { fg: (_token, text) => text },
|
||||
setStatus: () => {},
|
||||
notify: () => {},
|
||||
},
|
||||
isIdle: () => true,
|
||||
hasPendingMessages: () => false,
|
||||
abort: () => {},
|
||||
};
|
||||
await handlers.get("session_start")?.({}, ctx);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await handlers.get("session_shutdown")?.({}, ctx);
|
||||
await rm(agentDir, { recursive: true, force: true });
|
||||
`);
|
||||
|
||||
assert.equal(result.code, 0, result.stderr);
|
||||
assert.equal(result.signal, null, result.stderr);
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Regression tests for Telegram prompt-template bridge helpers
|
||||
* Covers Pi prompt-template discovery filtering and command expansion
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
createTelegramPromptTemplateCommandGetter,
|
||||
expandTelegramPromptTemplateCommand,
|
||||
getTelegramPromptTemplateCommands,
|
||||
mapPiPromptTemplateNameToTelegramCommandName,
|
||||
parsePromptTemplateArgs,
|
||||
substitutePromptTemplateArgs,
|
||||
} from "../lib/prompt-templates.ts";
|
||||
import { TELEGRAM_RESERVED_COMMAND_NAMES } from "../lib/commands.ts";
|
||||
import type { PiSlashCommandInfo } from "../lib/pi.ts";
|
||||
|
||||
function createCommand(
|
||||
name: string,
|
||||
source: PiSlashCommandInfo["source"],
|
||||
description?: string,
|
||||
): PiSlashCommandInfo {
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
source,
|
||||
sourceInfo: {
|
||||
path: `/prompts/${name}.md`,
|
||||
source: "local",
|
||||
scope: "project",
|
||||
origin: "top-level",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("Prompt-template helpers expose valid Telegram command templates", () => {
|
||||
const commands = getTelegramPromptTemplateCommands(
|
||||
[
|
||||
createCommand("review", "prompt", "Review changes"),
|
||||
createCommand("fix-tests", "prompt", "Fix failing tests"),
|
||||
createCommand("status", "prompt", "Reserved hidden command"),
|
||||
createCommand("start", "prompt", "Reserved visible command"),
|
||||
createCommand("skill", "skill", "Not a prompt"),
|
||||
createCommand("review", "prompt", "Duplicate"),
|
||||
],
|
||||
new Set(TELEGRAM_RESERVED_COMMAND_NAMES),
|
||||
);
|
||||
assert.deepEqual(commands, [
|
||||
{
|
||||
command: "fix_tests",
|
||||
description: "Fix failing tests",
|
||||
path: "/prompts/fix-tests.md",
|
||||
},
|
||||
{
|
||||
command: "review",
|
||||
description: "Review changes",
|
||||
path: "/prompts/review.md",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("Prompt-template helpers skip commands without source paths", () => {
|
||||
const commandWithoutSourcePath = {
|
||||
name: "review",
|
||||
description: "Review changes",
|
||||
source: "prompt",
|
||||
} as PiSlashCommandInfo;
|
||||
const commands = getTelegramPromptTemplateCommands(
|
||||
[commandWithoutSourcePath],
|
||||
new Set(TELEGRAM_RESERVED_COMMAND_NAMES),
|
||||
);
|
||||
assert.deepEqual(commands, []);
|
||||
});
|
||||
|
||||
test("Prompt-template helpers map pi template names to Telegram commands", () => {
|
||||
assert.equal(
|
||||
mapPiPromptTemplateNameToTelegramCommandName("fix-tests"),
|
||||
"fix_tests",
|
||||
);
|
||||
assert.equal(
|
||||
mapPiPromptTemplateNameToTelegramCommandName("Review PR"),
|
||||
"review_pr",
|
||||
);
|
||||
assert.equal(mapPiPromptTemplateNameToTelegramCommandName("---"), undefined);
|
||||
});
|
||||
|
||||
test("Prompt-template command getter hides extension command conflicts", () => {
|
||||
const getCommands = () => [
|
||||
createCommand("review", "prompt", "Review changes"),
|
||||
createCommand("start", "prompt", "Conflicts with built-in"),
|
||||
createCommand("status", "prompt", "Conflicts with hidden shortcut"),
|
||||
];
|
||||
const getPromptTemplateCommands = createTelegramPromptTemplateCommandGetter({
|
||||
getCommands,
|
||||
reservedCommandNames: TELEGRAM_RESERVED_COMMAND_NAMES,
|
||||
});
|
||||
assert.deepEqual(
|
||||
getPromptTemplateCommands().map((command) => command.command),
|
||||
["review"],
|
||||
);
|
||||
});
|
||||
|
||||
test("Prompt-template helpers parse and substitute arguments like pi templates", () => {
|
||||
const args = parsePromptTemplateArgs("one 'two words' \"three words\"");
|
||||
assert.deepEqual(args, ["one", "two words", "three words"]);
|
||||
assert.equal(
|
||||
substitutePromptTemplateArgs(
|
||||
"$1 | $2 | $@ | $ARGUMENTS | ${@:2} | ${@:2:1}",
|
||||
args,
|
||||
),
|
||||
"one | two words | one two words three words | one two words three words | two words three words | two words",
|
||||
);
|
||||
});
|
||||
|
||||
test("Prompt-template helpers expand command content from template files", () => {
|
||||
const expanded = expandTelegramPromptTemplateCommand(
|
||||
"review",
|
||||
"staged changes",
|
||||
[{ command: "review", description: "Review", path: "/prompts/review.md" }],
|
||||
() => "---\ndescription: Review\n---\nReview $@",
|
||||
);
|
||||
assert.equal(expanded, "Review staged changes");
|
||||
assert.equal(
|
||||
expandTelegramPromptTemplateCommand("missing", "", [], () => "unused"),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Regression tests for Telegram prompt injection helpers
|
||||
* Covers system prompt suffix construction and before-agent-start hook binding
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildTelegramBridgeSystemPrompt,
|
||||
createTelegramBeforeAgentStartHook,
|
||||
createTelegramProactiveBeforeAgentStartHook,
|
||||
getTelegramHelpText,
|
||||
registerTelegramHelpTool,
|
||||
} from "../lib/prompts.ts";
|
||||
|
||||
type BeforeAgentStartHookEvent = Parameters<
|
||||
ReturnType<typeof createTelegramBeforeAgentStartHook>
|
||||
>[0];
|
||||
|
||||
function createBeforeAgentStartEvent(
|
||||
prompt: string,
|
||||
systemPrompt: string,
|
||||
): BeforeAgentStartHookEvent {
|
||||
return { prompt, systemPrompt } as BeforeAgentStartHookEvent;
|
||||
}
|
||||
|
||||
test("Prompt helpers append context-aware system prompt suffixes", () => {
|
||||
assert.deepEqual(
|
||||
buildTelegramBridgeSystemPrompt({
|
||||
prompt: " [telegram] hello",
|
||||
systemPrompt: "base",
|
||||
telegramPrefix: "[telegram]",
|
||||
localSystemPromptSuffix: "\nlocal bridge available",
|
||||
telegramTurnSystemPromptSuffix: "\ntelegram turn contract",
|
||||
}),
|
||||
{
|
||||
systemPrompt:
|
||||
"base\nlocal bridge available\ntelegram turn contract\n- The current user message came from Telegram.",
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
buildTelegramBridgeSystemPrompt({
|
||||
prompt: "local hello",
|
||||
systemPrompt: "base",
|
||||
telegramPrefix: "[telegram]",
|
||||
localSystemPromptSuffix: "\nlocal bridge available",
|
||||
telegramTurnSystemPromptSuffix: "\ntelegram turn contract",
|
||||
}),
|
||||
{ systemPrompt: "base\nlocal bridge available" },
|
||||
);
|
||||
});
|
||||
|
||||
test("Prompt helpers keep local prompts on compact safety guidance only", () => {
|
||||
const result = createTelegramBeforeAgentStartHook()(
|
||||
createBeforeAgentStartEvent("local hello", "base"),
|
||||
).systemPrompt;
|
||||
assert.match(result, /Telegram bridge available/);
|
||||
assert.doesNotMatch(result, /telegram_help/);
|
||||
assert.doesNotMatch(result, /telegram_attach/);
|
||||
assert.doesNotMatch(result, /telegram_message/);
|
||||
assert.doesNotMatch(result, /37 visible cells/);
|
||||
assert.doesNotMatch(result, /telegram_voice text="Short summary"/);
|
||||
assert.doesNotMatch(result, /telegram_button: OK/);
|
||||
assert.doesNotMatch(result, /The current user message came from Telegram/);
|
||||
});
|
||||
|
||||
test("Prompt helpers add full Telegram-turn guidance for Telegram prompts", () => {
|
||||
const hook = createTelegramBeforeAgentStartHook({
|
||||
telegramPrefix: "[telegram]",
|
||||
localSystemPromptSuffix: "\nlocal bridge available",
|
||||
telegramTurnSystemPromptSuffix: "\ntelegram turn contract",
|
||||
});
|
||||
assert.deepEqual(
|
||||
hook(createBeforeAgentStartEvent(" [telegram] hello", "base")),
|
||||
{
|
||||
systemPrompt:
|
||||
"base\nlocal bridge available\ntelegram turn contract\n- The current user message came from Telegram.",
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
hook(
|
||||
createBeforeAgentStartEvent(
|
||||
" [telegram|chat:supergroup|thread:42] hello",
|
||||
"base",
|
||||
),
|
||||
),
|
||||
{
|
||||
systemPrompt:
|
||||
"base\nlocal bridge available\ntelegram turn contract\n- The current user message came from Telegram.",
|
||||
},
|
||||
);
|
||||
const defaultSystemPrompt = createTelegramBeforeAgentStartHook()(
|
||||
createBeforeAgentStartEvent(" [telegram] hello", "base"),
|
||||
).systemPrompt;
|
||||
assert.match(
|
||||
defaultSystemPrompt,
|
||||
/The current user message came from Telegram/,
|
||||
);
|
||||
assert.match(defaultSystemPrompt, /telegram_help/);
|
||||
assert.doesNotMatch(defaultSystemPrompt, /mobile Telegram/);
|
||||
assert.doesNotMatch(defaultSystemPrompt, /\$\.\.\.\$.*\$\$\.\.\.\$\$/);
|
||||
assert.doesNotMatch(defaultSystemPrompt, /37 visible cells/);
|
||||
assert.doesNotMatch(
|
||||
defaultSystemPrompt,
|
||||
/`\[reply\]` is quoted context only/,
|
||||
);
|
||||
assert.doesNotMatch(defaultSystemPrompt, /`\[outputs\]` are handler results/);
|
||||
assert.doesNotMatch(defaultSystemPrompt, /`\[time\]` is wall-clock context/);
|
||||
assert.doesNotMatch(
|
||||
defaultSystemPrompt,
|
||||
/`\[voice\]` gives reply-mode policy/,
|
||||
);
|
||||
assert.doesNotMatch(defaultSystemPrompt, /telegram_attach/);
|
||||
assert.doesNotMatch(defaultSystemPrompt, /telegram_message/);
|
||||
assert.match(defaultSystemPrompt, /telegram_voice: Speak this/);
|
||||
assert.match(defaultSystemPrompt, /\/telegram_voice/);
|
||||
assert.doesNotMatch(defaultSystemPrompt, /telegram_button: OK/);
|
||||
assert.doesNotMatch(defaultSystemPrompt, /state\.json/);
|
||||
assert.doesNotMatch(defaultSystemPrompt, /logs\.jsonl/);
|
||||
assert.doesNotMatch(
|
||||
defaultSystemPrompt,
|
||||
/thread.*visible Thread identity.*not a bus role/s,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
defaultSystemPrompt,
|
||||
/Give yourself a unique thread name/,
|
||||
);
|
||||
assert.doesNotMatch(defaultSystemPrompt, /telegram_rename_thread/);
|
||||
|
||||
const topicSystemPrompt = createTelegramBeforeAgentStartHook()(
|
||||
createBeforeAgentStartEvent(" [telegram|thread:C] hello", "base"),
|
||||
).systemPrompt;
|
||||
assert.match(
|
||||
topicSystemPrompt,
|
||||
/The current user message came from Telegram/,
|
||||
);
|
||||
assert.doesNotMatch(topicSystemPrompt, /unnamed fresh topic/);
|
||||
assert.doesNotMatch(topicSystemPrompt, /telegram_rename_thread/);
|
||||
});
|
||||
|
||||
test("Prompt helpers expose detailed Telegram guidance through agent help tool", async () => {
|
||||
const help = getTelegramHelpText();
|
||||
assert.match(help, /Assistant-authored Telegram actions/);
|
||||
assert.match(help, /telegram_voice text="Short summary"/);
|
||||
assert.match(help, /telegram_button: OK/);
|
||||
assert.match(help, /inboundHandlers/);
|
||||
assert.match(help, /speech-to-text/);
|
||||
assert.match(help, /state\.json/);
|
||||
assert.match(help, /logs\.jsonl/);
|
||||
const namedHelp = getTelegramHelpText("work");
|
||||
assert.match(namedHelp, /state\.work\.json/);
|
||||
assert.match(namedHelp, /logs\.work\.jsonl/);
|
||||
|
||||
let tool:
|
||||
{ name?: string; execute: () => Promise<unknown> | unknown } | undefined;
|
||||
registerTelegramHelpTool(
|
||||
{
|
||||
registerTool: (definition: { name?: string; execute: () => unknown }) => {
|
||||
tool = definition;
|
||||
},
|
||||
} as never,
|
||||
{ getActiveProfileName: () => "work" },
|
||||
);
|
||||
assert.equal(tool?.name, "telegram_help");
|
||||
assert.deepEqual(await tool?.execute(), {
|
||||
content: [{ type: "text", text: namedHelp }],
|
||||
details: {},
|
||||
});
|
||||
});
|
||||
|
||||
test("Prompt helpers leave local prompts private for proactive result push", async () => {
|
||||
const hook = createTelegramProactiveBeforeAgentStartHook({
|
||||
baseHook: createTelegramBeforeAgentStartHook({
|
||||
telegramPrefix: "[telegram]",
|
||||
localSystemPromptSuffix: "\nlocal bridge available",
|
||||
telegramTurnSystemPromptSuffix: "\ntelegram turn contract",
|
||||
}),
|
||||
isConfigured: () => true,
|
||||
isProactivePushEnabled: () => true,
|
||||
isCurrentOwner: () => true,
|
||||
});
|
||||
const result = await hook(
|
||||
createBeforeAgentStartEvent("local prompt", "base"),
|
||||
"ctx",
|
||||
);
|
||||
assert.deepEqual(result, { systemPrompt: "base\nlocal bridge available" });
|
||||
});
|
||||
|
||||
test("Prompt helpers skip suffix injection when Telegram is not configured", async () => {
|
||||
const hook = createTelegramProactiveBeforeAgentStartHook({
|
||||
baseHook: createTelegramBeforeAgentStartHook({
|
||||
telegramPrefix: "[telegram]",
|
||||
localSystemPromptSuffix: "\nlocal bridge available",
|
||||
telegramTurnSystemPromptSuffix: "\ntelegram turn contract",
|
||||
}),
|
||||
isConfigured: () => false,
|
||||
isProactivePushEnabled: () => true,
|
||||
isCurrentOwner: () => true,
|
||||
});
|
||||
const result = await hook(
|
||||
createBeforeAgentStartEvent("[telegram] hello", "base"),
|
||||
"ctx",
|
||||
);
|
||||
assert.deepEqual(result, { systemPrompt: "base" });
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Regression tests for public package API exports
|
||||
* Zones: package boundary, extension interop
|
||||
* Guards stable public subpaths and the removal of deep lib wildcard exports
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
async function assertPackagePathNotExported(specifier: string): Promise<void> {
|
||||
await assert.rejects(
|
||||
() => import(specifier),
|
||||
(error: unknown) =>
|
||||
error instanceof Error &&
|
||||
"code" in error &&
|
||||
error.code === "ERR_PACKAGE_PATH_NOT_EXPORTED",
|
||||
);
|
||||
}
|
||||
|
||||
test("Public package subpaths expose the stable extension API", async () => {
|
||||
const [
|
||||
root,
|
||||
inbound,
|
||||
outbound,
|
||||
delivery,
|
||||
activity,
|
||||
updates,
|
||||
commands,
|
||||
sections,
|
||||
status,
|
||||
voice,
|
||||
keyboard,
|
||||
] = await Promise.all([
|
||||
import("@evandrodevbr/oh-my-pi-telegram"),
|
||||
import("@evandrodevbr/oh-my-pi-telegram/inbound"),
|
||||
import("@evandrodevbr/oh-my-pi-telegram/outbound"),
|
||||
import("@evandrodevbr/oh-my-pi-telegram/delivery"),
|
||||
import("@evandrodevbr/oh-my-pi-telegram/activity"),
|
||||
import("@evandrodevbr/oh-my-pi-telegram/updates"),
|
||||
import("@evandrodevbr/oh-my-pi-telegram/commands"),
|
||||
import("@evandrodevbr/oh-my-pi-telegram/sections"),
|
||||
import("@evandrodevbr/oh-my-pi-telegram/status"),
|
||||
import("@evandrodevbr/oh-my-pi-telegram/voice"),
|
||||
import("@evandrodevbr/oh-my-pi-telegram/keyboard"),
|
||||
]);
|
||||
|
||||
assert.deepEqual(Object.keys(root), ["default"]);
|
||||
assert.deepEqual(Object.keys(inbound).sort(), [
|
||||
"registerTelegramInboundHandler",
|
||||
]);
|
||||
assert.deepEqual(Object.keys(outbound).sort(), [
|
||||
"recordTelegramRuntimeEvent",
|
||||
"registerTelegramOutboundHandler",
|
||||
]);
|
||||
assert.deepEqual(Object.keys(delivery).sort(), [
|
||||
"deleteTelegramView",
|
||||
"editTelegramView",
|
||||
"sendTelegramChatAction",
|
||||
"sendTelegramView",
|
||||
]);
|
||||
assert.deepEqual(Object.keys(activity).sort(), [
|
||||
"registerTelegramActivityHandler",
|
||||
]);
|
||||
assert.deepEqual(Object.keys(updates).sort(), [
|
||||
"registerTelegramUpdateHandler",
|
||||
]);
|
||||
assert.deepEqual(Object.keys(commands).sort(), ["registerTelegramCommand"]);
|
||||
assert.deepEqual(Object.keys(sections).sort(), [
|
||||
"getTelegramSectionDiagnostics",
|
||||
"registerTelegramSection",
|
||||
]);
|
||||
assert.deepEqual(Object.keys(status).sort(), [
|
||||
"registerTelegramStatusLineProvider",
|
||||
]);
|
||||
assert.deepEqual(Object.keys(voice).sort(), [
|
||||
"TELEGRAM_VOICE_REPLY_MODES",
|
||||
"computeVoicePromptContribution",
|
||||
"computeVoiceTurnFlags",
|
||||
"getTelegramVoiceReplyMode",
|
||||
"getTelegramVoiceSendTranscript",
|
||||
"isVoiceTurn",
|
||||
"registerTelegramVoiceSynthesisProvider",
|
||||
"registerTelegramVoiceTranscriptionProvider",
|
||||
"shouldSuppressPreviewForVoice",
|
||||
]);
|
||||
assert.deepEqual(Object.keys(keyboard), []);
|
||||
});
|
||||
|
||||
test("Activity API declares the Pi lifecycle compatibility floor", async () => {
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(new URL("../package.json", import.meta.url), "utf8"),
|
||||
) as { peerDependencies?: Record<string, string> };
|
||||
assert.equal(
|
||||
packageJson.peerDependencies?.["@earendil-works/pi-coding-agent"],
|
||||
">=0.80.6",
|
||||
);
|
||||
assert.equal(
|
||||
packageJson.peerDependencies?.["@earendil-works/pi-agent-core"],
|
||||
">=0.80.6",
|
||||
);
|
||||
assert.equal(
|
||||
packageJson.peerDependencies?.["@earendil-works/pi-ai"],
|
||||
">=0.80.6",
|
||||
);
|
||||
});
|
||||
|
||||
test("Package-private lib implementation paths are not exported", async () => {
|
||||
await assertPackagePathNotExported("@evandrodevbr/oh-my-pi-telegram/lib/updates.ts");
|
||||
await assertPackagePathNotExported("@evandrodevbr/oh-my-pi-telegram/lib/sections.ts");
|
||||
await assertPackagePathNotExported("@evandrodevbr/oh-my-pi-telegram/api/updates.ts");
|
||||
});
|
||||
+3981
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,664 @@
|
||||
/**
|
||||
* Regression tests for Telegram UI/compat rendering helpers
|
||||
* Covers nested lists, code blocks, tables, links, quotes, chunking, and other Telegram-specific render edge cases
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
chunkHtmlPreservingTags,
|
||||
escapeHtml,
|
||||
escapeHtmlAttribute,
|
||||
MAX_MESSAGE_LENGTH,
|
||||
renderTelegramMessage,
|
||||
} from "../lib/rendering.ts";
|
||||
|
||||
function countMatches(text: string, pattern: RegExp): number {
|
||||
return text.match(pattern)?.length ?? 0;
|
||||
}
|
||||
|
||||
test("HTML helpers escape text and attribute contexts", () => {
|
||||
assert.equal(escapeHtml("<tag>&value"), "<tag>&value");
|
||||
assert.equal(
|
||||
escapeHtmlAttribute(`"quoted" 'value' & <tag>`),
|
||||
""quoted" 'value' & <tag>",
|
||||
);
|
||||
});
|
||||
|
||||
test("HTML helpers chunk long text while preserving open tags", () => {
|
||||
const chunks = chunkHtmlPreservingTags(`<b>${"x".repeat(10)}</b>`, 12);
|
||||
assert.deepEqual(chunks, ["<b>xxxxx</b>", "<b>xxxxx</b>"]);
|
||||
});
|
||||
|
||||
test("HTML helpers do not reopen void tags across chunks", () => {
|
||||
const chunks = chunkHtmlPreservingTags(`a<br>${"b".repeat(10)}`, 8);
|
||||
assert.deepEqual(chunks, ["a<br>bbb", "bbbbbbbb".slice(0, 7)]);
|
||||
});
|
||||
|
||||
test("Malformed and boundary markdown renders safely", () => {
|
||||
const cases = [
|
||||
"",
|
||||
"```ts\nconst raw = '**not bold**'",
|
||||
`${"x".repeat(MAX_MESSAGE_LENGTH + 250)} end`,
|
||||
`surrogate-boundary: ${"🙂".repeat(120)}\uD83D`,
|
||||
[
|
||||
"> level 1",
|
||||
"> > level 2",
|
||||
"> > > level 3",
|
||||
"> > > > level 4",
|
||||
"> **bold quote",
|
||||
].join("\n"),
|
||||
];
|
||||
|
||||
for (const markdown of cases) {
|
||||
let chunks: ReturnType<typeof renderTelegramMessage> = [];
|
||||
assert.doesNotThrow(() => {
|
||||
chunks = renderTelegramMessage(markdown, { mode: "markdown" });
|
||||
});
|
||||
for (const chunk of chunks) {
|
||||
assert.ok(chunk.text.length <= MAX_MESSAGE_LENGTH);
|
||||
assert.equal(
|
||||
countMatches(chunk.text, /<blockquote>/g),
|
||||
countMatches(chunk.text, /<\/blockquote>/g),
|
||||
);
|
||||
assert.equal(
|
||||
countMatches(chunk.text, /<b>/g),
|
||||
countMatches(chunk.text, /<\/b>/g),
|
||||
);
|
||||
assert.equal(
|
||||
countMatches(chunk.text, /<i>/g),
|
||||
countMatches(chunk.text, /<\/i>/g),
|
||||
);
|
||||
assert.equal(
|
||||
countMatches(chunk.text, /<pre>/g),
|
||||
countMatches(chunk.text, /<\/pre>/g),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("Nested lists stay out of code blocks", () => {
|
||||
const chunks = renderTelegramMessage(
|
||||
"- Level 1\n - Level 2\n - Level 3 with **bold** text",
|
||||
{ mode: "markdown" },
|
||||
);
|
||||
assert.ok(chunks.length > 0);
|
||||
assert.equal(
|
||||
chunks.some((chunk) => chunk.text.includes("<pre><code>")),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
chunks.some((chunk) =>
|
||||
chunk.text.includes("<code>-</code> Level 3 with <b>bold</b> text"),
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("Fenced code blocks preserve literal markdown", () => {
|
||||
const chunks = renderTelegramMessage('~~~ts\nconst value = "**raw**";\n~~~', {
|
||||
mode: "markdown",
|
||||
});
|
||||
assert.equal(chunks.length, 1);
|
||||
assert.match(chunks[0]?.text ?? "", /<pre><code class="language-ts">/);
|
||||
assert.match(chunks[0]?.text ?? "", /\*\*raw\*\*/);
|
||||
});
|
||||
|
||||
test("Underscores inside words do not become italic", () => {
|
||||
const chunks = renderTelegramMessage("Path: foo_bar_baz.txt and **bold**", {
|
||||
mode: "markdown",
|
||||
});
|
||||
assert.equal(chunks.length, 1);
|
||||
assert.equal((chunks[0]?.text ?? "").includes("<i>bar</i>"), false);
|
||||
assert.match(chunks[0]?.text ?? "", /<b>bold<\/b>/);
|
||||
});
|
||||
|
||||
test("Bold markdown can span soft line breaks", () => {
|
||||
const chunks = renderTelegramMessage(
|
||||
"Скорее: **архитектурно — да,\nпрактически — почти**.",
|
||||
{ mode: "markdown" },
|
||||
);
|
||||
assert.equal(chunks.length, 1);
|
||||
assert.match(
|
||||
chunks[0]?.text ?? "",
|
||||
/Скорее: <b>архитектурно — да,\nпрактически — почти<\/b>\./,
|
||||
);
|
||||
});
|
||||
|
||||
test("Quoted nested lists stay in blockquote rendering", () => {
|
||||
const chunks = renderTelegramMessage(
|
||||
"> Quoted intro\n> - nested item\n> - deeper item",
|
||||
{ mode: "markdown" },
|
||||
);
|
||||
assert.equal(chunks.length, 1);
|
||||
assert.match(chunks[0]?.text ?? "", /<blockquote>/);
|
||||
assert.match(chunks[0]?.text ?? "", /nested item/);
|
||||
assert.match(chunks[0]?.text ?? "", /<code>-<\/code> nested item/);
|
||||
assert.equal((chunks[0]?.text ?? "").includes("<pre><code>"), false);
|
||||
});
|
||||
|
||||
test("Numbered lists use monospace numeric markers", () => {
|
||||
const chunks = renderTelegramMessage("1. first\n 2. second", {
|
||||
mode: "markdown",
|
||||
});
|
||||
assert.equal(chunks.length, 1);
|
||||
assert.match(chunks[0]?.text ?? "", /<code>1\.<\/code> first/);
|
||||
assert.match(chunks[0]?.text ?? "", /<code>2\.<\/code> second/);
|
||||
});
|
||||
|
||||
test("Ordered task lists preserve numeric markers in rendering", () => {
|
||||
const markdown = "1. [x] first\n2. [ ] second";
|
||||
const chunks = renderTelegramMessage(markdown, {
|
||||
mode: "markdown",
|
||||
});
|
||||
assert.equal(chunks.length, 1);
|
||||
assert.match(
|
||||
chunks[0]?.text ?? "",
|
||||
/<code>1\.<\/code> <code>\[x\]<\/code> first/,
|
||||
);
|
||||
assert.match(
|
||||
chunks[0]?.text ?? "",
|
||||
/<code>2\.<\/code> <code>\[ \]<\/code> second/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Leading indentation on the first markdown line stays intact", () => {
|
||||
const markdown = " - nested bullet\n - nested child";
|
||||
const chunks = renderTelegramMessage(markdown, {
|
||||
mode: "markdown",
|
||||
});
|
||||
assert.equal(chunks.length, 1);
|
||||
assert.match(
|
||||
chunks[0]?.text ?? "",
|
||||
/^\u00A0\u00A0<code>-<\/code> nested bullet/m,
|
||||
);
|
||||
assert.match(
|
||||
chunks[0]?.text ?? "",
|
||||
/^\u00A0\u00A0\u00A0\u00A0<code>-<\/code> nested child/m,
|
||||
);
|
||||
});
|
||||
|
||||
test("UI/compat rendering preserves multiple blank lines between blocks", () => {
|
||||
const markdown = "# Title\n\n\nParagraph\n\n\n> Quote";
|
||||
const chunks = renderTelegramMessage(markdown, {
|
||||
mode: "markdown",
|
||||
});
|
||||
assert.equal(chunks.length, 1);
|
||||
assert.match(
|
||||
chunks[0]?.text ?? "",
|
||||
/<b>Title<\/b>\n\n\nParagraph\n\n\n<blockquote>Quote<\/blockquote>/,
|
||||
);
|
||||
});
|
||||
|
||||
test("UI/compat rendering preserves original blank-line spacing across block transitions", () => {
|
||||
const cases = [
|
||||
{
|
||||
markdown: "Para\n\n\n```ts\nconst x = 1\n```",
|
||||
finalText:
|
||||
'Para\n\n\n<pre><code class="language-ts">const x = 1</code></pre>',
|
||||
},
|
||||
{
|
||||
markdown: "```ts\nconst x = 1\n```\n\n\nPara",
|
||||
finalText:
|
||||
'<pre><code class="language-ts">const x = 1</code></pre>\n\n\nPara',
|
||||
},
|
||||
{
|
||||
markdown: "Para\n\n\n- item",
|
||||
finalText: "Para\n\n\n<code>-</code> item",
|
||||
},
|
||||
{
|
||||
markdown: "Para\n\n\n> Quote",
|
||||
finalText: "Para\n\n\n<blockquote>Quote</blockquote>",
|
||||
},
|
||||
];
|
||||
for (const testCase of cases) {
|
||||
const finalChunks = renderTelegramMessage(testCase.markdown, {
|
||||
mode: "markdown",
|
||||
});
|
||||
assert.equal(finalChunks.length, 1);
|
||||
assert.equal(finalChunks[0]?.text ?? "", testCase.finalText);
|
||||
}
|
||||
});
|
||||
|
||||
test("Headings keep visible spacing before following code blocks even without source blank lines", () => {
|
||||
const markdown = "### Title\n```ts\nconst x = 1\n```";
|
||||
const chunks = renderTelegramMessage(markdown, {
|
||||
mode: "markdown",
|
||||
});
|
||||
assert.equal(chunks.length, 1);
|
||||
assert.match(
|
||||
chunks[0]?.text ?? "",
|
||||
/<b>Title<\/b>\n\n<pre><code class="language-ts">const x = 1<\/code><\/pre>/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Standalone checkbox-looking prose stays literal outside task lists", () => {
|
||||
const markdown = "Use [ ] as a placeholder and keep [x] literal";
|
||||
const chunks = renderTelegramMessage(markdown, {
|
||||
mode: "markdown",
|
||||
});
|
||||
assert.equal(chunks.length, 1);
|
||||
assert.equal((chunks[0]?.text ?? "").includes("<code>[ ]</code>"), false);
|
||||
assert.equal((chunks[0]?.text ?? "").includes("<code>[x]</code>"), false);
|
||||
assert.match(chunks[0]?.text ?? "", /Use \[ \] as a placeholder/);
|
||||
assert.match(chunks[0]?.text ?? "", /keep \[x\] literal/);
|
||||
});
|
||||
|
||||
test("Nested blockquotes flatten into one Telegram blockquote with indentation", () => {
|
||||
const chunks = renderTelegramMessage("> outer\n>> inner\n>>> deepest", {
|
||||
mode: "markdown",
|
||||
});
|
||||
assert.equal(chunks.length, 1);
|
||||
assert.equal((chunks[0]?.text.match(/<blockquote>/g) ?? []).length, 1);
|
||||
assert.equal((chunks[0]?.text.match(/<\/blockquote>/g) ?? []).length, 1);
|
||||
assert.match(chunks[0]?.text ?? "", /outer/);
|
||||
assert.match(chunks[0]?.text ?? "", /\u00A0\u00A0inner/);
|
||||
assert.match(chunks[0]?.text ?? "", /\u00A0\u00A0\u00A0\u00A0deepest/);
|
||||
});
|
||||
|
||||
test("UI/compat Markdown tables render as literal monospace blocks without outer side borders", () => {
|
||||
const chunks = renderTelegramMessage(
|
||||
"| Name | Value |\n| --- | --- |\n| **x** | `y` |",
|
||||
{ mode: "markdown" },
|
||||
);
|
||||
assert.equal(chunks.length, 1);
|
||||
assert.match(chunks[0]?.text ?? "", /<pre><code class="language-markdown">/);
|
||||
assert.equal((chunks[0]?.text ?? "").includes("<b>x</b>"), false);
|
||||
assert.match(chunks[0]?.text ?? "", /Name\s+\|\s+Value/);
|
||||
assert.match(chunks[0]?.text ?? "", /x\s+\|\s+y/);
|
||||
assert.equal((chunks[0]?.text ?? "").includes("| Name |"), false);
|
||||
assert.equal((chunks[0]?.text ?? "").includes("| x |"), false);
|
||||
});
|
||||
|
||||
test("Markdown table padding uses emoji grapheme display width", () => {
|
||||
const chunks = renderTelegramMessage(
|
||||
"| Icon | Name |\n| --- | --- |\n| 👩💻 | dev |\n| ✅ | done |\n| 1️⃣ | key |",
|
||||
{ mode: "markdown" },
|
||||
);
|
||||
assert.equal(chunks.length, 1);
|
||||
assert.match(chunks[0]?.text ?? "", /Icon \| Name/);
|
||||
assert.match(chunks[0]?.text ?? "", /👩💻 {3}\| dev /u);
|
||||
assert.match(chunks[0]?.text ?? "", /✅ {3}\| done/u);
|
||||
assert.match(chunks[0]?.text ?? "", /1️⃣ {3}\| key /u);
|
||||
});
|
||||
|
||||
test("Markdown table padding handles CJK and combining marks", () => {
|
||||
const chunks = renderTelegramMessage(
|
||||
"| Word | Note |\n| --- | --- |\n| é | acute |\n| 東京 | city |",
|
||||
{ mode: "markdown" },
|
||||
);
|
||||
assert.equal(chunks.length, 1);
|
||||
assert.match(chunks[0]?.text ?? "", /Word \| Note /);
|
||||
assert.match(chunks[0]?.text ?? "", /é {4}\| acute/u);
|
||||
assert.match(chunks[0]?.text ?? "", /東京 \| city /u);
|
||||
});
|
||||
|
||||
test("Links, code spans, and underscore-heavy text coexist safely", () => {
|
||||
const chunks = renderTelegramMessage(
|
||||
"See [docs](https://example.com), run `foo_bar()` and keep foo_bar.txt literal",
|
||||
{ mode: "markdown" },
|
||||
);
|
||||
assert.equal(chunks.length, 1);
|
||||
assert.match(
|
||||
chunks[0]?.text ?? "",
|
||||
/<a href="https:\/\/example.com">docs<\/a>/,
|
||||
);
|
||||
assert.match(chunks[0]?.text ?? "", /<code>foo_bar\(\)<\/code>/);
|
||||
assert.equal((chunks[0]?.text ?? "").includes("<i>bar</i>"), false);
|
||||
});
|
||||
|
||||
test("HTML attributes are escaped or sanitized in generated Telegram markup", () => {
|
||||
const linkChunks = renderTelegramMessage(
|
||||
'[quoted](<https://example.com/"quoted">)',
|
||||
{ mode: "markdown" },
|
||||
);
|
||||
assert.match(
|
||||
linkChunks[0]?.text ?? "",
|
||||
/<a href="https:\/\/example.com\/"quoted"">quoted<\/a>/,
|
||||
);
|
||||
const codeChunks = renderTelegramMessage(
|
||||
'```ts"onclick=bad\nconst value = 1;\n```',
|
||||
{ mode: "markdown" },
|
||||
);
|
||||
assert.match(
|
||||
codeChunks[0]?.text ?? "",
|
||||
/<pre><code class="language-tsonclickbad">/,
|
||||
);
|
||||
assert.equal((codeChunks[0]?.text ?? "").includes('"onclick'), false);
|
||||
});
|
||||
|
||||
test("HTML mode chunks long messages below Telegram limits", () => {
|
||||
const chunks = renderTelegramMessage("x".repeat(5000), {
|
||||
mode: "html",
|
||||
});
|
||||
assert.ok(chunks.length > 1);
|
||||
for (const chunk of chunks) {
|
||||
assert.equal(chunk.parseMode, "HTML");
|
||||
assert.ok(chunk.text.length <= MAX_MESSAGE_LENGTH);
|
||||
}
|
||||
});
|
||||
|
||||
test("HTML mode keeps tags balanced across long chunk boundaries", () => {
|
||||
const chunks = renderTelegramMessage(
|
||||
`<blockquote><b>${"x".repeat(5000)}</b></blockquote>`,
|
||||
{ mode: "html" },
|
||||
);
|
||||
assert.ok(chunks.length > 1);
|
||||
for (const chunk of chunks) {
|
||||
assert.equal(chunk.parseMode, "HTML");
|
||||
assert.ok(chunk.text.length <= MAX_MESSAGE_LENGTH);
|
||||
assert.equal(
|
||||
(chunk.text.match(/<blockquote>/g) ?? []).length,
|
||||
(chunk.text.match(/<\/blockquote>/g) ?? []).length,
|
||||
);
|
||||
assert.equal(
|
||||
(chunk.text.match(/<b>/g) ?? []).length,
|
||||
(chunk.text.match(/<\/b>/g) ?? []).length,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("Links degrade or normalize safely across supported and unsupported markdown forms", () => {
|
||||
const markdown = [
|
||||
"[**Bold** label](https://example.com/path)",
|
||||
"[Docs](https://example.com/a_(b))",
|
||||
'[Title](https://example.com/path "Tooltip")',
|
||||
"[Relative](./docs/README.md)",
|
||||
"[Ref][docs]",
|
||||
"",
|
||||
"[docs]: https://example.com/ref",
|
||||
"",
|
||||
"Footnote[^1]",
|
||||
"",
|
||||
"[^1]: Footnote body",
|
||||
].join("\n");
|
||||
const chunks = renderTelegramMessage(markdown, {
|
||||
mode: "markdown",
|
||||
});
|
||||
assert.equal(chunks.length, 1);
|
||||
assert.match(
|
||||
chunks[0]?.text ?? "",
|
||||
/<a href="https:\/\/example.com\/path">Bold label<\/a>/,
|
||||
);
|
||||
assert.match(
|
||||
chunks[0]?.text ?? "",
|
||||
/<a href="https:\/\/example.com\/a_\(b\)">Docs<\/a>/,
|
||||
);
|
||||
assert.match(
|
||||
chunks[0]?.text ?? "",
|
||||
/<a href="https:\/\/example.com\/path">Title<\/a>/,
|
||||
);
|
||||
assert.equal(
|
||||
(chunks[0]?.text ?? "").includes('<a href="./docs/README.md">'),
|
||||
false,
|
||||
);
|
||||
assert.match(chunks[0]?.text ?? "", /Relative/);
|
||||
assert.equal(
|
||||
(chunks[0]?.text ?? "").includes('<a href="https://example.com/ref">'),
|
||||
false,
|
||||
);
|
||||
assert.match(chunks[0]?.text ?? "", /\[Ref\]\[docs\]/);
|
||||
assert.match(chunks[0]?.text ?? "", /Footnote\[\^1\]/);
|
||||
assert.match(chunks[0]?.text ?? "", /\[\^1\]: Footnote body/);
|
||||
});
|
||||
|
||||
test("Long quoted blocks stay chunked with balanced blockquote tags", () => {
|
||||
const markdown = Array.from(
|
||||
{ length: 500 },
|
||||
(_, index) => `> quoted **${index}** line`,
|
||||
).join("\n");
|
||||
const chunks = renderTelegramMessage(markdown, {
|
||||
mode: "markdown",
|
||||
});
|
||||
assert.ok(chunks.length > 1);
|
||||
for (const chunk of chunks) {
|
||||
assert.ok(chunk.text.length <= MAX_MESSAGE_LENGTH);
|
||||
assert.equal(
|
||||
(chunk.text.match(/<blockquote>/g) ?? []).length,
|
||||
(chunk.text.match(/<\/blockquote>/g) ?? []).length,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("Long UI/compat Markdown messages stay chunked below Telegram limits", () => {
|
||||
const markdown = Array.from(
|
||||
{ length: 600 },
|
||||
(_, index) => `- item **${index}**`,
|
||||
).join("\n");
|
||||
const chunks = renderTelegramMessage(markdown, {
|
||||
mode: "markdown",
|
||||
});
|
||||
assert.ok(chunks.length > 1);
|
||||
for (const chunk of chunks) {
|
||||
assert.ok(chunk.text.length <= MAX_MESSAGE_LENGTH);
|
||||
assert.equal(
|
||||
(chunk.text.match(/<b>/g) ?? []).length,
|
||||
(chunk.text.match(/<\/b>/g) ?? []).length,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("Long mixed links and code spans stay chunked with balanced inline tags", () => {
|
||||
const markdown = Array.from(
|
||||
{ length: 450 },
|
||||
(_, index) =>
|
||||
`Paragraph ${index}: see [docs ${index}](https://example.com/${index}), run \`code_${index}()\`, and keep foo_bar_${index}.txt literal`,
|
||||
).join("\n\n");
|
||||
const chunks = renderTelegramMessage(markdown, {
|
||||
mode: "markdown",
|
||||
});
|
||||
assert.ok(chunks.length > 1);
|
||||
for (const chunk of chunks) {
|
||||
assert.ok(chunk.text.length <= MAX_MESSAGE_LENGTH);
|
||||
assert.equal(
|
||||
(chunk.text.match(/<a /g) ?? []).length,
|
||||
(chunk.text.match(/<\/a>/g) ?? []).length,
|
||||
);
|
||||
assert.equal(
|
||||
(chunk.text.match(/<code>/g) ?? []).length,
|
||||
(chunk.text.match(/<\/code>/g) ?? []).length,
|
||||
);
|
||||
assert.equal((chunk.text ?? "").includes("<i>bar</i>"), false);
|
||||
}
|
||||
});
|
||||
|
||||
test("Long multi-block markdown keeps quotes and code fences structurally balanced", () => {
|
||||
const markdown = Array.from({ length: 120 }, (_, index) => {
|
||||
return [
|
||||
`## Section ${index}`,
|
||||
`> quoted **${index}** line`,
|
||||
`- item ${index}`,
|
||||
"```ts",
|
||||
`const value_${index} = "**raw**";`,
|
||||
"```",
|
||||
].join("\n");
|
||||
}).join("\n\n");
|
||||
const chunks = renderTelegramMessage(markdown, {
|
||||
mode: "markdown",
|
||||
});
|
||||
assert.ok(chunks.length > 1);
|
||||
for (const chunk of chunks) {
|
||||
assert.ok(chunk.text.length <= MAX_MESSAGE_LENGTH);
|
||||
assert.equal(
|
||||
(chunk.text.match(/<blockquote>/g) ?? []).length,
|
||||
(chunk.text.match(/<\/blockquote>/g) ?? []).length,
|
||||
);
|
||||
assert.equal(
|
||||
(chunk.text.match(/<pre><code/g) ?? []).length,
|
||||
(chunk.text.match(/<\/code><\/pre>/g) ?? []).length,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("Chunked mixed block transitions keep quote and list structure balanced", () => {
|
||||
const markdown = Array.from({ length: 260 }, (_, index) => {
|
||||
return [
|
||||
`> quoted **${index}** intro`,
|
||||
`> continuation ${index}`,
|
||||
`- item ${index}`,
|
||||
`plain paragraph ${index} with [link](https://example.com/${index})`,
|
||||
].join("\n");
|
||||
}).join("\n\n");
|
||||
const chunks = renderTelegramMessage(markdown, {
|
||||
mode: "markdown",
|
||||
});
|
||||
assert.ok(chunks.length > 1);
|
||||
for (const chunk of chunks) {
|
||||
assert.ok(chunk.text.length <= MAX_MESSAGE_LENGTH);
|
||||
assert.equal(
|
||||
(chunk.text.match(/<blockquote>/g) ?? []).length,
|
||||
(chunk.text.match(/<\/blockquote>/g) ?? []).length,
|
||||
);
|
||||
assert.equal(
|
||||
(chunk.text.match(/<a /g) ?? []).length,
|
||||
(chunk.text.match(/<\/a>/g) ?? []).length,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("Chunked code fence transitions keep code blocks closed before following prose", () => {
|
||||
const markdown = Array.from({ length: 220 }, (_, index) => {
|
||||
return [
|
||||
"```ts",
|
||||
`const block_${index} = "value_${index}";`,
|
||||
"```",
|
||||
`After code **${index}** and \`inline_${index}()\``,
|
||||
].join("\n");
|
||||
}).join("\n\n");
|
||||
const chunks = renderTelegramMessage(markdown, {
|
||||
mode: "markdown",
|
||||
});
|
||||
assert.ok(chunks.length > 1);
|
||||
for (const chunk of chunks) {
|
||||
assert.ok(chunk.text.length <= MAX_MESSAGE_LENGTH);
|
||||
assert.equal(
|
||||
(chunk.text.match(/<pre><code/g) ?? []).length,
|
||||
(chunk.text.match(/<\/code><\/pre>/g) ?? []).length,
|
||||
);
|
||||
assert.equal(
|
||||
(chunk.text.match(/<code(?: class="[^"]+")?>/g) ?? []).length,
|
||||
(chunk.text.match(/<\/code>/g) ?? []).length,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("Long inline formatting paragraphs stay balanced across chunk boundaries", () => {
|
||||
const markdown = Array.from({ length: 500 }, (_, index) => {
|
||||
return `Segment ${index} keeps **bold_${index}** with \`code_${index}()\`, [link_${index}](https://example.com/${index}), and foo_bar_${index}.txt literal.`;
|
||||
}).join(" ");
|
||||
const chunks = renderTelegramMessage(markdown, {
|
||||
mode: "markdown",
|
||||
});
|
||||
assert.ok(chunks.length > 1);
|
||||
for (const chunk of chunks) {
|
||||
assert.ok(chunk.text.length <= MAX_MESSAGE_LENGTH);
|
||||
assert.equal(
|
||||
(chunk.text.match(/<b>/g) ?? []).length,
|
||||
(chunk.text.match(/<\/b>/g) ?? []).length,
|
||||
);
|
||||
assert.equal(
|
||||
(chunk.text.match(/<a /g) ?? []).length,
|
||||
(chunk.text.match(/<\/a>/g) ?? []).length,
|
||||
);
|
||||
assert.equal(
|
||||
(chunk.text.match(/<code>/g) ?? []).length,
|
||||
(chunk.text.match(/<\/code>/g) ?? []).length,
|
||||
);
|
||||
assert.equal(chunk.text.includes("<i>bar</i>"), false);
|
||||
}
|
||||
});
|
||||
|
||||
test("Realistic Markdown fixtures render safely for UI/compat surfaces", () => {
|
||||
const fixtures = [
|
||||
[
|
||||
"Here is the UI action plan:",
|
||||
"",
|
||||
"1. [x] Snapshot the database",
|
||||
"2. [ ] Apply the patch with `pnpm db:migrate`",
|
||||
"3. [ ] Verify [Grafana](https://example.com/dashboards/db)",
|
||||
"",
|
||||
"> Note: keep `user_id` and `team_id` untouched.",
|
||||
"",
|
||||
"```sql",
|
||||
"ALTER TABLE users ADD COLUMN team_id uuid; -- **literal**",
|
||||
"```",
|
||||
].join("\n"),
|
||||
[
|
||||
"### Comparison",
|
||||
"| Option | Pros | Cons |",
|
||||
"| --- | --- | --- |",
|
||||
"| A | Fast | More foo_bar flags |",
|
||||
"| B | Safer | Requires rollback notes |",
|
||||
"",
|
||||
"Unsupported relative link: [local](./notes.md)",
|
||||
"Supported link: [docs](https://example.com/path?q=a_b)",
|
||||
].join("\n"),
|
||||
[
|
||||
"The JSON payload should stay literal:",
|
||||
"",
|
||||
"```json",
|
||||
'{ "markdown": "**not bold**", "path": "foo_bar/baz" }',
|
||||
"```",
|
||||
"",
|
||||
"Nested quote from interactive compatibility message:",
|
||||
"> outer",
|
||||
">> inner with **bold**",
|
||||
].join("\n"),
|
||||
];
|
||||
for (const markdown of fixtures) {
|
||||
const chunks = renderTelegramMessage(markdown, {
|
||||
mode: "markdown",
|
||||
});
|
||||
assert.ok(chunks.length > 0);
|
||||
for (const chunk of chunks) {
|
||||
assert.equal(chunk.parseMode, "HTML");
|
||||
assert.ok(chunk.text.length <= MAX_MESSAGE_LENGTH);
|
||||
assert.equal(
|
||||
countMatches(chunk.text, /<a /g),
|
||||
countMatches(chunk.text, /<\/a>/g),
|
||||
);
|
||||
assert.equal(
|
||||
countMatches(chunk.text, /<blockquote>/g),
|
||||
countMatches(chunk.text, /<\/blockquote>/g),
|
||||
);
|
||||
assert.equal(
|
||||
countMatches(chunk.text, /<pre><code/g),
|
||||
countMatches(chunk.text, /<\/code><\/pre>/g),
|
||||
);
|
||||
assert.equal(chunk.text.includes("<i>bar</i>"), false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("Chunked list, code, quote, and prose cycles stay balanced across transitions", () => {
|
||||
const markdown = Array.from({ length: 180 }, (_, index) => {
|
||||
return [
|
||||
`- list item **${index}**`,
|
||||
"```ts",
|
||||
`const cycle_${index} = "value_${index}";`,
|
||||
"```",
|
||||
`> quoted ${index} with [link](https://example.com/${index})`,
|
||||
`Plain paragraph ${index} with \`inline_${index}()\``,
|
||||
].join("\n");
|
||||
}).join("\n\n");
|
||||
const chunks = renderTelegramMessage(markdown, {
|
||||
mode: "markdown",
|
||||
});
|
||||
assert.ok(chunks.length > 1);
|
||||
for (const chunk of chunks) {
|
||||
assert.ok(chunk.text.length <= MAX_MESSAGE_LENGTH);
|
||||
assert.equal(
|
||||
(chunk.text.match(/<pre><code/g) ?? []).length,
|
||||
(chunk.text.match(/<\/code><\/pre>/g) ?? []).length,
|
||||
);
|
||||
assert.equal(
|
||||
(chunk.text.match(/<blockquote>/g) ?? []).length,
|
||||
(chunk.text.match(/<\/blockquote>/g) ?? []).length,
|
||||
);
|
||||
assert.equal(
|
||||
(chunk.text.match(/<a /g) ?? []).length,
|
||||
(chunk.text.match(/<\/a>/g) ?? []).length,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,919 @@
|
||||
/**
|
||||
* Regression tests for Telegram reply delivery helpers
|
||||
* Covers UI/compat rendered-message transport, chunk delivery, and native/plain final reply sending
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
// Transport-level dedup is module-global; reset between tests.
|
||||
test.beforeEach(() => {
|
||||
resetTransportReplyDedup();
|
||||
});
|
||||
|
||||
import {
|
||||
buildTelegramReplyParameters,
|
||||
buildTelegramReplyTransport,
|
||||
createGuestMarkdownReplySender,
|
||||
createReplyDedupRuntime,
|
||||
createTelegramRenderedMessageDeliveryRuntime,
|
||||
createTelegramRenderedMessageRuntime,
|
||||
dedupSendTextReply,
|
||||
editTelegramRenderedMessage,
|
||||
extractLatestAssistantMessageText,
|
||||
getAgentMessageText,
|
||||
isAssistantAgentMessage,
|
||||
normalizeTelegramNativeMarkdown,
|
||||
resetTransportReplyDedup,
|
||||
sendTelegramNativeMarkdownReply,
|
||||
sendTelegramPlainReply,
|
||||
sendTelegramRenderedChunks,
|
||||
splitTelegramNativeMarkdown,
|
||||
TELEGRAM_RICH_MESSAGE_MAX_BLOCKS,
|
||||
TELEGRAM_RICH_MESSAGE_MAX_CHARS,
|
||||
} from "../lib/replies.ts";
|
||||
import { createDedupAgentStartHook } from "../lib/lifecycle.ts";
|
||||
import { createTelegramThreadTarget } from "../lib/target.ts";
|
||||
|
||||
test("Reply helpers extract assistant message text and metadata", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: [{ type: "text", text: "question" }] },
|
||||
{
|
||||
role: "assistant",
|
||||
stopReason: "error",
|
||||
errorMessage: "boom",
|
||||
content: [
|
||||
{ type: "text", text: " hello " },
|
||||
{ type: "image", source: "ignored" },
|
||||
{ type: "text", text: "world " },
|
||||
],
|
||||
},
|
||||
];
|
||||
assert.equal(isAssistantAgentMessage(messages[1]), true);
|
||||
assert.equal(getAgentMessageText(messages[1]), "hello world");
|
||||
assert.deepEqual(extractLatestAssistantMessageText(messages), {
|
||||
text: "hello world",
|
||||
stopReason: "error",
|
||||
errorMessage: "boom",
|
||||
});
|
||||
});
|
||||
|
||||
test("Reply transport forwards send and edit operations through delivery helpers", async () => {
|
||||
const events: string[] = [];
|
||||
const transport = buildTelegramReplyTransport({
|
||||
sendMessage: async (body) => {
|
||||
events.push(`send:${body.chat_id}:${body.text}`);
|
||||
return { message_id: 5 };
|
||||
},
|
||||
editMessage: async (body) => {
|
||||
events.push(`edit:${body.chat_id}:${body.message_id}:${body.text}`);
|
||||
},
|
||||
});
|
||||
assert.equal(await transport.sendRenderedChunks(7, [{ text: "one" }]), 5);
|
||||
assert.equal(await transport.editRenderedMessage(7, 9, [{ text: "two" }]), 9);
|
||||
assert.deepEqual(events, ["send:7:one", "edit:7:9:two"]);
|
||||
});
|
||||
|
||||
test("Reply delivery includes thread target on rendered chunks", async () => {
|
||||
const sentBodies: Array<Record<string, unknown>> = [];
|
||||
const ownership: Array<Record<string, unknown>> = [];
|
||||
await sendTelegramRenderedChunks(
|
||||
-1007,
|
||||
[{ text: "one" }, { text: "two" }],
|
||||
{
|
||||
sendMessage: async (body) => {
|
||||
sentBodies.push(body);
|
||||
return { message_id: sentBodies.length };
|
||||
},
|
||||
editMessage: async () => {},
|
||||
recordOwnership: (input) => {
|
||||
ownership.push(input);
|
||||
},
|
||||
},
|
||||
{ target: createTelegramThreadTarget(-1007, 42) },
|
||||
);
|
||||
assert.deepEqual(
|
||||
sentBodies.map((body) => body.message_thread_id),
|
||||
[42, 42],
|
||||
);
|
||||
assert.deepEqual(
|
||||
ownership.map((record) => record.messageId),
|
||||
[1, 2],
|
||||
);
|
||||
assert.deepEqual(ownership[0]?.target, createTelegramThreadTarget(-1007, 42));
|
||||
});
|
||||
|
||||
test("Reply delivery includes thread target on rendered edits and continuation chunks", async () => {
|
||||
const sentBodies: Array<Record<string, unknown>> = [];
|
||||
const editedBodies: Array<Record<string, unknown>> = [];
|
||||
const ownership: Array<Record<string, unknown>> = [];
|
||||
await editTelegramRenderedMessage(
|
||||
-1007,
|
||||
9,
|
||||
[{ text: "one" }, { text: "two" }],
|
||||
{
|
||||
sendMessage: async (body) => {
|
||||
sentBodies.push(body);
|
||||
return { message_id: sentBodies.length };
|
||||
},
|
||||
editMessage: async (body) => {
|
||||
editedBodies.push(body);
|
||||
},
|
||||
recordOwnership: (input) => {
|
||||
ownership.push(input);
|
||||
},
|
||||
},
|
||||
{ target: createTelegramThreadTarget(-1007, 42) },
|
||||
);
|
||||
assert.equal(editedBodies[0]?.message_thread_id, 42);
|
||||
assert.equal(sentBodies[0]?.message_thread_id, 42);
|
||||
assert.deepEqual(
|
||||
ownership.map((record) => record.messageId),
|
||||
[9, 1],
|
||||
);
|
||||
});
|
||||
|
||||
test("Reply delivery sends chunks and applies reply markup only to the last chunk", async () => {
|
||||
const sentBodies: Array<Record<string, unknown>> = [];
|
||||
const messageId = await sendTelegramRenderedChunks(
|
||||
7,
|
||||
[{ text: "one" }, { text: "two", parseMode: "HTML" }],
|
||||
{
|
||||
sendMessage: async (body) => {
|
||||
sentBodies.push(body);
|
||||
return { message_id: sentBodies.length };
|
||||
},
|
||||
editMessage: async () => {},
|
||||
},
|
||||
{
|
||||
replyMarkup: {
|
||||
inline_keyboard: [[{ text: "ok", callback_data: "noop" }]],
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(messageId, 2);
|
||||
assert.deepEqual(sentBodies, [
|
||||
{ chat_id: 7, text: "one", parse_mode: undefined, reply_markup: undefined },
|
||||
{
|
||||
chat_id: 7,
|
||||
text: "two",
|
||||
parse_mode: "HTML",
|
||||
reply_markup: {
|
||||
inline_keyboard: [[{ text: "ok", callback_data: "noop" }]],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("Reply delivery rejects generated buttons above Telegram callback limit", async () => {
|
||||
const sentBodies: Array<Record<string, unknown>> = [];
|
||||
await assert.rejects(
|
||||
() =>
|
||||
sendTelegramRenderedChunks(
|
||||
7,
|
||||
[{ text: "one" }],
|
||||
{
|
||||
sendMessage: async (body) => {
|
||||
sentBodies.push(body);
|
||||
return { message_id: sentBodies.length };
|
||||
},
|
||||
editMessage: async () => {},
|
||||
},
|
||||
{
|
||||
replyMarkup: {
|
||||
inline_keyboard: [
|
||||
[{ text: "Too long", callback_data: "x".repeat(65) }],
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
/exceeds 64 bytes/,
|
||||
);
|
||||
assert.deepEqual(sentBodies, []);
|
||||
});
|
||||
|
||||
test("Reply delivery applies reply parameters only to the first chunk", async () => {
|
||||
const sentBodies: Array<Record<string, unknown>> = [];
|
||||
await sendTelegramRenderedChunks(
|
||||
7,
|
||||
[{ text: "one" }, { text: "two" }],
|
||||
{
|
||||
sendMessage: async (body) => {
|
||||
sentBodies.push(body);
|
||||
return { message_id: sentBodies.length };
|
||||
},
|
||||
editMessage: async () => {},
|
||||
},
|
||||
{ replyToMessageId: 42 },
|
||||
);
|
||||
assert.deepEqual(sentBodies[0]?.reply_parameters, {
|
||||
message_id: 42,
|
||||
allow_sending_without_reply: true,
|
||||
});
|
||||
assert.equal("reply_parameters" in (sentBodies[1] ?? {}), false);
|
||||
});
|
||||
|
||||
test("Reply delivery edits the first chunk and sends remaining chunks separately", async () => {
|
||||
const editedBodies: Array<Record<string, unknown>> = [];
|
||||
const sentBodies: Array<Record<string, unknown>> = [];
|
||||
const result = await editTelegramRenderedMessage(
|
||||
7,
|
||||
99,
|
||||
[{ text: "first", parseMode: "HTML" }, { text: "second" }],
|
||||
{
|
||||
sendMessage: async (body) => {
|
||||
sentBodies.push(body);
|
||||
return { message_id: 123 };
|
||||
},
|
||||
editMessage: async (body) => {
|
||||
editedBodies.push(body);
|
||||
},
|
||||
},
|
||||
{
|
||||
replyMarkup: {
|
||||
inline_keyboard: [[{ text: "ok", callback_data: "noop" }]],
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(result, 123);
|
||||
assert.deepEqual(editedBodies, [
|
||||
{
|
||||
chat_id: 7,
|
||||
message_id: 99,
|
||||
text: "first",
|
||||
parse_mode: "HTML",
|
||||
reply_markup: undefined,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(sentBodies, [
|
||||
{
|
||||
chat_id: 7,
|
||||
text: "second",
|
||||
parse_mode: undefined,
|
||||
reply_markup: {
|
||||
inline_keyboard: [[{ text: "ok", callback_data: "noop" }]],
|
||||
},
|
||||
},
|
||||
]);
|
||||
assert.equal("reply_parameters" in (sentBodies[0] ?? {}), false);
|
||||
});
|
||||
|
||||
test("Reply runtime bundles text, native markdown, and UI/compat interactive delivery", async () => {
|
||||
const sent: Array<Record<string, unknown>> = [];
|
||||
const richSent: Array<Record<string, unknown>> = [];
|
||||
const edited: Array<Record<string, unknown>> = [];
|
||||
const runtime = createTelegramRenderedMessageRuntime({
|
||||
renderTelegramMessage: (text, options) => [
|
||||
{ text: `${options?.mode ?? "plain"}:${text}` },
|
||||
],
|
||||
replyTransport: buildTelegramReplyTransport({
|
||||
sendMessage: async (body) => {
|
||||
sent.push(body);
|
||||
return { message_id: sent.length };
|
||||
},
|
||||
editMessage: async (body) => {
|
||||
edited.push(body);
|
||||
},
|
||||
}),
|
||||
sendRichMessage: async (body) => {
|
||||
richSent.push(body);
|
||||
return { message_id: 77 };
|
||||
},
|
||||
});
|
||||
assert.equal(await runtime.sendTextReply(7, 42, "hello"), 1);
|
||||
assert.equal(await runtime.sendMarkdownReply(7, 43, "**hello**"), 77);
|
||||
assert.equal(
|
||||
await runtime.sendInteractiveMessage(
|
||||
7,
|
||||
"menu",
|
||||
"html",
|
||||
{
|
||||
inline_keyboard: [],
|
||||
},
|
||||
{ replyToMessageId: 44 },
|
||||
),
|
||||
2,
|
||||
);
|
||||
await runtime.editInteractiveMessage(7, 9, "menu", "html", {
|
||||
inline_keyboard: [],
|
||||
});
|
||||
assert.deepEqual(
|
||||
sent.map((body) => body.text),
|
||||
["plain:hello", "html:menu"],
|
||||
);
|
||||
assert.deepEqual(richSent, [
|
||||
{
|
||||
chat_id: 7,
|
||||
rich_message: { markdown: "**hello**", skip_entity_detection: true },
|
||||
reply_markup: undefined,
|
||||
reply_parameters: {
|
||||
message_id: 43,
|
||||
allow_sending_without_reply: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(sent[0]?.reply_parameters, {
|
||||
message_id: 42,
|
||||
allow_sending_without_reply: true,
|
||||
});
|
||||
assert.deepEqual(sent[1]?.reply_markup, { inline_keyboard: [] });
|
||||
assert.deepEqual(sent[1]?.reply_parameters, {
|
||||
message_id: 44,
|
||||
allow_sending_without_reply: true,
|
||||
});
|
||||
assert.deepEqual(
|
||||
edited.map((body) => body.text),
|
||||
["html:menu"],
|
||||
);
|
||||
});
|
||||
|
||||
test("Plain reply includes thread target on rendered messages", async () => {
|
||||
const sentBodies: Array<Record<string, unknown>> = [];
|
||||
const runtime = createTelegramRenderedMessageRuntime({
|
||||
renderTelegramMessage: (text) => [{ text }],
|
||||
replyTransport: buildTelegramReplyTransport({
|
||||
sendMessage: async (body) => {
|
||||
sentBodies.push(body);
|
||||
return { message_id: 1 };
|
||||
},
|
||||
editMessage: async () => {},
|
||||
}),
|
||||
sendRichMessage: async () => ({ message_id: 77 }),
|
||||
});
|
||||
await runtime.sendTextReply(-1007, 9, "hello", {
|
||||
target: createTelegramThreadTarget(-1007, 42),
|
||||
});
|
||||
assert.equal(sentBodies[0]?.message_thread_id, 42);
|
||||
assert.deepEqual(sentBodies[0]?.reply_parameters, {
|
||||
message_id: 9,
|
||||
allow_sending_without_reply: true,
|
||||
});
|
||||
assert.equal("reply_to_message_id" in (sentBodies[0] ?? {}), false);
|
||||
});
|
||||
|
||||
test("Native Markdown reply anchors only the first rich chunk in thread targets", async () => {
|
||||
const sentBodies: Array<Record<string, unknown>> = [];
|
||||
const ownership: Array<Record<string, unknown>> = [];
|
||||
const first = "a".repeat(TELEGRAM_RICH_MESSAGE_MAX_CHARS - 10);
|
||||
const second = "b".repeat(30);
|
||||
const target = createTelegramThreadTarget(-1007, 42);
|
||||
await sendTelegramNativeMarkdownReply(
|
||||
-1007,
|
||||
9,
|
||||
`${first}\n\n${second}`,
|
||||
{
|
||||
recordOwnership: (input) => {
|
||||
ownership.push(input);
|
||||
},
|
||||
sendRichMessage: async (body) => {
|
||||
sentBodies.push(body);
|
||||
return { message_id: sentBodies.length };
|
||||
},
|
||||
},
|
||||
{ target },
|
||||
);
|
||||
assert.equal(sentBodies.length, 2);
|
||||
assert.equal(sentBodies[0]?.message_thread_id, 42);
|
||||
assert.equal(sentBodies[1]?.message_thread_id, 42);
|
||||
assert.deepEqual(sentBodies[0]?.reply_parameters, {
|
||||
message_id: 9,
|
||||
allow_sending_without_reply: true,
|
||||
});
|
||||
assert.equal("reply_parameters" in (sentBodies[1] ?? {}), false);
|
||||
assert.deepEqual(ownership, [
|
||||
{ chatId: -1007, messageId: 1, target },
|
||||
{ chatId: -1007, messageId: 2, target },
|
||||
]);
|
||||
});
|
||||
|
||||
test("Reply runtime can send markdown through native rich messages", async () => {
|
||||
const richBodies: Array<Record<string, unknown>> = [];
|
||||
const sentBodies: Array<Record<string, unknown>> = [];
|
||||
const runtime = createTelegramRenderedMessageRuntime({
|
||||
renderTelegramMessage: (text, options) => [
|
||||
{ text: `${options?.mode ?? "plain"}:${text}` },
|
||||
],
|
||||
replyTransport: buildTelegramReplyTransport({
|
||||
sendMessage: async (body) => {
|
||||
sentBodies.push(body);
|
||||
return { message_id: sentBodies.length };
|
||||
},
|
||||
editMessage: async () => {},
|
||||
}),
|
||||
sendRichMessage: async (body) => {
|
||||
richBodies.push(body);
|
||||
return { message_id: 77 };
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
await runtime.sendMarkdownReply(7, 43, "# hello", {
|
||||
replyMarkup: {
|
||||
inline_keyboard: [[{ text: "ok", callback_data: "noop" }]],
|
||||
},
|
||||
}),
|
||||
77,
|
||||
);
|
||||
assert.deepEqual(richBodies, [
|
||||
{
|
||||
chat_id: 7,
|
||||
rich_message: { markdown: "# hello", skip_entity_detection: true },
|
||||
reply_markup: {
|
||||
inline_keyboard: [[{ text: "ok", callback_data: "noop" }]],
|
||||
},
|
||||
reply_parameters: {
|
||||
message_id: 43,
|
||||
allow_sending_without_reply: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(sentBodies, []);
|
||||
});
|
||||
|
||||
test("Reply runtime uses native rich messages for anchored thread markdown replies", async () => {
|
||||
const sentBodies: Array<Record<string, unknown>> = [];
|
||||
const richBodies: Array<Record<string, unknown>> = [];
|
||||
const target = createTelegramThreadTarget(-1007, 42);
|
||||
const runtime = createTelegramRenderedMessageRuntime({
|
||||
renderTelegramMessage: (text, options) => [
|
||||
{ text: `${options?.mode ?? "plain"}:${text}`, parseMode: "HTML" },
|
||||
],
|
||||
replyTransport: buildTelegramReplyTransport({
|
||||
sendMessage: async (body) => {
|
||||
sentBodies.push(body);
|
||||
return { message_id: sentBodies.length };
|
||||
},
|
||||
editMessage: async () => {},
|
||||
}),
|
||||
sendRichMessage: async (body) => {
|
||||
richBodies.push(body);
|
||||
return { message_id: 77 };
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
await runtime.sendMarkdownReply(-1007, 43, "# hello", {
|
||||
replyMarkup: {
|
||||
inline_keyboard: [[{ text: "ok", callback_data: "noop" }]],
|
||||
},
|
||||
target,
|
||||
}),
|
||||
77,
|
||||
);
|
||||
|
||||
assert.deepEqual(sentBodies, []);
|
||||
assert.deepEqual(richBodies, [
|
||||
{
|
||||
chat_id: -1007,
|
||||
rich_message: { markdown: "# hello", skip_entity_detection: true },
|
||||
reply_markup: {
|
||||
inline_keyboard: [[{ text: "ok", callback_data: "noop" }]],
|
||||
},
|
||||
reply_parameters: {
|
||||
message_id: 43,
|
||||
allow_sending_without_reply: true,
|
||||
},
|
||||
message_thread_id: 42,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("Reply runtime does not fall back to HTML when native rich message delivery fails", async () => {
|
||||
const sentBodies: Array<Record<string, unknown>> = [];
|
||||
const runtime = createTelegramRenderedMessageRuntime({
|
||||
renderTelegramMessage: (text, options) => [
|
||||
{ text: `${options?.mode ?? "plain"}:${text}`, parseMode: "HTML" },
|
||||
],
|
||||
replyTransport: buildTelegramReplyTransport({
|
||||
sendMessage: async (body) => {
|
||||
sentBodies.push(body);
|
||||
return { message_id: sentBodies.length };
|
||||
},
|
||||
editMessage: async () => {},
|
||||
}),
|
||||
sendRichMessage: async () => {
|
||||
throw new Error("rich unsupported");
|
||||
},
|
||||
});
|
||||
await assert.rejects(() => runtime.sendMarkdownReply(7, 43, "# hello"), {
|
||||
message: "rich unsupported",
|
||||
});
|
||||
assert.deepEqual(sentBodies, []);
|
||||
});
|
||||
|
||||
test("Reply delivery runtime exposes transport and UI/compat rendered-message helpers", async () => {
|
||||
const sent: Array<Record<string, unknown>> = [];
|
||||
const runtime = createTelegramRenderedMessageDeliveryRuntime({
|
||||
renderTelegramMessage: (text, options) => [
|
||||
{ text: `${options?.mode ?? "plain"}:${text}` },
|
||||
],
|
||||
sendMessage: async (body) => {
|
||||
sent.push(body);
|
||||
return { message_id: sent.length };
|
||||
},
|
||||
editMessage: async () => {},
|
||||
sendRichMessage: async () => ({ message_id: 99 }),
|
||||
});
|
||||
assert.equal(await runtime.sendTextReply(7, 42, "hello"), 1);
|
||||
assert.equal(
|
||||
await runtime.replyTransport.sendRenderedChunks(7, [{ text: "raw" }]),
|
||||
2,
|
||||
);
|
||||
assert.deepEqual(
|
||||
sent.map((body) => body.text),
|
||||
["plain:hello", "raw"],
|
||||
);
|
||||
});
|
||||
|
||||
test("Guest replies answer with native Rich Markdown content", async () => {
|
||||
const calls: Array<{
|
||||
guestQueryId: string;
|
||||
text?: string;
|
||||
options?: {
|
||||
richMessage?: { markdown?: string; skip_entity_detection?: boolean };
|
||||
};
|
||||
}> = [];
|
||||
const sendGuestReply = createGuestMarkdownReplySender({
|
||||
answerGuestQuery: async (guestQueryId, text, options) => {
|
||||
calls.push({ guestQueryId, text, options });
|
||||
},
|
||||
});
|
||||
await sendGuestReply("guest-1", "**hello** /start");
|
||||
assert.deepEqual(calls, [
|
||||
{
|
||||
guestQueryId: "guest-1",
|
||||
text: undefined,
|
||||
options: {
|
||||
richMessage: {
|
||||
markdown: "**hello** /start",
|
||||
skip_entity_detection: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("Native Markdown delivery normalizes space-after-marker blockquotes outside code", () => {
|
||||
const markdown = "> quoted\n\n```md\n> quoted code\n```";
|
||||
assert.equal(
|
||||
normalizeTelegramNativeMarkdown(markdown),
|
||||
">quoted\n\n```md\n> quoted code\n```",
|
||||
);
|
||||
});
|
||||
|
||||
test("Native Markdown delivery neutralizes indented list markers that break Rich Markdown", () => {
|
||||
const markdown = [
|
||||
"**HEAD**",
|
||||
"",
|
||||
" - alpha/beta gamma-delta with `inline.code`",
|
||||
"",
|
||||
"END",
|
||||
].join("\n");
|
||||
assert.equal(
|
||||
normalizeTelegramNativeMarkdown(markdown),
|
||||
[
|
||||
"**HEAD**",
|
||||
"",
|
||||
"\u00A0\u00A0- alpha/beta gamma-delta with `inline.code`",
|
||||
"",
|
||||
"END",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
test("Native Markdown delivery preserves top-level list markers", () => {
|
||||
assert.equal(
|
||||
normalizeTelegramNativeMarkdown("- plain\n - nested\n\t- tabbed"),
|
||||
"- plain\n\u00A0\u00A0- nested\n\u00A0\u00A0- tabbed",
|
||||
);
|
||||
});
|
||||
|
||||
test("Native Markdown delivery converts multiline display math to math fences", () => {
|
||||
const markdown = [
|
||||
"Block math:",
|
||||
"",
|
||||
"$$",
|
||||
"\\int_0^1 x^2 dx = \\frac{1}{3}",
|
||||
"$$",
|
||||
"",
|
||||
"```md",
|
||||
"$$",
|
||||
"literal fenced math delimiters",
|
||||
"$$",
|
||||
"```",
|
||||
].join("\n");
|
||||
assert.equal(
|
||||
normalizeTelegramNativeMarkdown(markdown),
|
||||
[
|
||||
"Block math:",
|
||||
"",
|
||||
"```math",
|
||||
"\\int_0^1 x^2 dx = \\frac{1}{3}",
|
||||
"```",
|
||||
"",
|
||||
"```md",
|
||||
"$$",
|
||||
"literal fenced math delimiters",
|
||||
"$$",
|
||||
"```",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
test("Native Markdown delivery ignores math delimiters inside fences when pairing", () => {
|
||||
const markdown = ["$$", "```md", "$$", "```"].join("\n");
|
||||
assert.equal(normalizeTelegramNativeMarkdown(markdown), markdown);
|
||||
});
|
||||
|
||||
test("Native Markdown splitter keeps truncation-risk fixture intact", () => {
|
||||
const markdown = [
|
||||
"**HEAD**",
|
||||
"",
|
||||
" - alpha/beta gamma-delta with `inline.code`",
|
||||
"",
|
||||
"END",
|
||||
].join("\n");
|
||||
const chunks = splitTelegramNativeMarkdown(markdown);
|
||||
assert.equal(chunks.length, 1);
|
||||
assert.match(chunks[0] ?? "", /inline\.code/);
|
||||
assert.match(chunks[0] ?? "", /END$/);
|
||||
});
|
||||
|
||||
test("Native Markdown delivery sends normalized risky fixture without losing tail text", async () => {
|
||||
const bodies: Array<Record<string, unknown>> = [];
|
||||
await sendTelegramNativeMarkdownReply(
|
||||
7,
|
||||
undefined,
|
||||
[
|
||||
"**HEAD**",
|
||||
"",
|
||||
" - alpha/beta gamma-delta with `inline.code`",
|
||||
"",
|
||||
"END",
|
||||
].join("\n"),
|
||||
{
|
||||
sendRichMessage: async (body) => {
|
||||
bodies.push(body);
|
||||
return { message_id: bodies.length };
|
||||
},
|
||||
},
|
||||
);
|
||||
const markdown =
|
||||
(bodies[0]?.rich_message as { markdown?: string })?.markdown ?? "";
|
||||
assert.match(markdown, /^\*\*HEAD\*\*/);
|
||||
assert.match(
|
||||
markdown,
|
||||
/\u00A0\u00A0- alpha\/beta gamma-delta with `inline\.code`/,
|
||||
);
|
||||
assert.match(markdown, /END$/);
|
||||
});
|
||||
|
||||
test("Native Markdown delivery escapes dollar ticker atoms outside code", () => {
|
||||
const markdown = [
|
||||
"Токен $BLDR может ломать math parsing.",
|
||||
"Bold ticker **$BTC** тоже ломает rich parsing.",
|
||||
"Токен $NTVE, $VETO. и $NTVE/Bucket тоже опасны.",
|
||||
"Inline code: `$BLDR`, formula $x^2 + y^2$, and explicit math $BTC$ stay unchanged.",
|
||||
"```md",
|
||||
"$BLDR in code fence",
|
||||
"```",
|
||||
].join("\n");
|
||||
assert.equal(
|
||||
normalizeTelegramNativeMarkdown(markdown),
|
||||
[
|
||||
"Токен \\$BLDR может ломать math parsing.",
|
||||
"Bold ticker **\\$BTC** тоже ломает rich parsing.",
|
||||
"Токен \\$NTVE, \\$VETO. и \\$NTVE/Bucket тоже опасны.",
|
||||
"Inline code: `$BLDR`, formula $x^2 + y^2$, and explicit math $BTC$ stay unchanged.",
|
||||
"```md",
|
||||
"$BLDR in code fence",
|
||||
"```",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
test("Native Markdown splitter prefers paragraph boundaries", () => {
|
||||
const first = "a".repeat(TELEGRAM_RICH_MESSAGE_MAX_CHARS - 10);
|
||||
const second = "b".repeat(30);
|
||||
const chunks = splitTelegramNativeMarkdown(`${first}\n\n${second}`);
|
||||
assert.deepEqual(chunks, [first, second]);
|
||||
assert.ok(
|
||||
chunks.every((chunk) => chunk.length <= TELEGRAM_RICH_MESSAGE_MAX_CHARS),
|
||||
);
|
||||
});
|
||||
|
||||
test("Native Markdown splitter falls back to hard limits for long atoms", () => {
|
||||
const chunks = splitTelegramNativeMarkdown(
|
||||
"x".repeat(TELEGRAM_RICH_MESSAGE_MAX_CHARS + 5),
|
||||
);
|
||||
assert.equal(chunks.length, 2);
|
||||
assert.equal(chunks[0]?.length, TELEGRAM_RICH_MESSAGE_MAX_CHARS);
|
||||
assert.equal(chunks[1], "xxxxx");
|
||||
});
|
||||
|
||||
test("Native Markdown splitter keeps fenced code blocks together when possible", () => {
|
||||
const codeBlock = `\`\`\`ts\n${"x\n\n".repeat(100)}\`\`\``;
|
||||
const prefix = "a".repeat(TELEGRAM_RICH_MESSAGE_MAX_CHARS - codeBlock.length);
|
||||
const chunks = splitTelegramNativeMarkdown(
|
||||
`${prefix}\n\n${codeBlock}\n\ntail`,
|
||||
);
|
||||
assert.equal(chunks.length, 2);
|
||||
assert.equal(chunks[1], `${codeBlock}\n\ntail`);
|
||||
});
|
||||
|
||||
test("Native Markdown splitter rewraps oversized fenced code blocks", () => {
|
||||
const chunks = splitTelegramNativeMarkdown(
|
||||
`\`\`\`ts\n${"x".repeat(TELEGRAM_RICH_MESSAGE_MAX_CHARS + 500)}\n\`\`\``,
|
||||
);
|
||||
assert.ok(chunks.length > 1);
|
||||
assert.ok(
|
||||
chunks.every((chunk) => chunk.length <= TELEGRAM_RICH_MESSAGE_MAX_CHARS),
|
||||
);
|
||||
assert.ok(chunks.every((chunk) => chunk.startsWith("```ts\n")));
|
||||
assert.ok(chunks.every((chunk) => chunk.endsWith("\n```")));
|
||||
const contentLength = chunks
|
||||
.map((chunk) => chunk.split("\n").slice(1, -1).join("\n").length)
|
||||
.reduce((sum, length) => sum + length, 0);
|
||||
assert.equal(contentLength, TELEGRAM_RICH_MESSAGE_MAX_CHARS + 500);
|
||||
});
|
||||
|
||||
test("Native Markdown splitter rewraps oversized display math blocks", () => {
|
||||
const chunks = splitTelegramNativeMarkdown(
|
||||
`$$\n${"x".repeat(TELEGRAM_RICH_MESSAGE_MAX_CHARS + 500)}\n$$`,
|
||||
);
|
||||
assert.ok(chunks.length > 1);
|
||||
assert.ok(
|
||||
chunks.every((chunk) => chunk.length <= TELEGRAM_RICH_MESSAGE_MAX_CHARS),
|
||||
);
|
||||
assert.ok(chunks.every((chunk) => chunk.startsWith("```math\n")));
|
||||
assert.ok(chunks.every((chunk) => chunk.endsWith("\n```")));
|
||||
});
|
||||
|
||||
test("Native Markdown splitter rewraps oversized inline formatting blocks", () => {
|
||||
const chunks = splitTelegramNativeMarkdown(
|
||||
`**${"x".repeat(TELEGRAM_RICH_MESSAGE_MAX_CHARS + 500)}**`,
|
||||
);
|
||||
assert.ok(chunks.length > 1);
|
||||
assert.ok(
|
||||
chunks.every((chunk) => chunk.length <= TELEGRAM_RICH_MESSAGE_MAX_CHARS),
|
||||
);
|
||||
assert.ok(chunks.every((chunk) => chunk.startsWith("**")));
|
||||
assert.ok(chunks.every((chunk) => chunk.endsWith("**")));
|
||||
assert.equal(
|
||||
chunks
|
||||
.map((chunk) => chunk.slice(2, -2).length)
|
||||
.reduce((sum, length) => sum + length, 0),
|
||||
TELEGRAM_RICH_MESSAGE_MAX_CHARS + 500,
|
||||
);
|
||||
});
|
||||
|
||||
test("Native Markdown splitter respects the rich-message block limit for lists", () => {
|
||||
const markdown = Array.from(
|
||||
{ length: TELEGRAM_RICH_MESSAGE_MAX_BLOCKS + 5 },
|
||||
(_, index) => `- item ${index + 1}`,
|
||||
).join("\n");
|
||||
const chunks = splitTelegramNativeMarkdown(markdown);
|
||||
assert.equal(chunks.length, 2);
|
||||
assert.equal(chunks[0]?.split("\n").length, TELEGRAM_RICH_MESSAGE_MAX_BLOCKS);
|
||||
assert.equal(chunks[1]?.split("\n").length, 5);
|
||||
});
|
||||
|
||||
test("Native Markdown delivery attaches reply metadata first and markup last", async () => {
|
||||
const bodies: Array<Record<string, unknown>> = [];
|
||||
const markdown = `${"a".repeat(TELEGRAM_RICH_MESSAGE_MAX_CHARS - 10)}\n\n${"b".repeat(30)}`;
|
||||
const lastId = await sendTelegramNativeMarkdownReply(
|
||||
7,
|
||||
42,
|
||||
markdown,
|
||||
{
|
||||
sendRichMessage: async (body) => {
|
||||
bodies.push(body);
|
||||
return { message_id: bodies.length };
|
||||
},
|
||||
},
|
||||
{
|
||||
replyMarkup: {
|
||||
inline_keyboard: [[{ text: "ok", callback_data: "noop" }]],
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(lastId, 2);
|
||||
assert.equal(bodies.length, 2);
|
||||
assert.deepEqual(bodies[0]?.reply_parameters, {
|
||||
message_id: 42,
|
||||
allow_sending_without_reply: true,
|
||||
});
|
||||
assert.equal("reply_markup" in (bodies[0] ?? {}), true);
|
||||
assert.equal(bodies[0]?.reply_markup, undefined);
|
||||
assert.equal("reply_parameters" in (bodies[1] ?? {}), false);
|
||||
assert.deepEqual(bodies[1]?.reply_markup, {
|
||||
inline_keyboard: [[{ text: "ok", callback_data: "noop" }]],
|
||||
});
|
||||
assert.deepEqual(
|
||||
(bodies[0]?.rich_message as { markdown?: string })?.markdown,
|
||||
"a".repeat(TELEGRAM_RICH_MESSAGE_MAX_CHARS - 10),
|
||||
);
|
||||
assert.deepEqual(
|
||||
(bodies[1]?.rich_message as { markdown?: string })?.markdown,
|
||||
"b".repeat(30),
|
||||
);
|
||||
});
|
||||
|
||||
test("Reply runtime sends plain replies using the requested parse mode", async () => {
|
||||
const sent: string[] = [];
|
||||
const messageId = await sendTelegramPlainReply(
|
||||
"hello",
|
||||
{
|
||||
renderTelegramMessage: (_text, options) => [
|
||||
{ text: options?.mode === "html" ? "html" : "plain" },
|
||||
],
|
||||
sendRenderedChunks: async (chunks) => {
|
||||
sent.push(chunks[0]?.text ?? "");
|
||||
return 7;
|
||||
},
|
||||
},
|
||||
{ parseMode: "HTML" },
|
||||
);
|
||||
assert.equal(messageId, 7);
|
||||
assert.deepEqual(sent, ["html"]);
|
||||
});
|
||||
|
||||
test("Transport reply dedup scopes repeated prompt message ids by chat and thread", () => {
|
||||
assert.equal(buildTelegramReplyParameters(1, 0), undefined);
|
||||
assert.deepEqual(buildTelegramReplyParameters(1, 42), {
|
||||
message_id: 42,
|
||||
allow_sending_without_reply: true,
|
||||
});
|
||||
assert.equal(buildTelegramReplyParameters(1, 42), undefined);
|
||||
assert.deepEqual(buildTelegramReplyParameters(2, 42), {
|
||||
message_id: 42,
|
||||
allow_sending_without_reply: true,
|
||||
});
|
||||
assert.deepEqual(
|
||||
buildTelegramReplyParameters(1, 42, { chatId: 1, threadId: 10 }),
|
||||
{
|
||||
message_id: 42,
|
||||
allow_sending_without_reply: true,
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
buildTelegramReplyParameters(1, 42, { chatId: 1, threadId: 10 }),
|
||||
undefined,
|
||||
);
|
||||
assert.deepEqual(
|
||||
buildTelegramReplyParameters(1, 42, { chatId: 1, threadId: 11 }),
|
||||
{
|
||||
message_id: 42,
|
||||
allow_sending_without_reply: true,
|
||||
},
|
||||
);
|
||||
resetTransportReplyDedup();
|
||||
assert.deepEqual(buildTelegramReplyParameters(1, 42), {
|
||||
message_id: 42,
|
||||
allow_sending_without_reply: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("Reply dedup tracks first reply per prompt message id and resets", () => {
|
||||
const dedup = createReplyDedupRuntime();
|
||||
assert.equal(dedup.shouldReply(42), true);
|
||||
assert.equal(dedup.shouldReply(42), false);
|
||||
assert.equal(dedup.shouldReply(99), true);
|
||||
dedup.reset();
|
||||
assert.equal(dedup.shouldReply(42), true);
|
||||
});
|
||||
|
||||
test("Dedup wrapper suppresses reply_to_message_id after the first message in a turn", async () => {
|
||||
const dedup = createReplyDedupRuntime();
|
||||
const passedReplyIds: Array<number | undefined> = [];
|
||||
const inner = async (
|
||||
_chatId: number,
|
||||
replyToMessageId: number | undefined,
|
||||
) => {
|
||||
passedReplyIds.push(replyToMessageId);
|
||||
return 1;
|
||||
};
|
||||
const wrapped = dedupSendTextReply(dedup, inner);
|
||||
await wrapped(7, 42, "first");
|
||||
await wrapped(7, 42, "second");
|
||||
await wrapped(7, 99, "other");
|
||||
assert.deepEqual(passedReplyIds, [42, undefined, 99]);
|
||||
});
|
||||
|
||||
test("Dedup reset fires on agent_start through lifecycle hook", async () => {
|
||||
const dedup = createReplyDedupRuntime();
|
||||
dedup.shouldReply(42); // marks replied
|
||||
let agentStartCalled = false;
|
||||
const hook = createDedupAgentStartHook(dedup, async () => {
|
||||
agentStartCalled = true;
|
||||
});
|
||||
await hook(
|
||||
{} as Parameters<typeof hook>[0],
|
||||
{} as Parameters<typeof hook>[1],
|
||||
);
|
||||
assert.equal(agentStartCalled, true);
|
||||
assert.equal(
|
||||
dedup.shouldReply(42),
|
||||
true,
|
||||
"reset clears previous reply state",
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,516 @@
|
||||
/**
|
||||
* Regression tests for the runtime domain
|
||||
* Covers lib/runtime.ts state helpers, controllers, lifecycle hooks, typing, and progress primitives
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import testRoot, { type TestContext } from "node:test";
|
||||
|
||||
import * as Runtime from "../lib/runtime.ts";
|
||||
import { createTelegramThreadTarget } from "../lib/target.ts";
|
||||
|
||||
type RuntimeTestHandler = (context: TestContext) => void | Promise<void>;
|
||||
function test(name: string, fn: RuntimeTestHandler): void {
|
||||
void testRoot(name, { concurrency: false, timeout: 5000 }, fn);
|
||||
}
|
||||
|
||||
async function flushMicrotasks(iterations = 10): Promise<void> {
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
test("Runtime facade binds grouped operations to one bridge state", () => {
|
||||
const state = Runtime.createTelegramBridgeRuntimeState();
|
||||
const runtime = Runtime.createTelegramBridgeRuntime(state);
|
||||
assert.equal(runtime.state, state);
|
||||
assert.equal(runtime.queue.allocateItemOrder(), 0);
|
||||
assert.equal(runtime.queue.allocateControlOrder(), 0);
|
||||
runtime.queue.syncCounters({ nextPriorityReactionOrder: 5 });
|
||||
assert.equal(runtime.queue.getNextPriorityReactionOrder(), 5);
|
||||
runtime.queue.incrementNextPriorityReactionOrder();
|
||||
assert.equal(runtime.queue.getNextPriorityReactionOrder(), 6);
|
||||
runtime.lifecycle.setDispatchPending(true);
|
||||
runtime.lifecycle.setCompactionInProgress(true);
|
||||
runtime.lifecycle.setActiveToolExecutions(3);
|
||||
runtime.lifecycle.setFoldQueuedPromptsIntoHistory(true);
|
||||
assert.equal(runtime.lifecycle.hasDispatchPending(), true);
|
||||
assert.equal(runtime.lifecycle.isCompactionInProgress(), true);
|
||||
assert.equal(runtime.lifecycle.getActiveToolExecutions(), 3);
|
||||
runtime.lifecycle.clearDispatchPending();
|
||||
runtime.lifecycle.resetActiveToolExecutions();
|
||||
assert.equal(runtime.lifecycle.hasDispatchPending(), false);
|
||||
assert.equal(runtime.lifecycle.getActiveToolExecutions(), 0);
|
||||
assert.equal(runtime.lifecycle.shouldFoldQueuedPromptsIntoHistory(), true);
|
||||
assert.equal(runtime.setup.start(), true);
|
||||
assert.equal(runtime.setup.isInProgress(), true);
|
||||
runtime.setup.finish();
|
||||
assert.equal(runtime.setup.isInProgress(), false);
|
||||
let abortCount = 0;
|
||||
runtime.abort.setHandler(() => {
|
||||
abortCount += 1;
|
||||
});
|
||||
assert.equal(runtime.abort.hasHandler(), true);
|
||||
assert.equal(runtime.abort.abortTurn(), true);
|
||||
assert.equal(abortCount, 1);
|
||||
runtime.abort.clearHandler();
|
||||
assert.equal(runtime.abort.hasHandler(), false);
|
||||
});
|
||||
|
||||
test("Runtime state helpers allocate queue order and manage typing loops", async () => {
|
||||
const state = Runtime.createTelegramBridgeRuntimeState();
|
||||
assert.equal(Runtime.allocateTelegramQueueItemOrder(state), 0);
|
||||
assert.equal(Runtime.allocateTelegramQueueItemOrder(state), 1);
|
||||
assert.equal(Runtime.allocateTelegramQueueControlOrder(state), 0);
|
||||
assert.equal(Runtime.getNextTelegramPriorityReactionOrder(state), 0);
|
||||
Runtime.incrementNextTelegramPriorityReactionOrder(state);
|
||||
assert.equal(Runtime.getNextTelegramPriorityReactionOrder(state), 1);
|
||||
Runtime.syncTelegramQueueRuntimeCounters(state, {
|
||||
nextQueuedTelegramItemOrder: 10,
|
||||
nextQueuedTelegramControlOrder: 20,
|
||||
nextPriorityReactionOrder: 30,
|
||||
});
|
||||
assert.equal(Runtime.allocateTelegramQueueItemOrder(state), 10);
|
||||
assert.equal(Runtime.allocateTelegramQueueControlOrder(state), 20);
|
||||
assert.equal(Runtime.getNextTelegramPriorityReactionOrder(state), 30);
|
||||
assert.equal(Runtime.hasTelegramDispatchPending(state), false);
|
||||
assert.equal(Runtime.isTelegramCompactionInProgress(state), false);
|
||||
assert.equal(Runtime.getActiveTelegramToolExecutions(state), 0);
|
||||
assert.equal(Runtime.shouldFoldQueuedPromptsIntoHistory(state), false);
|
||||
Runtime.syncTelegramLifecycleRuntimeFlags(state, {
|
||||
activeTelegramToolExecutions: 2,
|
||||
telegramTurnDispatchPending: true,
|
||||
compactionInProgress: true,
|
||||
foldQueuedPromptsIntoHistory: true,
|
||||
});
|
||||
assert.equal(Runtime.hasTelegramDispatchPending(state), true);
|
||||
assert.equal(Runtime.isTelegramCompactionInProgress(state), true);
|
||||
assert.equal(Runtime.getActiveTelegramToolExecutions(state), 2);
|
||||
assert.equal(Runtime.shouldFoldQueuedPromptsIntoHistory(state), true);
|
||||
Runtime.clearTelegramDispatchPending(state);
|
||||
Runtime.setTelegramCompactionInProgress(state, false);
|
||||
Runtime.resetActiveTelegramToolExecutions(state);
|
||||
assert.equal(Runtime.hasTelegramDispatchPending(state), false);
|
||||
assert.equal(Runtime.getActiveTelegramToolExecutions(state), 0);
|
||||
Runtime.setActiveTelegramToolExecutions(state, 1);
|
||||
Runtime.setFoldQueuedPromptsIntoHistory(state, false);
|
||||
assert.equal(Runtime.startTelegramSetup(state), true);
|
||||
assert.equal(Runtime.startTelegramSetup(state), false);
|
||||
assert.equal(Runtime.isTelegramSetupInProgress(state), true);
|
||||
Runtime.finishTelegramSetup(state);
|
||||
assert.equal(Runtime.isTelegramSetupInProgress(state), false);
|
||||
let abortCount = 0;
|
||||
assert.equal(Runtime.hasTelegramAbortHandler(state), false);
|
||||
assert.equal(Runtime.abortTelegramTurn(state), false);
|
||||
Runtime.setTelegramAbortHandler(state, () => {
|
||||
abortCount += 1;
|
||||
});
|
||||
assert.equal(Runtime.hasTelegramAbortHandler(state), true);
|
||||
assert.equal(Runtime.abortTelegramTurn(state), true);
|
||||
assert.equal(abortCount, 1);
|
||||
assert.equal(typeof Runtime.getTelegramAbortHandler(state), "function");
|
||||
Runtime.clearTelegramAbortHandler(state);
|
||||
assert.equal(Runtime.hasTelegramAbortHandler(state), false);
|
||||
assert.equal(Runtime.hasTelegramDispatchPending(state), false);
|
||||
assert.equal(Runtime.isTelegramCompactionInProgress(state), false);
|
||||
assert.equal(Runtime.getActiveTelegramToolExecutions(state), 1);
|
||||
assert.equal(Runtime.shouldFoldQueuedPromptsIntoHistory(state), false);
|
||||
const typingActions: number[] = [];
|
||||
assert.equal(
|
||||
Runtime.startTelegramTypingLoop(state, {
|
||||
chatId: undefined,
|
||||
intervalMs: 1000,
|
||||
sendTypingAction: async (chatId) => {
|
||||
typingActions.push(chatId);
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
Runtime.startTelegramTypingLoop(state, {
|
||||
chatId: 42,
|
||||
intervalMs: 1000,
|
||||
sendTypingAction: async (chatId) => {
|
||||
typingActions.push(chatId);
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
await flushMicrotasks();
|
||||
assert.deepEqual(typingActions, [42]);
|
||||
assert.equal(
|
||||
Runtime.startTelegramTypingLoop(state, {
|
||||
chatId: 43,
|
||||
intervalMs: 1000,
|
||||
sendTypingAction: async (chatId) => {
|
||||
typingActions.push(chatId);
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
await flushMicrotasks();
|
||||
assert.deepEqual(typingActions, [42, 43]);
|
||||
assert.equal(Runtime.stopTelegramTypingLoop(state), true);
|
||||
assert.equal(Runtime.stopTelegramTypingLoop(state), false);
|
||||
});
|
||||
|
||||
test("Typing loop retargets chat-level activity into the active thread", async () => {
|
||||
const state = Runtime.createTelegramBridgeRuntimeState();
|
||||
const typingActions: Array<{
|
||||
chatId: number;
|
||||
threadId?: number;
|
||||
aggregate?: boolean;
|
||||
}> = [];
|
||||
const recordTypingAction = (
|
||||
chatId: number,
|
||||
options?: { message_thread_id?: number },
|
||||
): void => {
|
||||
typingActions.push({
|
||||
chatId,
|
||||
...(typeof options?.message_thread_id === "number"
|
||||
? { threadId: options.message_thread_id }
|
||||
: {}),
|
||||
});
|
||||
};
|
||||
assert.equal(
|
||||
Runtime.startTelegramTypingLoop(state, {
|
||||
chatId: 42,
|
||||
intervalMs: 1000,
|
||||
sendTypingAction: async (chatId, options) => {
|
||||
recordTypingAction(chatId, options);
|
||||
},
|
||||
sendAggregateTypingAction: async (chatId) => {
|
||||
typingActions.push({ chatId, aggregate: true });
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
await flushMicrotasks();
|
||||
assert.deepEqual(typingActions, [{ chatId: 42 }]);
|
||||
assert.equal(
|
||||
Runtime.startTelegramTypingLoop(state, {
|
||||
chatId: 42,
|
||||
target: createTelegramThreadTarget(42, 99),
|
||||
intervalMs: 1000,
|
||||
sendTypingAction: async (chatId, options) => {
|
||||
recordTypingAction(chatId, options);
|
||||
},
|
||||
sendAggregateTypingAction: async (chatId) => {
|
||||
typingActions.push({ chatId, aggregate: true });
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
await flushMicrotasks();
|
||||
assert.deepEqual(typingActions, [
|
||||
{ chatId: 42 },
|
||||
{ chatId: 42, threadId: 99 },
|
||||
{ chatId: 42, aggregate: true },
|
||||
]);
|
||||
assert.equal(Runtime.stopTelegramTypingLoop(state), true);
|
||||
});
|
||||
|
||||
test("Typing loop sends chat actions into thread target and aggregate surface", async () => {
|
||||
const state = Runtime.createTelegramBridgeRuntimeState();
|
||||
const typingActions: Array<{
|
||||
chatId: number;
|
||||
threadId?: number;
|
||||
aggregate?: boolean;
|
||||
}> = [];
|
||||
assert.equal(
|
||||
Runtime.startTelegramTypingLoop(state, {
|
||||
chatId: 42,
|
||||
target: createTelegramThreadTarget(42, 99),
|
||||
intervalMs: 1000,
|
||||
sendTypingAction: async (chatId, options) => {
|
||||
typingActions.push({
|
||||
chatId,
|
||||
threadId: options?.message_thread_id,
|
||||
});
|
||||
},
|
||||
sendAggregateTypingAction: async (chatId) => {
|
||||
typingActions.push({ chatId, aggregate: true });
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
await flushMicrotasks();
|
||||
assert.deepEqual(typingActions, [
|
||||
{ chatId: 42, threadId: 99 },
|
||||
{ chatId: 42, aggregate: true },
|
||||
]);
|
||||
assert.equal(Runtime.stopTelegramTypingLoop(state), true);
|
||||
});
|
||||
|
||||
test("Typing loop idle wait is bounded for slow in-flight chat actions", async () => {
|
||||
const state = Runtime.createTelegramBridgeRuntimeState();
|
||||
state.typingInFlight = new Promise(() => {});
|
||||
const startedAt = Date.now();
|
||||
|
||||
await Runtime.waitForTelegramTypingLoopIdle(state, 1);
|
||||
|
||||
assert.ok(Date.now() - startedAt < 100);
|
||||
});
|
||||
|
||||
test("Abort handler setter and agent-end resetter bind runtime cleanup", () => {
|
||||
const runtime = Runtime.createTelegramBridgeRuntime();
|
||||
const events: string[] = [];
|
||||
const setAbortHandler = Runtime.createTelegramContextAbortHandlerSetter(
|
||||
runtime.abort,
|
||||
);
|
||||
setAbortHandler({
|
||||
abort: () => {
|
||||
events.push("abort");
|
||||
},
|
||||
});
|
||||
assert.equal(runtime.abort.abortTurn(), true);
|
||||
const reset = Runtime.createTelegramAgentEndResetter({
|
||||
abort: runtime.abort,
|
||||
typing: runtime.typing,
|
||||
clearActiveTurn: () => {
|
||||
events.push("active");
|
||||
},
|
||||
resetToolExecutions: () => {
|
||||
events.push("tools");
|
||||
},
|
||||
clearPendingModelSwitch: () => {
|
||||
events.push("switch");
|
||||
},
|
||||
clearDispatchPending: runtime.lifecycle.clearDispatchPending,
|
||||
});
|
||||
runtime.lifecycle.setDispatchPending(true);
|
||||
reset();
|
||||
assert.equal(runtime.abort.hasHandler(), false);
|
||||
assert.equal(runtime.lifecycle.hasDispatchPending(), false);
|
||||
assert.deepEqual(events, ["abort", "active", "tools", "switch"]);
|
||||
});
|
||||
|
||||
test("Prompt dispatch lifecycle owns dispatch flags, typing, and status", () => {
|
||||
const runtime = Runtime.createTelegramBridgeRuntime();
|
||||
const events: string[] = [];
|
||||
const lifecycle = Runtime.createTelegramPromptDispatchLifecycle<{
|
||||
id: string;
|
||||
}>({
|
||||
lifecycle: runtime.lifecycle,
|
||||
typing: runtime.typing,
|
||||
startTypingLoop: (ctx, chatId) => {
|
||||
events.push(`typing:${ctx.id}:${chatId ?? "default"}`);
|
||||
},
|
||||
updateStatus: (ctx, error) => {
|
||||
events.push(`status:${ctx.id}:${error ?? "ok"}`);
|
||||
},
|
||||
recordRuntimeEvent: (category, error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
events.push(`event:${category}:${message}`);
|
||||
},
|
||||
});
|
||||
lifecycle.onPromptDispatchStart({ id: "ctx" }, 42);
|
||||
assert.equal(runtime.lifecycle.hasDispatchPending(), true);
|
||||
lifecycle.onPromptDispatchFailure({ id: "ctx" }, "boom");
|
||||
assert.equal(runtime.lifecycle.hasDispatchPending(), false);
|
||||
assert.deepEqual(events, [
|
||||
"typing:ctx:42",
|
||||
"status:ctx:ok",
|
||||
"event:dispatch:boom",
|
||||
"status:ctx:dispatch failed: boom",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Prompt dispatch lifecycle records stale status failures", () => {
|
||||
const runtime = Runtime.createTelegramBridgeRuntime();
|
||||
const events: string[] = [];
|
||||
const lifecycle = Runtime.createTelegramPromptDispatchLifecycle<{
|
||||
id: string;
|
||||
}>({
|
||||
lifecycle: runtime.lifecycle,
|
||||
typing: runtime.typing,
|
||||
startTypingLoop: () => {
|
||||
events.push("typing");
|
||||
},
|
||||
updateStatus: () => {
|
||||
throw new Error("stale ctx");
|
||||
},
|
||||
recordRuntimeEvent: (category, error, details) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
events.push(`${category}:${message}:${details?.phase ?? "event"}`);
|
||||
},
|
||||
});
|
||||
|
||||
assert.doesNotThrow(() => lifecycle.onPromptDispatchStart({ id: "ctx" }));
|
||||
assert.equal(runtime.lifecycle.hasDispatchPending(), true);
|
||||
assert.doesNotThrow(() =>
|
||||
lifecycle.onPromptDispatchFailure({ id: "ctx" }, "boom"),
|
||||
);
|
||||
assert.equal(runtime.lifecycle.hasDispatchPending(), false);
|
||||
assert.deepEqual(events, [
|
||||
"typing",
|
||||
"dispatch:stale ctx:status-update",
|
||||
"dispatch:boom:event",
|
||||
"dispatch:stale ctx:status-update",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Prompt dispatch runtime binds typing starter and dispatch lifecycle", async () => {
|
||||
const runtime = Runtime.createTelegramBridgeRuntime();
|
||||
const sentChatIds: number[] = [];
|
||||
const statuses: string[] = [];
|
||||
const promptRuntime = Runtime.createTelegramPromptDispatchRuntime<{
|
||||
id: string;
|
||||
}>({
|
||||
lifecycle: runtime.lifecycle,
|
||||
typing: runtime.typing,
|
||||
getDefaultChatId: () => 7,
|
||||
sendTypingAction: async (chatId) => {
|
||||
sentChatIds.push(chatId);
|
||||
},
|
||||
updateStatus: (_ctx, error) => {
|
||||
statuses.push(error ?? "ok");
|
||||
},
|
||||
intervalMs: 1000,
|
||||
});
|
||||
promptRuntime.onPromptDispatchStart({ id: "ctx" }, 9);
|
||||
await flushMicrotasks();
|
||||
assert.equal(runtime.lifecycle.hasDispatchPending(), true);
|
||||
assert.deepEqual(sentChatIds, [9]);
|
||||
promptRuntime.onPromptDispatchFailure({ id: "ctx" }, "boom");
|
||||
assert.equal(runtime.lifecycle.hasDispatchPending(), false);
|
||||
assert.deepEqual(statuses, ["ok", "dispatch failed: boom"]);
|
||||
});
|
||||
|
||||
test("Typing loop starter uses a conservative native keepalive interval", () => {
|
||||
let capturedIntervalMs = 0;
|
||||
const startTypingLoop = Runtime.createTelegramTypingLoopStarter<{
|
||||
id: string;
|
||||
}>({
|
||||
typing: {
|
||||
start: (deps) => {
|
||||
capturedIntervalMs = deps.intervalMs;
|
||||
return true;
|
||||
},
|
||||
stop: () => true,
|
||||
waitForIdle: async () => {},
|
||||
},
|
||||
getDefaultChatId: () => 7,
|
||||
sendTypingAction: async () => {},
|
||||
updateStatus: () => {},
|
||||
});
|
||||
|
||||
startTypingLoop({ id: "ctx" });
|
||||
|
||||
assert.equal(capturedIntervalMs, 2500);
|
||||
});
|
||||
|
||||
test("Typing loop starter sends one thread action and one aggregate action", async () => {
|
||||
const state = Runtime.createTelegramBridgeRuntimeState();
|
||||
const runtime = Runtime.createTelegramBridgeRuntime(state);
|
||||
const actions: string[] = [];
|
||||
const startTypingLoop = Runtime.createTelegramTypingLoopStarter<{
|
||||
id: string;
|
||||
}>({
|
||||
typing: runtime.typing,
|
||||
getDefaultChatId: () => undefined,
|
||||
sendTypingAction: async (chatId, options) => {
|
||||
actions.push(`thread:${chatId}:${options?.message_thread_id ?? "all"}`);
|
||||
},
|
||||
sendAggregateTypingAction: async (chatId) => {
|
||||
actions.push(`aggregate:${chatId}`);
|
||||
},
|
||||
updateStatus: () => {},
|
||||
intervalMs: 1000,
|
||||
});
|
||||
|
||||
startTypingLoop({ id: "ctx" }, 8, {
|
||||
target: createTelegramThreadTarget(8, 44),
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.deepEqual(actions, ["thread:8:44", "aggregate:8"]);
|
||||
assert.equal(runtime.typing.stop(), true);
|
||||
});
|
||||
|
||||
test("Typing loop starter binds default chat and reports failures", async () => {
|
||||
const state = Runtime.createTelegramBridgeRuntimeState();
|
||||
const runtime = Runtime.createTelegramBridgeRuntime(state);
|
||||
const sentChatIds: number[] = [];
|
||||
const statusErrors: string[] = [];
|
||||
const runtimeEvents: string[] = [];
|
||||
const startTypingLoop = Runtime.createTelegramTypingLoopStarter<{
|
||||
id: string;
|
||||
}>({
|
||||
typing: runtime.typing,
|
||||
getDefaultChatId: () => 7,
|
||||
sendTypingAction: async (chatId) => {
|
||||
sentChatIds.push(chatId);
|
||||
},
|
||||
updateStatus: (_ctx: { id: string }, error?: string) => {
|
||||
if (error) statusErrors.push(error);
|
||||
},
|
||||
recordRuntimeEvent: (category, error, details) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
runtimeEvents.push(`${category}:${message}:${details?.chatId}`);
|
||||
},
|
||||
intervalMs: 1000,
|
||||
});
|
||||
startTypingLoop({ id: "ctx" });
|
||||
await flushMicrotasks();
|
||||
assert.deepEqual(sentChatIds, [7]);
|
||||
assert.deepEqual(statusErrors, []);
|
||||
assert.equal(runtime.typing.stop(), true);
|
||||
const failingStatusErrors: string[] = [];
|
||||
const startFailingTypingLoop = Runtime.createTelegramTypingLoopStarter<{
|
||||
id: string;
|
||||
}>({
|
||||
typing: runtime.typing,
|
||||
getDefaultChatId: () => undefined,
|
||||
sendTypingAction: async () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
updateStatus: (_ctx: { id: string }, error?: string) => {
|
||||
if (error) failingStatusErrors.push(error);
|
||||
},
|
||||
recordRuntimeEvent: (category, error, details) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
runtimeEvents.push(`${category}:${message}:${details?.chatId}`);
|
||||
},
|
||||
intervalMs: 1000,
|
||||
});
|
||||
startFailingTypingLoop({ id: "ctx" }, 8);
|
||||
await flushMicrotasks();
|
||||
assert.deepEqual(failingStatusErrors, ["boom"]);
|
||||
assert.deepEqual(runtimeEvents, ["typing:boom:8"]);
|
||||
assert.equal(runtime.typing.stop(), true);
|
||||
});
|
||||
|
||||
test("Typing loop starter records stale status failures", async () => {
|
||||
const state = Runtime.createTelegramBridgeRuntimeState();
|
||||
const runtime = Runtime.createTelegramBridgeRuntime(state);
|
||||
const runtimeEvents: string[] = [];
|
||||
const startTypingLoop = Runtime.createTelegramTypingLoopStarter<{
|
||||
id: string;
|
||||
}>({
|
||||
typing: runtime.typing,
|
||||
getDefaultChatId: () => undefined,
|
||||
sendTypingAction: async () => {
|
||||
throw new Error("typing failed");
|
||||
},
|
||||
updateStatus: () => {
|
||||
throw new Error("stale ctx");
|
||||
},
|
||||
recordRuntimeEvent: (category, error, details) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
runtimeEvents.push(
|
||||
`${category}:${message}:${details?.phase ?? details?.chatId}`,
|
||||
);
|
||||
},
|
||||
intervalMs: 1000,
|
||||
});
|
||||
|
||||
startTypingLoop({ id: "ctx" }, 8);
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.deepEqual(runtimeEvents, [
|
||||
"typing:stale ctx:status-update",
|
||||
"typing:typing failed:8",
|
||||
]);
|
||||
assert.equal(runtime.typing.stop(), true);
|
||||
});
|
||||
@@ -0,0 +1,670 @@
|
||||
/**
|
||||
* Telegram Extension Sections tests
|
||||
* Zones: telegram ui, extension platform, callback routing
|
||||
*/
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
createTelegramExtensionSectionRegistry,
|
||||
getTelegramExtensionSettingsRows,
|
||||
getTelegramSectionMainMenuRows,
|
||||
parseTelegramSectionCallback,
|
||||
handleTelegramSectionOpen,
|
||||
handleTelegramSectionCallback,
|
||||
handleTelegramSectionSettingsOpen,
|
||||
type TelegramSectionRegistration,
|
||||
type TelegramSectionCallbackHandlerDeps,
|
||||
} from "../lib/sections.ts";
|
||||
|
||||
function stubSection(
|
||||
id: string,
|
||||
label: string,
|
||||
overrides: Partial<TelegramSectionRegistration> = {},
|
||||
): TelegramSectionRegistration {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
render: async () => ({ text: `<b>${label}</b>`, parseMode: "html" }),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function stubDeps(
|
||||
overrides: Partial<TelegramSectionCallbackHandlerDeps> = {},
|
||||
): TelegramSectionCallbackHandlerDeps {
|
||||
return {
|
||||
answerCallbackQuery: async () => {},
|
||||
editInteractiveMessage: async () => {},
|
||||
sendInteractiveMessage: async () => undefined,
|
||||
enqueuePrompt: async () => {},
|
||||
deleteMessage: async () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Registry ---
|
||||
|
||||
test("Registry registers a section and returns a disposer", () => {
|
||||
const registry = createTelegramExtensionSectionRegistry();
|
||||
const unregister = registry.register(stubSection("@test/section-a", "A"));
|
||||
const sections = registry.getSections();
|
||||
assert.equal(sections.length, 1);
|
||||
assert.equal(sections[0].id, "@test/section-a");
|
||||
assert.equal(sections[0].label, "A");
|
||||
assert.equal(typeof sections[0].token, "string");
|
||||
unregister();
|
||||
assert.equal(registry.getSections().length, 0);
|
||||
});
|
||||
|
||||
test("Registry assigns unique tokens to each section", () => {
|
||||
const registry = createTelegramExtensionSectionRegistry();
|
||||
const u1 = registry.register(stubSection("@test/a", "A"));
|
||||
const u2 = registry.register(stubSection("@test/b", "B"));
|
||||
const sections = registry.getSections();
|
||||
assert.equal(sections.length, 2);
|
||||
assert.notEqual(sections[0].token, sections[1].token);
|
||||
u1();
|
||||
u2();
|
||||
});
|
||||
|
||||
test("Registry rejects duplicate section ids", () => {
|
||||
const registry = createTelegramExtensionSectionRegistry();
|
||||
registry.register(stubSection("@test/a", "A"));
|
||||
assert.throws(
|
||||
() => registry.register(stubSection("@test/a", "A2")),
|
||||
/already registered/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Registry sorts sections by order then id", () => {
|
||||
const registry = createTelegramExtensionSectionRegistry();
|
||||
registry.register(stubSection("@test/c", "C", { order: 2 }));
|
||||
registry.register(stubSection("@test/a", "A", { order: 0 }));
|
||||
registry.register(stubSection("@test/b", "B", { order: 1 }));
|
||||
const sections = registry.getSections();
|
||||
assert.equal(sections[0].id, "@test/a");
|
||||
assert.equal(sections[1].id, "@test/b");
|
||||
assert.equal(sections[2].id, "@test/c");
|
||||
});
|
||||
|
||||
test("Registry getByToken resolves token to section", () => {
|
||||
const registry = createTelegramExtensionSectionRegistry();
|
||||
registry.register(stubSection("@test/a", "A"));
|
||||
const sections = registry.getSections();
|
||||
const found = registry.getByToken(sections[0].token);
|
||||
assert.ok(found);
|
||||
assert.equal(found.id, "@test/a");
|
||||
});
|
||||
|
||||
test("Registry getByToken returns undefined for unknown token", () => {
|
||||
const registry = createTelegramExtensionSectionRegistry();
|
||||
assert.equal(registry.getByToken("99"), undefined);
|
||||
});
|
||||
|
||||
test("Registry clear removes all sections and resets tokens", () => {
|
||||
const registry = createTelegramExtensionSectionRegistry();
|
||||
const u1 = registry.register(stubSection("@test/a", "A"));
|
||||
registry.clear();
|
||||
assert.equal(registry.getSections().length, 0);
|
||||
// New registration gets fresh tokens
|
||||
const u2 = registry.register(stubSection("@test/b", "B"));
|
||||
const sections = registry.getSections();
|
||||
assert.equal(sections[0].token, "0");
|
||||
u1();
|
||||
u2();
|
||||
});
|
||||
|
||||
test("Registry diagnostics reports active and error states", () => {
|
||||
const registry = createTelegramExtensionSectionRegistry();
|
||||
registry.register(stubSection("@test/a", "A"));
|
||||
let diags = registry.getDiagnostics();
|
||||
assert.equal(diags.length, 1);
|
||||
assert.equal(diags[0].status, "active");
|
||||
assert.equal(diags[0].id, "@test/a");
|
||||
registry.recordError("0", "boom");
|
||||
diags = registry.getDiagnostics();
|
||||
assert.equal(diags[0].status, "error");
|
||||
assert.equal(diags[0].lastError, "boom");
|
||||
registry.clearError("0");
|
||||
assert.equal(registry.getDiagnostics()[0].status, "active");
|
||||
});
|
||||
|
||||
// --- Settings Rows ---
|
||||
|
||||
test("getTelegramExtensionSettingsRows returns rows only for sections with settings", () => {
|
||||
const registry = createTelegramExtensionSectionRegistry();
|
||||
registry.register(stubSection("@test/a", "A"));
|
||||
registry.register(
|
||||
stubSection("@test/b", "B", {
|
||||
settings: {
|
||||
label: "🔧 B Settings",
|
||||
open: async () => ({ text: "B settings" }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
const rows = getTelegramExtensionSettingsRows(registry);
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].label, "🔧 B Settings");
|
||||
assert.ok(rows[0].callback_data.startsWith("section:"));
|
||||
assert.ok(rows[0].callback_data.endsWith(":settings:open"));
|
||||
});
|
||||
|
||||
test("getTelegramExtensionSettingsRows sorts by settings order", () => {
|
||||
const registry = createTelegramExtensionSectionRegistry();
|
||||
registry.register(
|
||||
stubSection("@test/c", "C", {
|
||||
settings: { label: "C", open: async () => ({ text: "C" }), order: 2 },
|
||||
}),
|
||||
);
|
||||
registry.register(
|
||||
stubSection("@test/a", "A", {
|
||||
settings: { label: "A", open: async () => ({ text: "A" }), order: 0 },
|
||||
}),
|
||||
);
|
||||
const rows = getTelegramExtensionSettingsRows(registry);
|
||||
assert.equal(rows[0].label, "A");
|
||||
assert.equal(rows[1].label, "C");
|
||||
});
|
||||
|
||||
// --- Menu Rows ---
|
||||
|
||||
test("getTelegramSectionMainMenuRows omits sections with failing labels", () => {
|
||||
const registry = createTelegramExtensionSectionRegistry();
|
||||
registry.register(stubSection("@test/a", "A"));
|
||||
registry.register(
|
||||
stubSection("@test/b", "B", {
|
||||
getLabel: () => {
|
||||
throw new Error("label failed");
|
||||
},
|
||||
}),
|
||||
);
|
||||
const rows = getTelegramSectionMainMenuRows(registry);
|
||||
assert.deepEqual(
|
||||
rows.map((row) => row.text),
|
||||
["A"],
|
||||
);
|
||||
const diagnostics = registry.getDiagnostics();
|
||||
assert.equal(diagnostics[1].status, "error");
|
||||
assert.equal(diagnostics[1].lastError, "label failed");
|
||||
});
|
||||
|
||||
test("getTelegramExtensionSettingsRows omits settings with failing labels", () => {
|
||||
const registry = createTelegramExtensionSectionRegistry();
|
||||
registry.register(
|
||||
stubSection("@test/a", "A", {
|
||||
settings: { label: "A", open: async () => ({ text: "A" }) },
|
||||
}),
|
||||
);
|
||||
registry.register(
|
||||
stubSection("@test/b", "B", {
|
||||
settings: {
|
||||
label: "B",
|
||||
getLabel: () => {
|
||||
throw new Error("settings label failed");
|
||||
},
|
||||
open: async () => ({ text: "B" }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
const rows = getTelegramExtensionSettingsRows(registry);
|
||||
assert.deepEqual(
|
||||
rows.map((row) => row.label),
|
||||
["A"],
|
||||
);
|
||||
const diagnostics = registry.getDiagnostics();
|
||||
assert.equal(diagnostics[1].status, "error");
|
||||
assert.equal(diagnostics[1].lastError, "settings label failed");
|
||||
});
|
||||
|
||||
test("successful labels do not clear unrelated runtime diagnostics", () => {
|
||||
const registry = createTelegramExtensionSectionRegistry();
|
||||
registry.register(stubSection("@test/a", "A"));
|
||||
registry.recordError("0", "callback failed", "callback");
|
||||
assert.equal(getTelegramSectionMainMenuRows(registry)[0].text, "A");
|
||||
assert.equal(registry.getDiagnostics()[0].status, "error");
|
||||
assert.equal(registry.getDiagnostics()[0].lastError, "callback failed");
|
||||
registry.clearError("0", "callback");
|
||||
assert.equal(registry.getDiagnostics()[0].status, "active");
|
||||
});
|
||||
|
||||
// --- Callback Parsing ---
|
||||
|
||||
test("parseTelegramSectionCallback parses section:token:action", () => {
|
||||
const result = parseTelegramSectionCallback("section:00:open");
|
||||
assert.ok(result);
|
||||
assert.equal(result.token, "00");
|
||||
assert.equal(result.action, "open");
|
||||
assert.equal(result.payload, "");
|
||||
});
|
||||
|
||||
test("parseTelegramSectionCallback parses section:token:action:payload", () => {
|
||||
const result = parseTelegramSectionCallback("section:01:toggle:on");
|
||||
assert.ok(result);
|
||||
assert.equal(result.token, "01");
|
||||
assert.equal(result.action, "toggle");
|
||||
assert.equal(result.payload, "on");
|
||||
});
|
||||
|
||||
test("parseTelegramSectionCallback parses payload with colons", () => {
|
||||
const result = parseTelegramSectionCallback("section:05:nav:path:to:item");
|
||||
assert.ok(result);
|
||||
assert.equal(result.token, "05");
|
||||
assert.equal(result.action, "nav");
|
||||
assert.equal(result.payload, "path:to:item");
|
||||
});
|
||||
|
||||
test("parseTelegramSectionCallback returns undefined for non-section data", () => {
|
||||
assert.equal(parseTelegramSectionCallback("menu:model"), undefined);
|
||||
assert.equal(parseTelegramSectionCallback("settings:list"), undefined);
|
||||
assert.equal(parseTelegramSectionCallback(""), undefined);
|
||||
});
|
||||
|
||||
// --- Section Open ---
|
||||
|
||||
test("handleTelegramSectionOpen renders section view with back row", async () => {
|
||||
const registry = createTelegramExtensionSectionRegistry();
|
||||
registry.register(
|
||||
stubSection("@test/a", "🗂 A", {
|
||||
render: async () => ({
|
||||
text: "<b>Hello</b>",
|
||||
parseMode: "html",
|
||||
replyMarkup: {
|
||||
inline_keyboard: [
|
||||
[{ text: "Do", callback_data: "section:0:act:do" }],
|
||||
],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
let editedText = "";
|
||||
let editedMarkup: unknown = undefined;
|
||||
const deps = stubDeps({
|
||||
editInteractiveMessage: async (
|
||||
_chatId,
|
||||
_msgId,
|
||||
text,
|
||||
_mode,
|
||||
replyMarkup,
|
||||
) => {
|
||||
editedText = text;
|
||||
editedMarkup = replyMarkup;
|
||||
},
|
||||
});
|
||||
const handled = await handleTelegramSectionOpen(
|
||||
registry,
|
||||
"0",
|
||||
123,
|
||||
456,
|
||||
"cb-id",
|
||||
deps,
|
||||
);
|
||||
assert.equal(handled, true);
|
||||
assert.equal(editedText, "<b>Hello</b>");
|
||||
const markup = editedMarkup as {
|
||||
inline_keyboard: Array<Array<{ text: string; callback_data: string }>>;
|
||||
};
|
||||
assert.ok(markup);
|
||||
// First row is the Back button
|
||||
assert.equal(markup.inline_keyboard[0][0].text, "⬆️ Main menu");
|
||||
assert.equal(markup.inline_keyboard[0][0].callback_data, "menu:back");
|
||||
// Second row is the section's own button
|
||||
assert.equal(markup.inline_keyboard[1][0].text, "Do");
|
||||
});
|
||||
|
||||
test("section callbackData rejects payloads above Telegram's byte limit", async () => {
|
||||
const registry = createTelegramExtensionSectionRegistry();
|
||||
registry.register(
|
||||
stubSection("@test/a", "A", {
|
||||
render: async (ctx) => {
|
||||
assert.throws(
|
||||
() => ctx.callbackData("act", "x".repeat(80)),
|
||||
/64 bytes/,
|
||||
);
|
||||
return { text: "ok" };
|
||||
},
|
||||
}),
|
||||
);
|
||||
const handled = await handleTelegramSectionOpen(
|
||||
registry,
|
||||
"0",
|
||||
123,
|
||||
456,
|
||||
"cb-id",
|
||||
stubDeps(),
|
||||
);
|
||||
assert.equal(handled, true);
|
||||
});
|
||||
|
||||
test("handleTelegramSectionOpen preserves existing back button", async () => {
|
||||
const registry = createTelegramExtensionSectionRegistry();
|
||||
registry.register(
|
||||
stubSection("@test/a", "A", {
|
||||
render: async () => ({
|
||||
text: "Test",
|
||||
replyMarkup: {
|
||||
inline_keyboard: [
|
||||
[{ text: "⬆️ Main menu", callback_data: "menu:back" }],
|
||||
[{ text: "Custom", callback_data: "section:0:custom" }],
|
||||
],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
let editedMarkup: unknown = undefined;
|
||||
const deps = stubDeps({
|
||||
editInteractiveMessage: async (_a, _b, _c, _d, replyMarkup) => {
|
||||
editedMarkup = replyMarkup;
|
||||
},
|
||||
});
|
||||
await handleTelegramSectionOpen(registry, "0", 123, 456, "cb-id", deps);
|
||||
const markup = editedMarkup as {
|
||||
inline_keyboard: Array<Array<{ text: string }>>;
|
||||
};
|
||||
assert.equal(markup.inline_keyboard.length, 2);
|
||||
// No duplicate back row
|
||||
assert.equal(markup.inline_keyboard[0][0].text, "⬆️ Main menu");
|
||||
});
|
||||
|
||||
test("handleTelegramSectionOpen handles stale token gracefully", async () => {
|
||||
const registry = createTelegramExtensionSectionRegistry();
|
||||
let answeredText = "";
|
||||
const deps = stubDeps({
|
||||
answerCallbackQuery: async (_id, text) => {
|
||||
answeredText = text ?? "";
|
||||
},
|
||||
});
|
||||
const handled = await handleTelegramSectionOpen(
|
||||
registry,
|
||||
"99",
|
||||
123,
|
||||
456,
|
||||
"cb-id",
|
||||
deps,
|
||||
);
|
||||
assert.equal(handled, true);
|
||||
assert.ok(answeredText.includes("no longer available"));
|
||||
});
|
||||
|
||||
test("handleTelegramSectionOpen records and clears render errors", async () => {
|
||||
const registry = createTelegramExtensionSectionRegistry();
|
||||
let fail = true;
|
||||
registry.register(
|
||||
stubSection("@test/a", "A", {
|
||||
render: async () => {
|
||||
if (fail) throw new Error("render failed");
|
||||
return { text: "ok" };
|
||||
},
|
||||
}),
|
||||
);
|
||||
await handleTelegramSectionOpen(registry, "0", 123, 456, "cb-id", stubDeps());
|
||||
assert.equal(registry.getDiagnostics()[0].status, "error");
|
||||
assert.equal(registry.getDiagnostics()[0].lastError, "render failed");
|
||||
fail = false;
|
||||
await handleTelegramSectionOpen(registry, "0", 123, 456, "cb-id", stubDeps());
|
||||
assert.equal(registry.getDiagnostics()[0].status, "active");
|
||||
});
|
||||
|
||||
// --- Section Callback ---
|
||||
|
||||
test("handleTelegramSectionCallback dispatches to section handler", async () => {
|
||||
const registry = createTelegramExtensionSectionRegistry();
|
||||
let receivedAction = "";
|
||||
let receivedPayload = "";
|
||||
registry.register(
|
||||
stubSection("@test/a", "A", {
|
||||
handleCallback: async (ctx) => {
|
||||
receivedAction = ctx.action;
|
||||
receivedPayload = ctx.payload;
|
||||
return "handled" as const;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const deps = stubDeps();
|
||||
const handled = await handleTelegramSectionCallback(
|
||||
registry,
|
||||
"0",
|
||||
"toggle",
|
||||
"on",
|
||||
123,
|
||||
456,
|
||||
"cb-id",
|
||||
deps,
|
||||
);
|
||||
assert.equal(handled, true);
|
||||
assert.equal(receivedAction, "toggle");
|
||||
assert.equal(receivedPayload, "on");
|
||||
});
|
||||
|
||||
test("handleTelegramSectionCallback falls back to settings handler", async () => {
|
||||
const registry = createTelegramExtensionSectionRegistry();
|
||||
let settingsAction = "";
|
||||
registry.register(
|
||||
stubSection("@test/a", "A", {
|
||||
handleCallback: async () => "pass" as const,
|
||||
settings: {
|
||||
label: "Settings",
|
||||
open: async () => ({ text: "settings" }),
|
||||
handleCallback: async (ctx) => {
|
||||
settingsAction = ctx.action;
|
||||
return "handled" as const;
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const deps = stubDeps();
|
||||
await handleTelegramSectionCallback(
|
||||
registry,
|
||||
"0",
|
||||
"toggle",
|
||||
"off",
|
||||
123,
|
||||
456,
|
||||
"cb-id",
|
||||
deps,
|
||||
);
|
||||
assert.equal(settingsAction, "toggle");
|
||||
});
|
||||
|
||||
test("handleTelegramSectionCallback gives settings-only handlers settings navigation", async () => {
|
||||
const registry = createTelegramExtensionSectionRegistry();
|
||||
registry.register(
|
||||
stubSection("@test/a", "A", {
|
||||
settings: {
|
||||
label: "Settings",
|
||||
open: async () => ({ text: "settings" }),
|
||||
handleCallback: async (ctx) => {
|
||||
await ctx.edit({ text: "settings detail" });
|
||||
return "handled" as const;
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
let editedMarkup: unknown;
|
||||
const deps = stubDeps({
|
||||
editInteractiveMessage: async (_chat, _message, _text, _mode, markup) => {
|
||||
editedMarkup = markup;
|
||||
},
|
||||
});
|
||||
await handleTelegramSectionCallback(
|
||||
registry,
|
||||
"0",
|
||||
"toggle",
|
||||
"off",
|
||||
123,
|
||||
456,
|
||||
"cb-id",
|
||||
deps,
|
||||
);
|
||||
const markup = editedMarkup as {
|
||||
inline_keyboard: Array<Array<{ text: string; callback_data: string }>>;
|
||||
};
|
||||
assert.equal(markup.inline_keyboard[0][0].text, "⬆️ Back");
|
||||
assert.equal(markup.inline_keyboard[0][0].callback_data, "settings:list");
|
||||
});
|
||||
|
||||
test("handleTelegramSectionCallback answers when no handler exists", async () => {
|
||||
const registry = createTelegramExtensionSectionRegistry();
|
||||
registry.register(stubSection("@test/a", "A"));
|
||||
let answered = false;
|
||||
const deps = stubDeps({
|
||||
answerCallbackQuery: async () => {
|
||||
answered = true;
|
||||
},
|
||||
});
|
||||
const handled = await handleTelegramSectionCallback(
|
||||
registry,
|
||||
"0",
|
||||
"act",
|
||||
"",
|
||||
123,
|
||||
456,
|
||||
"cb-id",
|
||||
deps,
|
||||
);
|
||||
assert.equal(handled, true);
|
||||
assert.equal(answered, true);
|
||||
});
|
||||
|
||||
test("handleTelegramSectionCallback handles stale token", async () => {
|
||||
const registry = createTelegramExtensionSectionRegistry();
|
||||
let answeredText = "";
|
||||
const deps = stubDeps({
|
||||
answerCallbackQuery: async (_id, text) => {
|
||||
answeredText = text ?? "";
|
||||
},
|
||||
});
|
||||
const handled = await handleTelegramSectionCallback(
|
||||
registry,
|
||||
"99",
|
||||
"act",
|
||||
"",
|
||||
123,
|
||||
456,
|
||||
"cb-id",
|
||||
deps,
|
||||
);
|
||||
assert.equal(handled, true);
|
||||
assert.ok(answeredText.includes("no longer available"));
|
||||
});
|
||||
|
||||
// --- Section Settings Open ---
|
||||
|
||||
test("handleTelegramSectionSettingsOpen renders settings view", async () => {
|
||||
const registry = createTelegramExtensionSectionRegistry();
|
||||
registry.register(
|
||||
stubSection("@test/a", "A", {
|
||||
settings: {
|
||||
label: "⚙️ A Settings",
|
||||
open: async () => ({
|
||||
text: "<b>Settings for A</b>",
|
||||
parseMode: "html",
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
let editedText = "";
|
||||
const deps = stubDeps({
|
||||
editInteractiveMessage: async (_a, _b, text) => {
|
||||
editedText = text;
|
||||
},
|
||||
});
|
||||
const handled = await handleTelegramSectionSettingsOpen(
|
||||
registry,
|
||||
"0",
|
||||
123,
|
||||
456,
|
||||
"cb-id",
|
||||
deps,
|
||||
);
|
||||
assert.equal(handled, true);
|
||||
assert.equal(editedText, "<b>Settings for A</b>");
|
||||
});
|
||||
|
||||
test("handleTelegramSectionSettingsOpen handles section without settings gracefully", async () => {
|
||||
const registry = createTelegramExtensionSectionRegistry();
|
||||
registry.register(stubSection("@test/a", "A"));
|
||||
let answeredText = "";
|
||||
const deps = stubDeps({
|
||||
answerCallbackQuery: async (_id, text) => {
|
||||
answeredText = text ?? "";
|
||||
},
|
||||
});
|
||||
const handled = await handleTelegramSectionSettingsOpen(
|
||||
registry,
|
||||
"0",
|
||||
123,
|
||||
456,
|
||||
"cb-id",
|
||||
deps,
|
||||
);
|
||||
assert.equal(handled, true);
|
||||
assert.ok(answeredText.includes("no longer available"));
|
||||
});
|
||||
|
||||
// --- Integration: menu-status rows ---
|
||||
|
||||
test("buildStatusReplyMarkup includes extension section rows before Settings", async () => {
|
||||
// Import dynamically to avoid circular deps in test
|
||||
const { buildStatusReplyMarkup } = await import("../lib/menu-status.ts");
|
||||
const registry = createTelegramExtensionSectionRegistry();
|
||||
registry.register(stubSection("@test/a", "Explorer"));
|
||||
registry.register(
|
||||
stubSection("@test/b", "Status", {
|
||||
getLabel: () => "🟢 Status",
|
||||
}),
|
||||
);
|
||||
|
||||
const markup = buildStatusReplyMarkup(undefined, "off" as never, 0, registry);
|
||||
const rows = markup.inline_keyboard;
|
||||
|
||||
// Find the Settings row index
|
||||
const settingsIdx = rows.findIndex(
|
||||
(r) => r[0].callback_data === "menu:settings",
|
||||
);
|
||||
assert.ok(settingsIdx > 0);
|
||||
// Queue should be before section rows
|
||||
assert.equal(rows[settingsIdx - 3][0].text, "⌛ Queue: 0");
|
||||
// Section rows injected between Queue and Settings
|
||||
assert.equal(rows[settingsIdx - 2][0].text, "Explorer");
|
||||
assert.equal(rows[settingsIdx - 2][0].callback_data, "section:0:open");
|
||||
assert.equal(rows[settingsIdx - 1][0].text, "🟢 Status");
|
||||
assert.equal(rows[settingsIdx - 1][0].callback_data, "section:1:open");
|
||||
});
|
||||
|
||||
// --- Integration: menu-settings rows ---
|
||||
|
||||
test("buildTelegramSettingsMenuReplyMarkup injects extension settings rows", async () => {
|
||||
const { buildTelegramSettingsMenuReplyMarkup } =
|
||||
await import("../lib/menu-settings.ts");
|
||||
const registry = createTelegramExtensionSectionRegistry();
|
||||
registry.register(
|
||||
stubSection("@test/a", "A", {
|
||||
settings: {
|
||||
label: "🔧 Extension A",
|
||||
open: async () => ({ text: "A settings" }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const markup = buildTelegramSettingsMenuReplyMarkup(
|
||||
false,
|
||||
false,
|
||||
"manual",
|
||||
"hidden",
|
||||
registry,
|
||||
);
|
||||
const rows = markup.inline_keyboard;
|
||||
|
||||
// First row: Main menu back
|
||||
assert.equal(rows[0][0].callback_data, "menu:back");
|
||||
// Second row: extension settings
|
||||
assert.equal(rows[1][0].text, "🔧 Extension A");
|
||||
assert.ok(rows[1][0].callback_data.startsWith("section:"));
|
||||
// Built-in rows follow extension settings
|
||||
assert.ok(rows[2][0].text.includes("Voice reply"));
|
||||
assert.ok(rows[3][0].text.includes("Time"));
|
||||
assert.ok(rows[4][0].text.includes("Draft previews"));
|
||||
assert.ok(rows[5][0].text.includes("Rendering"));
|
||||
assert.ok(rows[6][0].text.includes("Proactive push"));
|
||||
});
|
||||
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* Regression tests for Telegram setup prompt helpers
|
||||
* Exercises bot-token prompt defaults, setup success/failure, and prompt-runtime guard cleanup
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { createTelegramConfigStore } from "../lib/config.ts";
|
||||
import {
|
||||
TELEGRAM_BOT_TOKEN_INPUT_PLACEHOLDER,
|
||||
createTelegramSetupPromptRuntime,
|
||||
getTelegramBotTokenInputDefault,
|
||||
getTelegramBotTokenPromptSpec,
|
||||
runTelegramSetup,
|
||||
type TelegramSetupConfig,
|
||||
} from "../lib/setup.ts";
|
||||
|
||||
test("Setup token defaults prefer config, then env aliases, then placeholder", () => {
|
||||
assert.equal(
|
||||
getTelegramBotTokenInputDefault(
|
||||
{ TELEGRAM_BOT_TOKEN: " env-token " },
|
||||
" config-token ",
|
||||
),
|
||||
"config-token",
|
||||
);
|
||||
assert.equal(
|
||||
getTelegramBotTokenInputDefault({ TELEGRAM_KEY: " env-key " }),
|
||||
"env-key",
|
||||
);
|
||||
assert.equal(
|
||||
getTelegramBotTokenInputDefault({}),
|
||||
TELEGRAM_BOT_TOKEN_INPUT_PLACEHOLDER,
|
||||
);
|
||||
});
|
||||
|
||||
test("Setup prompt spec uses editor for real tokens and input for placeholder", () => {
|
||||
assert.deepEqual(getTelegramBotTokenPromptSpec({}, "123:abc"), {
|
||||
method: "editor",
|
||||
value: "123:abc",
|
||||
});
|
||||
assert.deepEqual(getTelegramBotTokenPromptSpec({}), {
|
||||
method: "input",
|
||||
value: TELEGRAM_BOT_TOKEN_INPUT_PLACEHOLDER,
|
||||
});
|
||||
});
|
||||
|
||||
test("Setup runner validates token, persists config, starts polling, and updates status", async () => {
|
||||
const calls: string[] = [];
|
||||
let persisted: unknown;
|
||||
const nextConfig = await runTelegramSetup({
|
||||
hasUI: true,
|
||||
env: {},
|
||||
config: { allowedUserId: 42 },
|
||||
promptInput: async (_label, value) => {
|
||||
calls.push(`input:${value}`);
|
||||
return "token";
|
||||
},
|
||||
promptEditor: async () => {
|
||||
throw new Error("must not use editor for placeholder");
|
||||
},
|
||||
getMe: async (botToken) => {
|
||||
calls.push(`getMe:${botToken}`);
|
||||
return { ok: true, result: { id: 7, username: "demo_bot" } };
|
||||
},
|
||||
persistConfig: async (config) => {
|
||||
persisted = config;
|
||||
calls.push("persist");
|
||||
},
|
||||
notify: (message, level) => calls.push(`${level}:${message}`),
|
||||
startPolling: () => ({ ok: true, message: "Polling started" }),
|
||||
updateStatus: () => calls.push("status"),
|
||||
});
|
||||
|
||||
assert.deepEqual(nextConfig, {
|
||||
status: "success",
|
||||
config: {
|
||||
allowedUserId: 42,
|
||||
botToken: "token",
|
||||
botId: 7,
|
||||
botUsername: "demo_bot",
|
||||
},
|
||||
});
|
||||
assert.deepEqual(persisted, nextConfig.config);
|
||||
assert.deepEqual(calls, [
|
||||
`input:${TELEGRAM_BOT_TOKEN_INPUT_PLACEHOLDER}`,
|
||||
"getMe:token",
|
||||
"persist",
|
||||
"info:Telegram bot connected: @demo_bot",
|
||||
"info:Send /start to your bot in Telegram to pair this extension with your account.",
|
||||
"info:Polling started",
|
||||
"status",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Setup runner reports invalid tokens without persisting or starting polling", async () => {
|
||||
const calls: string[] = [];
|
||||
const nextConfig = await runTelegramSetup({
|
||||
hasUI: true,
|
||||
env: {},
|
||||
config: {},
|
||||
promptInput: async () => "bad-token",
|
||||
promptEditor: async () => "bad-token",
|
||||
getMe: async () => ({ ok: false, description: "Unauthorized" }),
|
||||
persistConfig: async () => {
|
||||
calls.push("persist");
|
||||
},
|
||||
notify: (message, level) => calls.push(`${level}:${message}`),
|
||||
startPolling: () => calls.push("start"),
|
||||
updateStatus: () => calls.push("status"),
|
||||
});
|
||||
|
||||
assert.deepEqual(nextConfig, { status: "validation-failed" });
|
||||
assert.deepEqual(calls, ["error:Unauthorized"]);
|
||||
});
|
||||
|
||||
test("Setup runner distinguishes cancellation and polling startup failure", async () => {
|
||||
const baseDeps = {
|
||||
hasUI: true,
|
||||
env: {},
|
||||
config: {},
|
||||
promptEditor: async () => undefined,
|
||||
getMe: async () => ({
|
||||
ok: true,
|
||||
result: { id: 7, username: "demo_bot" },
|
||||
}),
|
||||
persistConfig: async () => undefined,
|
||||
notify: () => undefined,
|
||||
updateStatus: () => undefined,
|
||||
};
|
||||
assert.deepEqual(
|
||||
await runTelegramSetup({
|
||||
...baseDeps,
|
||||
hasUI: false,
|
||||
promptInput: async () => "token",
|
||||
startPolling: () => ({ ok: true }),
|
||||
}),
|
||||
{ status: "unavailable" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
await runTelegramSetup({
|
||||
...baseDeps,
|
||||
promptInput: async () => undefined,
|
||||
startPolling: () => ({ ok: true }),
|
||||
}),
|
||||
{ status: "cancelled" },
|
||||
);
|
||||
const failed = await runTelegramSetup({
|
||||
...baseDeps,
|
||||
promptInput: async () => "token",
|
||||
startPolling: () => ({ ok: false, message: "Polling unavailable" }),
|
||||
});
|
||||
assert.equal(failed.status, "polling-failed");
|
||||
assert.equal(
|
||||
failed.status === "polling-failed" && failed.config.botToken,
|
||||
"token",
|
||||
);
|
||||
});
|
||||
|
||||
test("Setup prompt runtime stores config before starting polling", async () => {
|
||||
const calls: string[] = [];
|
||||
let currentToken: string | undefined;
|
||||
const runtime = createTelegramSetupPromptRuntime({
|
||||
env: {},
|
||||
getConfig: () => ({}),
|
||||
setConfig: (config) => {
|
||||
currentToken = config.botToken;
|
||||
calls.push(`set:${config.botToken}`);
|
||||
},
|
||||
setupGuard: {
|
||||
start: () => true,
|
||||
finish: () => calls.push("finish"),
|
||||
},
|
||||
getMe: async () => ({ ok: true, result: { id: 7, username: "demo_bot" } }),
|
||||
persistConfig: async (config) => {
|
||||
calls.push(`persist:${config.botToken}`);
|
||||
},
|
||||
startPolling: () => calls.push(`start:${currentToken ?? "missing"}`),
|
||||
updateStatus: () => calls.push("status"),
|
||||
});
|
||||
|
||||
await runtime({
|
||||
hasUI: true,
|
||||
ui: {
|
||||
input: async () => "token",
|
||||
editor: async () => "token",
|
||||
notify: (message) => calls.push(`notify:${message}`),
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
"set:token",
|
||||
"persist:token",
|
||||
"notify:Telegram bot connected: @demo_bot",
|
||||
"notify:Send /start to your bot in Telegram to pair this extension with your account.",
|
||||
"start:token",
|
||||
"status",
|
||||
"finish",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Setup prompt runtime rolls memory back when persistence fails", async () => {
|
||||
const calls: string[] = [];
|
||||
let config: TelegramSetupConfig = { botToken: "previous" };
|
||||
const runtime = createTelegramSetupPromptRuntime({
|
||||
env: {},
|
||||
getConfig: () => config,
|
||||
setConfig: (next) => {
|
||||
config = { ...next };
|
||||
calls.push(`set:${next.botToken}`);
|
||||
},
|
||||
setupGuard: {
|
||||
start: () => true,
|
||||
finish: () => calls.push("finish"),
|
||||
},
|
||||
getMe: async () => ({ ok: true, result: { id: 7, username: "demo_bot" } }),
|
||||
persistConfig: async () => {
|
||||
calls.push("persist");
|
||||
throw new Error("disk full");
|
||||
},
|
||||
startPolling: () => calls.push("unexpected:start"),
|
||||
updateStatus: () => calls.push("unexpected:status"),
|
||||
recordRuntimeEvent: () => calls.push("record"),
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
runtime({
|
||||
hasUI: true,
|
||||
ui: {
|
||||
input: async () => "token",
|
||||
editor: async () => "token",
|
||||
notify: () => calls.push("unexpected:notify"),
|
||||
},
|
||||
}),
|
||||
/disk full/,
|
||||
);
|
||||
assert.deepEqual(config, { botToken: "previous" });
|
||||
assert.deepEqual(calls, [
|
||||
"set:token",
|
||||
"persist",
|
||||
"set:previous",
|
||||
"record",
|
||||
"finish",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Setup prompt runtime persists the first validated config to missing or empty files", async () => {
|
||||
for (const initialFile of [undefined, "{}\n"]) {
|
||||
const dir = await mkdtemp(join(tmpdir(), "pi-telegram-setup-first-run-"));
|
||||
const configPath = join(dir, "telegram.json");
|
||||
try {
|
||||
if (initialFile !== undefined) await writeFile(configPath, initialFile);
|
||||
const store = createTelegramConfigStore({ agentDir: dir, configPath });
|
||||
await store.load();
|
||||
const runtime = createTelegramSetupPromptRuntime({
|
||||
env: { TELEGRAM_BOT_TOKEN: "env-token" },
|
||||
getConfig: store.get,
|
||||
setConfig: store.set,
|
||||
setupGuard: { start: () => true, finish: () => {} },
|
||||
getMe: async () => ({
|
||||
ok: true,
|
||||
result: { id: 77, username: "first_run_bot" },
|
||||
}),
|
||||
persistConfig: async () => store.persist(),
|
||||
startPolling: () => ({ ok: true }),
|
||||
updateStatus: () => {},
|
||||
});
|
||||
|
||||
const result = await runtime({
|
||||
hasUI: true,
|
||||
ui: {
|
||||
input: async () => "env-token",
|
||||
editor: async () => "env-token",
|
||||
notify: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.status, "success");
|
||||
assert.deepEqual(JSON.parse(await readFile(configPath, "utf8")), {
|
||||
botToken: "env-token",
|
||||
botUsername: "first_run_bot",
|
||||
botId: 77,
|
||||
});
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("Setup prompt runtime persists a first named profile without changing siblings", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "pi-telegram-setup-profile-"));
|
||||
const configPath = join(dir, "telegram.json");
|
||||
try {
|
||||
const store = createTelegramConfigStore({
|
||||
agentDir: dir,
|
||||
configPath,
|
||||
initialConfig: {
|
||||
botToken: "default-token",
|
||||
botId: 1,
|
||||
profiles: {
|
||||
existing: { botToken: "existing-token", botId: 2 },
|
||||
fresh: { botToken: "" },
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(store.activateProfile("fresh"), true);
|
||||
const runtime = createTelegramSetupPromptRuntime({
|
||||
env: { TELEGRAM_BOT_TOKEN: "fresh-token" },
|
||||
getConfig: store.get,
|
||||
setConfig: store.set,
|
||||
setupGuard: { start: () => true, finish: () => {} },
|
||||
getMe: async () => ({
|
||||
ok: true,
|
||||
result: { id: 3, username: "fresh_bot" },
|
||||
}),
|
||||
persistConfig: async () => store.persist(),
|
||||
startPolling: () => ({ ok: true }),
|
||||
updateStatus: () => {},
|
||||
});
|
||||
|
||||
const result = await runtime({
|
||||
hasUI: true,
|
||||
ui: {
|
||||
input: async () => "fresh-token",
|
||||
editor: async () => "fresh-token",
|
||||
notify: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.status, "success");
|
||||
assert.deepEqual(JSON.parse(await readFile(configPath, "utf8")), {
|
||||
botToken: "default-token",
|
||||
botId: 1,
|
||||
profiles: {
|
||||
existing: { botToken: "existing-token", botId: 2 },
|
||||
fresh: {
|
||||
botToken: "fresh-token",
|
||||
botUsername: "fresh_bot",
|
||||
botId: 3,
|
||||
},
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Setup prompt runtime reports token check errors and always finishes", async () => {
|
||||
const calls: string[] = [];
|
||||
let locked = false;
|
||||
const runtime = createTelegramSetupPromptRuntime({
|
||||
env: {},
|
||||
getConfig: () => ({}),
|
||||
setConfig: () => calls.push("set-config"),
|
||||
setupGuard: {
|
||||
start: () => {
|
||||
if (locked) return false;
|
||||
locked = true;
|
||||
calls.push("guard-start");
|
||||
return true;
|
||||
},
|
||||
finish: () => {
|
||||
locked = false;
|
||||
calls.push("guard-finish");
|
||||
},
|
||||
},
|
||||
getMe: async () => {
|
||||
throw new Error("network down");
|
||||
},
|
||||
persistConfig: async () => {
|
||||
calls.push("persist");
|
||||
},
|
||||
startPolling: () => calls.push("start"),
|
||||
updateStatus: () => calls.push("status"),
|
||||
recordRuntimeEvent: (category, error) =>
|
||||
calls.push(`${category}:${(error as Error).message}`),
|
||||
});
|
||||
|
||||
await runtime({
|
||||
hasUI: true,
|
||||
ui: {
|
||||
input: async () => "token",
|
||||
editor: async () => "token",
|
||||
notify: (message) => calls.push(`notify:${message}`),
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
"guard-start",
|
||||
"notify:Telegram API check failed: network down",
|
||||
"guard-finish",
|
||||
]);
|
||||
assert.equal(locked, false);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
+1208
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Telegram target regression tests
|
||||
* Zones: telegram transport, routing, multi-instance bus
|
||||
* Covers private and thread target identity helpers used by future multi-instance routing
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
areTelegramTargetsEqual,
|
||||
createTelegramPrivateTarget,
|
||||
createTelegramThreadTarget,
|
||||
getTelegramTargetKey,
|
||||
getTelegramTargetThreadParams,
|
||||
isTelegramThreadTarget,
|
||||
} from "../lib/target.ts";
|
||||
|
||||
test("Telegram target helpers model private chat targets", () => {
|
||||
const target = createTelegramPrivateTarget(7);
|
||||
assert.deepEqual(target, { chatId: 7 });
|
||||
assert.equal(getTelegramTargetKey(target), "7:private");
|
||||
assert.equal(isTelegramThreadTarget(target), false);
|
||||
assert.deepEqual(getTelegramTargetThreadParams(target), {});
|
||||
});
|
||||
|
||||
test("Telegram target helpers model topic thread targets", () => {
|
||||
const target = createTelegramThreadTarget(-100123, 42);
|
||||
assert.deepEqual(target, { chatId: -100123, threadId: 42 });
|
||||
assert.equal(getTelegramTargetKey(target), "-100123:42");
|
||||
assert.equal(isTelegramThreadTarget(target), true);
|
||||
assert.deepEqual(getTelegramTargetThreadParams(target), {
|
||||
message_thread_id: 42,
|
||||
});
|
||||
});
|
||||
|
||||
test("Telegram target equality includes thread identity", () => {
|
||||
assert.equal(
|
||||
areTelegramTargetsEqual(
|
||||
createTelegramThreadTarget(1, 2),
|
||||
createTelegramThreadTarget(1, 2),
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
areTelegramTargetsEqual(
|
||||
createTelegramThreadTarget(1, 2),
|
||||
createTelegramThreadTarget(1, 3),
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
areTelegramTargetsEqual(
|
||||
createTelegramPrivateTarget(1),
|
||||
createTelegramThreadTarget(1, 2),
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Regression tests for Telegram text-group coalescing
|
||||
* Exercises conservative recovery of automatically split long Telegram messages
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import * as TextGroups from "../lib/text-groups.ts";
|
||||
|
||||
type TestMessage = TextGroups.TelegramTextGroupMessage;
|
||||
|
||||
function createMessage(
|
||||
messageId: number,
|
||||
text: string,
|
||||
overrides: Partial<TestMessage> = {},
|
||||
): TestMessage {
|
||||
return {
|
||||
message_id: messageId,
|
||||
chat: { id: 99 },
|
||||
from: { id: 77, is_bot: false },
|
||||
text,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("Text group helper delays likely split messages and appends quick continuations", () => {
|
||||
const groups = new Map<
|
||||
string,
|
||||
TextGroups.TelegramTextGroupState<TestMessage, string>
|
||||
>();
|
||||
const timers: Array<() => void> = [];
|
||||
const dispatched: string[] = [];
|
||||
const queue = (message: TestMessage) =>
|
||||
TextGroups.queueTelegramTextGroupMessage({
|
||||
message,
|
||||
context: "ctx",
|
||||
groups,
|
||||
debounceMs: 10,
|
||||
minSplitLength: 8,
|
||||
setTimer: (callback) => {
|
||||
timers.push(callback);
|
||||
return callback as unknown as ReturnType<typeof setTimeout>;
|
||||
},
|
||||
clearTimer: () => {},
|
||||
dispatchMessages: (messages, ctx) => {
|
||||
dispatched.push(
|
||||
`${ctx}:${messages.map((item) => item.text).join("|")}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
assert.equal(queue(createMessage(1, "short")), false);
|
||||
assert.equal(queue(createMessage(2, "long-enough")), true);
|
||||
assert.equal(queue(createMessage(3, "tail")), true);
|
||||
assert.deepEqual(dispatched, []);
|
||||
timers.at(-1)?.();
|
||||
assert.deepEqual(dispatched, ["ctx:long-enough|tail"]);
|
||||
});
|
||||
|
||||
test("Text group keeps split messages until asynchronous dispatch succeeds", async () => {
|
||||
const groups = new Map<
|
||||
string,
|
||||
TextGroups.TelegramTextGroupState<TestMessage, string>
|
||||
>();
|
||||
const timers: Array<() => void> = [];
|
||||
let attempts = 0;
|
||||
TextGroups.queueTelegramTextGroupMessage({
|
||||
message: createMessage(1, "long-enough"),
|
||||
context: "ctx",
|
||||
groups,
|
||||
debounceMs: 10,
|
||||
minSplitLength: 8,
|
||||
setTimer: (callback) => {
|
||||
timers.push(callback);
|
||||
return callback as unknown as ReturnType<typeof setTimeout>;
|
||||
},
|
||||
clearTimer: () => {},
|
||||
dispatchMessages: async () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) throw new Error("queue admission failed");
|
||||
},
|
||||
});
|
||||
|
||||
timers[0]?.();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.equal(groups.size, 1);
|
||||
assert.equal(timers.length, 2);
|
||||
timers[1]?.();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.equal(attempts, 2);
|
||||
assert.equal(groups.size, 0);
|
||||
});
|
||||
|
||||
test("Text group controller clears pending timers without stale dispatch", () => {
|
||||
const dispatched: string[] = [];
|
||||
const timers: Array<{ active: boolean; callback: () => void }> = [];
|
||||
const controller = TextGroups.createTelegramTextGroupController<
|
||||
TestMessage,
|
||||
string
|
||||
>({
|
||||
debounceMs: 10,
|
||||
minSplitLength: 8,
|
||||
setTimer: (callback) => {
|
||||
const timer = { active: true, callback };
|
||||
timers.push(timer);
|
||||
return timer as unknown as ReturnType<typeof setTimeout>;
|
||||
},
|
||||
clearTimer: (timer) => {
|
||||
(timer as unknown as { active: boolean }).active = false;
|
||||
},
|
||||
});
|
||||
const dispatchMessages = (messages: TestMessage[], ctx: string) => {
|
||||
dispatched.push(`${ctx}:${messages.map((item) => item.text).join("|")}`);
|
||||
};
|
||||
assert.equal(
|
||||
controller.queueMessage({
|
||||
message: createMessage(1, "long-enough"),
|
||||
context: "ctx",
|
||||
dispatchMessages,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
controller.clear();
|
||||
for (const timer of timers) {
|
||||
if (timer.active) timer.callback();
|
||||
}
|
||||
assert.deepEqual(dispatched, []);
|
||||
});
|
||||
|
||||
test("Text group suspension resumes admitted input in the replacement context", async () => {
|
||||
const timers: Array<{ active: boolean; callback: () => void }> = [];
|
||||
const dispatched: string[] = [];
|
||||
const controller = TextGroups.createTelegramTextGroupController<
|
||||
TestMessage,
|
||||
string
|
||||
>({
|
||||
debounceMs: 10,
|
||||
minSplitLength: 8,
|
||||
setTimer: (callback) => {
|
||||
const timer = { active: true, callback };
|
||||
timers.push(timer);
|
||||
return timer as unknown as ReturnType<typeof setTimeout>;
|
||||
},
|
||||
clearTimer: (timer) => {
|
||||
(timer as unknown as { active: boolean }).active = false;
|
||||
},
|
||||
});
|
||||
controller.queueMessage({
|
||||
message: createMessage(1, "long-enough"),
|
||||
context: "old-session",
|
||||
dispatchMessages: (messages, ctx) => {
|
||||
dispatched.push(`${ctx}:${messages.map((item) => item.text).join("|")}`);
|
||||
},
|
||||
});
|
||||
|
||||
controller.suspend();
|
||||
controller.resume("new-session");
|
||||
for (const timer of timers) {
|
||||
if (timer.active) timer.callback();
|
||||
}
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepEqual(dispatched, ["new-session:long-enough"]);
|
||||
});
|
||||
|
||||
test("Text group helper uses 3600 as the default near-limit threshold", () => {
|
||||
const groups = new Map<
|
||||
string,
|
||||
TextGroups.TelegramTextGroupState<TestMessage, string>
|
||||
>();
|
||||
const timers: Array<() => void> = [];
|
||||
const queue = (message: TestMessage) =>
|
||||
TextGroups.queueTelegramTextGroupMessage({
|
||||
message,
|
||||
context: "ctx",
|
||||
groups,
|
||||
debounceMs: 10,
|
||||
minSplitLength: 3600,
|
||||
setTimer: (callback) => {
|
||||
timers.push(callback);
|
||||
return callback as unknown as ReturnType<typeof setTimeout>;
|
||||
},
|
||||
clearTimer: () => {},
|
||||
dispatchMessages: () => {},
|
||||
});
|
||||
assert.equal(queue(createMessage(1, "x".repeat(3599))), false);
|
||||
assert.equal(queue(createMessage(2, "x".repeat(3600))), true);
|
||||
});
|
||||
|
||||
test("Text group helper ignores commands, bots, media groups, and non-contiguous tails", () => {
|
||||
const groups = new Map<
|
||||
string,
|
||||
TextGroups.TelegramTextGroupState<TestMessage, string>
|
||||
>();
|
||||
const timers: Array<() => void> = [];
|
||||
const base = {
|
||||
context: "ctx",
|
||||
groups,
|
||||
debounceMs: 10,
|
||||
minSplitLength: 8,
|
||||
setTimer: (callback: () => void) => {
|
||||
timers.push(callback);
|
||||
return callback as unknown as ReturnType<typeof setTimeout>;
|
||||
},
|
||||
clearTimer: () => {},
|
||||
dispatchMessages: () => {},
|
||||
};
|
||||
assert.equal(
|
||||
TextGroups.queueTelegramTextGroupMessage({
|
||||
...base,
|
||||
message: createMessage(1, "/template lots of text"),
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
TextGroups.queueTelegramTextGroupMessage({
|
||||
...base,
|
||||
message: createMessage(2, "long-enough", {
|
||||
from: { id: 77, is_bot: true },
|
||||
}),
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
TextGroups.queueTelegramTextGroupMessage({
|
||||
...base,
|
||||
message: createMessage(3, "long-enough", { media_group_id: "album" }),
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
TextGroups.queueTelegramTextGroupMessage({
|
||||
...base,
|
||||
message: createMessage(4, "long-enough"),
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
TextGroups.queueTelegramTextGroupMessage({
|
||||
...base,
|
||||
message: createMessage(30, "tail"),
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("Text group helper scopes split recovery by thread target", () => {
|
||||
const groups = new Map<
|
||||
string,
|
||||
TextGroups.TelegramTextGroupState<TestMessage, string>
|
||||
>();
|
||||
const timers: Array<() => void> = [];
|
||||
const dispatched: string[] = [];
|
||||
const queue = (message: TestMessage) =>
|
||||
TextGroups.queueTelegramTextGroupMessage({
|
||||
message,
|
||||
context: "ctx",
|
||||
groups,
|
||||
debounceMs: 10,
|
||||
minSplitLength: 8,
|
||||
setTimer: (callback) => {
|
||||
timers.push(callback);
|
||||
return callback as unknown as ReturnType<typeof setTimeout>;
|
||||
},
|
||||
clearTimer: () => {},
|
||||
dispatchMessages: (messages, ctx) => {
|
||||
dispatched.push(
|
||||
`${ctx}:${messages.map((item) => item.text).join("|")}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
queue(createMessage(1, "long-enough", { message_thread_id: 10 })),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
queue(createMessage(2, "other-thread", { message_thread_id: 11 })),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
queue(createMessage(3, "tail", { message_thread_id: 10 })),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(dispatched, []);
|
||||
timers.at(-1)?.();
|
||||
timers.at(-2)?.();
|
||||
assert.deepEqual(dispatched.sort(), [
|
||||
"ctx:long-enough|tail",
|
||||
"ctx:other-thread",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Text group helper appends many split tails with wider id gaps", () => {
|
||||
const groups = new Map<
|
||||
string,
|
||||
TextGroups.TelegramTextGroupState<TestMessage, string>
|
||||
>();
|
||||
const timers: Array<() => void> = [];
|
||||
const dispatched: string[] = [];
|
||||
const queue = (message: TestMessage) =>
|
||||
TextGroups.queueTelegramTextGroupMessage({
|
||||
message,
|
||||
context: "ctx",
|
||||
groups,
|
||||
debounceMs: 10,
|
||||
minSplitLength: 8,
|
||||
setTimer: (callback) => {
|
||||
timers.push(callback);
|
||||
return callback as unknown as ReturnType<typeof setTimeout>;
|
||||
},
|
||||
clearTimer: () => {},
|
||||
dispatchMessages: (messages, ctx) => {
|
||||
dispatched.push(
|
||||
`${ctx}:${messages.map((item) => item.text).join("|")}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(queue(createMessage(1, "long-enough")), true);
|
||||
assert.equal(queue(createMessage(8, "tail-1")), true);
|
||||
assert.equal(queue(createMessage(18, "tail-2")), true);
|
||||
assert.equal(queue(createMessage(28, "tail-3")), true);
|
||||
assert.deepEqual(dispatched, []);
|
||||
timers.at(-1)?.();
|
||||
assert.deepEqual(dispatched, ["ctx:long-enough|tail-1|tail-2|tail-3"]);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Regression tests for the Telegram time-injection runtime
|
||||
* Covers injection mode gating, interval bookkeeping per chat, and timezone formatting
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
resolveTelegramTimeConfig,
|
||||
type ResolvedTelegramTimeConfig,
|
||||
} from "../lib/config.ts";
|
||||
import {
|
||||
createTimeInjectionRuntime,
|
||||
formatTelegramTimeInjectionLine,
|
||||
} from "../lib/time-injection.ts";
|
||||
|
||||
function makeRuntime(config: Partial<ResolvedTelegramTimeConfig>) {
|
||||
const resolved: ResolvedTelegramTimeConfig = {
|
||||
injectionMode: config.injectionMode ?? "hidden",
|
||||
interval: config.interval ?? 60 * 60 * 1000,
|
||||
timezone: config.timezone ?? "UTC",
|
||||
};
|
||||
return createTimeInjectionRuntime({ getConfig: () => resolved });
|
||||
}
|
||||
|
||||
test("Time config resolves defaults when keys are missing", () => {
|
||||
const resolved = resolveTelegramTimeConfig(undefined);
|
||||
assert.equal(resolved.injectionMode, "hidden");
|
||||
assert.equal(resolved.interval, 60 * 60 * 1000);
|
||||
assert.equal(typeof resolved.timezone, "string");
|
||||
assert.ok(resolved.timezone.length > 0);
|
||||
});
|
||||
|
||||
test("Time config maps legacy off mode to hidden", () => {
|
||||
const resolved = resolveTelegramTimeConfig({
|
||||
injectionMode: "off" as never,
|
||||
});
|
||||
assert.equal(resolved.injectionMode, "hidden");
|
||||
});
|
||||
|
||||
test("Time config rejects non-positive interval", () => {
|
||||
const resolved = resolveTelegramTimeConfig({
|
||||
injectionMode: "interval",
|
||||
interval: 0,
|
||||
});
|
||||
assert.equal(resolved.interval, 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
test("Time injection runtime returns null for every call when injectionMode is hidden", () => {
|
||||
const runtime = makeRuntime({ injectionMode: "hidden", timezone: "UTC" });
|
||||
for (let i = 0; i < 5; i++) {
|
||||
assert.equal(runtime.resolveLine(1, new Date(i * 1000)), null);
|
||||
}
|
||||
});
|
||||
|
||||
test("Time injection runtime returns formatted line on every call when injectionMode is always", () => {
|
||||
const runtime = makeRuntime({ injectionMode: "always", timezone: "UTC" });
|
||||
const first = runtime.resolveLine(1, new Date("2026-05-16T14:32:10Z"));
|
||||
const second = runtime.resolveLine(1, new Date("2026-05-16T14:32:11Z"));
|
||||
assert.equal(first, "2026-05-16 14:32:10 UTC");
|
||||
assert.equal(second, "2026-05-16 14:32:11 UTC");
|
||||
});
|
||||
|
||||
test("Time injection runtime suppresses repeat lines within the interval window", () => {
|
||||
const runtime = makeRuntime({
|
||||
injectionMode: "interval",
|
||||
interval: 60_000,
|
||||
timezone: "UTC",
|
||||
});
|
||||
const t0 = new Date("2026-05-16T14:00:00Z");
|
||||
const tWithin = new Date("2026-05-16T14:00:30Z");
|
||||
const tAfter = new Date("2026-05-16T14:01:01Z");
|
||||
assert.equal(runtime.resolveLine(1, t0), "2026-05-16 14:00:00 UTC");
|
||||
assert.equal(runtime.resolveLine(1, tWithin), null);
|
||||
assert.equal(runtime.resolveLine(1, tAfter), "2026-05-16 14:01:01 UTC");
|
||||
});
|
||||
|
||||
test("Time injection runtime tracks interval state per chatId", () => {
|
||||
const runtime = makeRuntime({
|
||||
injectionMode: "interval",
|
||||
interval: 60_000,
|
||||
timezone: "UTC",
|
||||
});
|
||||
const t0 = new Date("2026-05-16T14:00:00Z");
|
||||
const tWithin = new Date("2026-05-16T14:00:30Z");
|
||||
assert.ok(runtime.resolveLine(1, t0));
|
||||
assert.equal(runtime.resolveLine(1, tWithin), null);
|
||||
// Different chat should not be gated by chat 1's recent injection
|
||||
assert.ok(runtime.resolveLine(2, tWithin));
|
||||
});
|
||||
|
||||
test("Time injection formatting honours the requested timezone", () => {
|
||||
const now = new Date("2026-05-16T14:32:10Z");
|
||||
assert.equal(
|
||||
formatTelegramTimeInjectionLine(now, "UTC"),
|
||||
"2026-05-16 14:32:10 UTC",
|
||||
);
|
||||
// Europe/Berlin is UTC+2 in May (CEST)
|
||||
assert.equal(
|
||||
formatTelegramTimeInjectionLine(now, "Europe/Berlin"),
|
||||
"2026-05-16 16:32:10 Europe/Berlin",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,951 @@
|
||||
/**
|
||||
* Regression tests for the Telegram turn-building domain
|
||||
* Covers queue-summary formatting, prompt construction, and prompt-turn assembly from messages and downloaded files
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildTelegramPromptTurn,
|
||||
buildTelegramPromptTurnRuntime,
|
||||
buildTelegramTurnPrompt,
|
||||
createTelegramPromptTurnRuntimeBuilder,
|
||||
createTelegramQueuedPromptEditRuntime,
|
||||
formatTelegramTurnStatusSummary,
|
||||
getTelegramVoiceReplyMode,
|
||||
truncateTelegramQueueSummary,
|
||||
updateQueuedTelegramPromptTurnText,
|
||||
updateTelegramPromptTurnText,
|
||||
} from "../lib/turns.ts";
|
||||
|
||||
test("Turn helpers truncate queue summaries predictably", () => {
|
||||
assert.equal(
|
||||
truncateTelegramQueueSummary("one two three four"),
|
||||
"one two three four",
|
||||
);
|
||||
assert.equal(
|
||||
truncateTelegramQueueSummary("one two three four five six"),
|
||||
"one two three four five…",
|
||||
);
|
||||
assert.equal(truncateTelegramQueueSummary(" "), "");
|
||||
});
|
||||
|
||||
test("Turn helpers build prompt text with history and attachments", () => {
|
||||
const prompt = buildTelegramTurnPrompt({
|
||||
telegramPrefix: "[telegram]",
|
||||
rawText: "current message",
|
||||
files: [{ path: "/tmp/demo.png", fileName: "demo.png", isImage: true }],
|
||||
historyTurns: [{ historyText: "older message" }],
|
||||
});
|
||||
assert.match(prompt, /^\[telegram\]/);
|
||||
assert.match(
|
||||
prompt,
|
||||
/Earlier Telegram messages arrived after an aborted turn/,
|
||||
);
|
||||
assert.match(prompt, /1\. older message/);
|
||||
assert.match(prompt, /Current Telegram message:\ncurrent message/);
|
||||
assert.match(prompt, /\[attachments\] \/tmp\n- \/demo.png/);
|
||||
});
|
||||
|
||||
test("Turn helpers omit [time] section by default", () => {
|
||||
const prompt = buildTelegramTurnPrompt({
|
||||
telegramPrefix: "[telegram]",
|
||||
rawText: "hello",
|
||||
files: [],
|
||||
});
|
||||
assert.equal(prompt, "[telegram] hello");
|
||||
});
|
||||
|
||||
test("Turn helpers inject [time] as the final context section", () => {
|
||||
const prompt = buildTelegramTurnPrompt({
|
||||
telegramPrefix: "[telegram]",
|
||||
rawText: "current message",
|
||||
files: [{ path: "/tmp/demo.png", fileName: "demo.png", isImage: true }],
|
||||
handlerOutputs: ["transcript"],
|
||||
voiceContext: { "reply mode": "manual" },
|
||||
timeLine: "2026-05-16 14:32:10 Europe/Berlin",
|
||||
});
|
||||
assert.match(
|
||||
prompt,
|
||||
/^\[telegram\] current message\n\n\[attachments\] \/tmp\n- \/demo\.png\n\n\[outputs\]\n- transcript\n\n\[voice\] reply mode: manual\n\n\[time\] 2026-05-16 14:32:10 Europe\/Berlin$/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Turn helpers still inject [time] when raw text is empty", () => {
|
||||
const prompt = buildTelegramTurnPrompt({
|
||||
telegramPrefix: "[telegram]",
|
||||
rawText: "",
|
||||
files: [],
|
||||
timeLine: "2026-05-16 14:32:10 UTC",
|
||||
});
|
||||
assert.equal(prompt, "[telegram]\n\n[time] 2026-05-16 14:32:10 UTC");
|
||||
});
|
||||
|
||||
test("Turn runtime builder calls resolveTimeLine with chatId and embeds result", async () => {
|
||||
const seen: number[] = [];
|
||||
const buildTurn = createTelegramPromptTurnRuntimeBuilder({
|
||||
allocateQueueOrder: () => 1,
|
||||
downloadFile: async (_fileId, fileName) => `/tmp/${fileName}`,
|
||||
resolveTimeLine: (chatId) => {
|
||||
seen.push(chatId);
|
||||
return "2026-05-16 14:32:10 UTC";
|
||||
},
|
||||
});
|
||||
const turn = await buildTurn([
|
||||
{ message_id: 42, chat: { id: 7 }, text: "hi" },
|
||||
]);
|
||||
assert.deepEqual(seen, [7]);
|
||||
assert.match(
|
||||
(turn.content[0] as { type: "text"; text: string }).text,
|
||||
/\[time\] 2026-05-16 14:32:10 UTC/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Turn runtime builder adds explicit thread attribute to Telegram prefix", async () => {
|
||||
const buildTurn = createTelegramPromptTurnRuntimeBuilder({
|
||||
allocateQueueOrder: () => 1,
|
||||
downloadFile: async (_fileId, fileName) => `/tmp/${fileName}`,
|
||||
getTelegramThreadLabel: () => "🧭 Axial|bad]name",
|
||||
});
|
||||
const turn = await buildTurn([
|
||||
{ message_id: 42, message_thread_id: 7, chat: { id: 7 }, text: "hi" },
|
||||
]);
|
||||
assert.equal(
|
||||
(turn.content[0] as { type: "text"; text: string }).text,
|
||||
"[telegram|thread:🧭 Axial bad name] hi",
|
||||
);
|
||||
});
|
||||
|
||||
test("Turn helpers summarize text and attachment-only turns", () => {
|
||||
assert.equal(
|
||||
formatTelegramTurnStatusSummary("hello there from telegram", []),
|
||||
"hello there from telegram",
|
||||
);
|
||||
assert.equal(
|
||||
formatTelegramTurnStatusSummary("", [
|
||||
{
|
||||
path: "/tmp/report-final-version.txt",
|
||||
fileName: "report-final-version.txt",
|
||||
isImage: false,
|
||||
},
|
||||
]),
|
||||
"📎 report-final-version.txt",
|
||||
);
|
||||
assert.equal(
|
||||
formatTelegramTurnStatusSummary("", [
|
||||
{ path: "/tmp/a.txt", fileName: "a.txt", isImage: false },
|
||||
{ path: "/tmp/b.txt", fileName: "b.txt", isImage: false },
|
||||
]),
|
||||
"📎 2 attachments",
|
||||
);
|
||||
});
|
||||
|
||||
test("Turn helpers update queued prompt text for edited Telegram messages", () => {
|
||||
const turn = {
|
||||
kind: "prompt" as const,
|
||||
chatId: 99,
|
||||
replyToMessageId: 10,
|
||||
sourceMessageIds: [10],
|
||||
queueOrder: 1,
|
||||
queueLane: "default" as const,
|
||||
laneOrder: 1,
|
||||
queuedAttachments: [],
|
||||
content: [{ type: "text" as const, text: "[telegram] old" }],
|
||||
historyText: "old",
|
||||
statusSummary: "old",
|
||||
};
|
||||
const updated = updateTelegramPromptTurnText({
|
||||
turn,
|
||||
telegramPrefix: "[telegram]",
|
||||
rawText: "new edited message",
|
||||
});
|
||||
assert.equal(updated.content[0]?.type, "text");
|
||||
assert.equal(
|
||||
(updated.content[0] as { type: "text"; text: string }).text,
|
||||
"[telegram] new edited message",
|
||||
);
|
||||
assert.equal(updated.historyText, "new edited message");
|
||||
assert.equal(updated.statusSummary, "new edited message");
|
||||
assert.notEqual(updated, turn);
|
||||
});
|
||||
|
||||
test("Turn runtime builder extracts text, downloads files, and allocates order", async () => {
|
||||
let nextOrder = 3;
|
||||
const downloaded: string[] = [];
|
||||
const buildTurn = createTelegramPromptTurnRuntimeBuilder({
|
||||
allocateQueueOrder: () => nextOrder++,
|
||||
downloadFile: async (fileId, fileName) => {
|
||||
downloaded.push(`${fileId}:${fileName}`);
|
||||
return `/tmp/${fileName}`;
|
||||
},
|
||||
});
|
||||
const turn = await buildTurn([
|
||||
{
|
||||
message_id: 11,
|
||||
chat: { id: 5 },
|
||||
caption: "see file",
|
||||
document: { file_id: "doc-1", file_name: "report.txt" },
|
||||
},
|
||||
]);
|
||||
assert.equal(turn.queueOrder, 3);
|
||||
assert.equal(turn.statusSummary, "see file");
|
||||
assert.deepEqual(downloaded, ["doc-1:report.txt"]);
|
||||
assert.match(
|
||||
(turn.content[0] as { type: "text"; text: string }).text,
|
||||
/\[attachments\] \/tmp\n- \/report\.txt/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Turn runtime builder injects Telegram reply context into prompt turns", async () => {
|
||||
const buildTurn = createTelegramPromptTurnRuntimeBuilder({
|
||||
allocateQueueOrder: () => 1,
|
||||
downloadFile: async (_fileId, fileName) => `/tmp/${fileName}`,
|
||||
});
|
||||
const turn = await buildTurn([
|
||||
{
|
||||
message_id: 12,
|
||||
chat: { id: 5 },
|
||||
text: "Not yet",
|
||||
reply_to_message: { text: "Have you seen the latest Claude update?" },
|
||||
},
|
||||
]);
|
||||
assert.equal(turn.statusSummary, "Not yet");
|
||||
assert.equal(
|
||||
turn.historyText,
|
||||
"Not yet\n\n[reply] Have you seen the latest Claude update?",
|
||||
);
|
||||
assert.equal(
|
||||
(turn.content[0] as { type: "text"; text: string }).text,
|
||||
"[telegram] Not yet\n\n[reply] Have you seen the latest Claude update?",
|
||||
);
|
||||
});
|
||||
|
||||
test("Turn runtime builder identifies forwarded messages from non-owner users", async () => {
|
||||
const buildTurn = createTelegramPromptTurnRuntimeBuilder({
|
||||
allocateQueueOrder: () => 1,
|
||||
downloadFile: async (_fileId, fileName) => `/tmp/${fileName}`,
|
||||
getAllowedUserId: () => 100,
|
||||
});
|
||||
const turn = await buildTurn([
|
||||
{
|
||||
message_id: 12,
|
||||
chat: { id: 5 },
|
||||
from: { id: 100, first_name: "Owner" },
|
||||
forward_origin: {
|
||||
type: "user",
|
||||
sender_user: { id: 200, first_name: "Alice", username: "alice" },
|
||||
},
|
||||
text: "forwarded text",
|
||||
},
|
||||
]);
|
||||
assert.equal(
|
||||
(turn.content[0] as { type: "text"; text: string }).text,
|
||||
"[telegram]\n\n[forward|from:alice] forwarded text",
|
||||
);
|
||||
});
|
||||
|
||||
test("Turn runtime builder places replied-message attachments in source attachment context", async () => {
|
||||
const buildTurn = createTelegramPromptTurnRuntimeBuilder({
|
||||
allocateQueueOrder: () => 1,
|
||||
downloadFile: async (_fileId, fileName) => `/tmp/${fileName}`,
|
||||
});
|
||||
const turn = await buildTurn([
|
||||
{
|
||||
message_id: 12,
|
||||
chat: { id: 5 },
|
||||
text: "look at this",
|
||||
reply_to_message: {
|
||||
message_id: 11,
|
||||
from: { id: 200, first_name: "Alice" },
|
||||
caption: "source image",
|
||||
photo: [{ file_id: "photo-1", file_size: 100 }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
assert.equal(
|
||||
(turn.content[0] as { type: "text"; text: string }).text,
|
||||
"[telegram] look at this\n\n[reply|from:200] source image\n\n[attachments|from:200] /tmp\n- /photo-11.jpg",
|
||||
);
|
||||
});
|
||||
|
||||
test("Turn runtime builder keeps current attachments before source reply context", async () => {
|
||||
const buildTurn = createTelegramPromptTurnRuntimeBuilder({
|
||||
allocateQueueOrder: () => 1,
|
||||
downloadFile: async (_fileId, fileName) => `/tmp/${fileName}`,
|
||||
});
|
||||
const turn = await buildTurn([
|
||||
{
|
||||
message_id: 12,
|
||||
chat: { id: 5 },
|
||||
text: "current",
|
||||
document: { file_id: "doc-1", file_name: "mine.txt" },
|
||||
reply_to_message: {
|
||||
message_id: 11,
|
||||
from: { id: 200, username: "alice" },
|
||||
photo: [{ file_id: "photo-1", file_size: 100 }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
assert.equal(
|
||||
(turn.content[0] as { type: "text"; text: string }).text,
|
||||
[
|
||||
"[telegram] current",
|
||||
"",
|
||||
"[attachments] /tmp",
|
||||
"- /mine.txt",
|
||||
"",
|
||||
"[reply|from:alice]",
|
||||
"",
|
||||
"[attachments|from:alice] /tmp",
|
||||
"- /photo-11.jpg",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
test("Turn runtime builder includes reply block for attachment-only replied messages", async () => {
|
||||
const buildTurn = createTelegramPromptTurnRuntimeBuilder({
|
||||
allocateQueueOrder: () => 1,
|
||||
downloadFile: async (_fileId, fileName) => `/tmp/${fileName}`,
|
||||
});
|
||||
const turn = await buildTurn([
|
||||
{
|
||||
message_id: 12,
|
||||
chat: { id: 5 },
|
||||
text: "what is this?",
|
||||
reply_to_message: {
|
||||
message_id: 11,
|
||||
from: { id: 200, username: "alice" },
|
||||
photo: [{ file_id: "photo-1", file_size: 100 }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
assert.equal(
|
||||
(turn.content[0] as { type: "text"; text: string }).text,
|
||||
"[telegram] what is this?\n\n[reply|from:alice]\n\n[attachments|from:alice] /tmp\n- /photo-11.jpg",
|
||||
);
|
||||
});
|
||||
|
||||
test("Turn runtime builder routes inbound handler output into prompt text", async () => {
|
||||
const buildTurn = createTelegramPromptTurnRuntimeBuilder<
|
||||
{
|
||||
message_id: number;
|
||||
chat: { id: number };
|
||||
voice: { file_id: string; mime_type: string };
|
||||
},
|
||||
{ cwd: string }
|
||||
>({
|
||||
allocateQueueOrder: () => 1,
|
||||
downloadFile: async (_fileId, fileName) => `/tmp/${fileName}`,
|
||||
processAttachments: async (files, rawText, ctx) => ({
|
||||
rawText,
|
||||
promptFiles: files,
|
||||
handlerOutputs: [`transcript from ${ctx.cwd}`],
|
||||
}),
|
||||
});
|
||||
const turn = await buildTurn(
|
||||
[
|
||||
{
|
||||
message_id: 12,
|
||||
chat: { id: 5 },
|
||||
voice: { file_id: "voice-1", mime_type: "audio/ogg" },
|
||||
},
|
||||
],
|
||||
[],
|
||||
{ cwd: "/work" },
|
||||
);
|
||||
assert.equal(turn.statusSummary, "transcript from /work");
|
||||
assert.deepEqual(turn.sourceMessageIds, [12]);
|
||||
assert.equal(
|
||||
turn.historyText,
|
||||
"(no text)\n\n[attachments] /tmp\n- /voice-12.ogg\n\n[outputs]\n- transcript from /work",
|
||||
);
|
||||
assert.equal(
|
||||
(turn.content[0] as { type: "text"; text: string }).text,
|
||||
"[telegram]\n\n[attachments] /tmp\n- /voice-12.ogg\n\n[outputs]\n- transcript from /work",
|
||||
);
|
||||
});
|
||||
|
||||
test("Turn runtime omits voice context when reply mode is only the implicit default", async () => {
|
||||
const buildTurn = createTelegramPromptTurnRuntimeBuilder<
|
||||
{
|
||||
message_id: number;
|
||||
chat: { id: number };
|
||||
voice: { file_id: string; mime_type: string };
|
||||
},
|
||||
unknown
|
||||
>({
|
||||
allocateQueueOrder: () => 1,
|
||||
downloadFile: async (_fileId, fileName) => `/tmp/${fileName}`,
|
||||
getVoiceReplyMode: () => "manual",
|
||||
isVoiceReplyModeConfigured: () => false,
|
||||
});
|
||||
const turn = await buildTurn([
|
||||
{
|
||||
message_id: 13,
|
||||
chat: { id: 5 },
|
||||
voice: { file_id: "voice-1", mime_type: "audio/ogg" },
|
||||
},
|
||||
]);
|
||||
assert.doesNotMatch(
|
||||
(turn.content[0] as { type: "text"; text: string }).text,
|
||||
/\[voice\]/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Turn builder preserves topic thread target", async () => {
|
||||
const turn = await buildTelegramPromptTurnRuntime({
|
||||
telegramPrefix: "[telegram]",
|
||||
messages: [{ message_id: 14, message_thread_id: 42, chat: { id: -1007 } }],
|
||||
queueOrder: 1,
|
||||
rawText: "hello",
|
||||
files: [],
|
||||
statusText: "hello",
|
||||
inferImageMimeType: () => undefined,
|
||||
});
|
||||
assert.deepEqual(turn.target, { chatId: -1007, threadId: 42 });
|
||||
});
|
||||
|
||||
test("Turn builder keeps private bot topic metadata out of prompt prefix", async () => {
|
||||
const turn = await buildTelegramPromptTurnRuntime({
|
||||
telegramPrefix: "[telegram]",
|
||||
messages: [
|
||||
{
|
||||
message_id: 14,
|
||||
message_thread_id: 42,
|
||||
chat: { id: 7, type: "private" },
|
||||
},
|
||||
],
|
||||
queueOrder: 1,
|
||||
rawText: "hello",
|
||||
files: [],
|
||||
statusText: "hello",
|
||||
inferImageMimeType: () => undefined,
|
||||
});
|
||||
assert.equal(
|
||||
(turn.content[0] as { type: "text"; text: string }).text,
|
||||
"[telegram] hello",
|
||||
);
|
||||
});
|
||||
|
||||
test("Turn builder keeps group topic metadata out of prompt prefix", async () => {
|
||||
const turn = await buildTelegramPromptTurnRuntime({
|
||||
telegramPrefix: "[telegram]",
|
||||
messages: [
|
||||
{
|
||||
message_id: 14,
|
||||
message_thread_id: 42,
|
||||
chat: { id: -1007, type: "supergroup" },
|
||||
},
|
||||
],
|
||||
queueOrder: 1,
|
||||
rawText: "hello",
|
||||
files: [],
|
||||
statusText: "hello",
|
||||
inferImageMimeType: () => undefined,
|
||||
});
|
||||
assert.equal(
|
||||
(turn.content[0] as { type: "text"; text: string }).text,
|
||||
"[telegram] hello",
|
||||
);
|
||||
});
|
||||
|
||||
test("Voice reply mode tags turn when voice file present and mode is mirror", async () => {
|
||||
const turn = await buildTelegramPromptTurnRuntime({
|
||||
telegramPrefix: "[telegram]",
|
||||
messages: [{ message_id: 14, chat: { id: 5 } }],
|
||||
queueOrder: 1,
|
||||
rawText: "transcribed voice message",
|
||||
files: [
|
||||
{
|
||||
path: "/tmp/voice-14.ogg",
|
||||
fileName: "voice-14.ogg",
|
||||
mimeType: "audio/ogg",
|
||||
kind: "voice" as import("../lib/media.ts").TelegramAttachmentKind,
|
||||
isImage: false,
|
||||
},
|
||||
],
|
||||
statusText: "transcribed voice message",
|
||||
inferImageMimeType: () => undefined,
|
||||
voiceReplyMode: "mirror",
|
||||
});
|
||||
assert.equal(turn.voiceReplyPreferred, true);
|
||||
assert.equal(turn.voiceReplyRequired, false);
|
||||
assert.match(
|
||||
(turn.content[0] as { type: "text"; text: string }).text,
|
||||
/\[voice\] reply mode: mirror/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Voice reply mode tags turn with voice-required when mode is always", async () => {
|
||||
const turn = await buildTelegramPromptTurnRuntime({
|
||||
telegramPrefix: "[telegram]",
|
||||
messages: [{ message_id: 15, chat: { id: 5 } }],
|
||||
queueOrder: 1,
|
||||
rawText: "hello",
|
||||
files: [],
|
||||
statusText: "hello",
|
||||
inferImageMimeType: () => undefined,
|
||||
voiceReplyMode: "always",
|
||||
});
|
||||
assert.equal(turn.voiceReplyPreferred, false);
|
||||
assert.equal(turn.voiceReplyRequired, true);
|
||||
assert.match(
|
||||
(turn.content[0] as { type: "text"; text: string }).text,
|
||||
/\[voice\] reply mode: always/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Voice reply mode mirror with no voice file stays on the manual text path", async () => {
|
||||
const turn = await buildTelegramPromptTurnRuntime({
|
||||
telegramPrefix: "[telegram]",
|
||||
messages: [{ message_id: 17, chat: { id: 5 } }],
|
||||
queueOrder: 1,
|
||||
rawText: "hello",
|
||||
statusText: "hello",
|
||||
files: [],
|
||||
inferImageMimeType: () => undefined,
|
||||
voiceReplyMode: "mirror",
|
||||
});
|
||||
assert.equal(turn.voiceReplyPreferred, false);
|
||||
assert.equal(turn.voiceReplyRequired, false);
|
||||
assert.doesNotMatch(
|
||||
(turn.content[0] as { type: "text"; text: string }).text,
|
||||
/\[voice\]/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Voice reply mode always with voice file present sets voiceReplyRequired only", async () => {
|
||||
const turn = await buildTelegramPromptTurnRuntime({
|
||||
telegramPrefix: "[telegram]",
|
||||
messages: [{ message_id: 18, chat: { id: 5 } }],
|
||||
queueOrder: 1,
|
||||
rawText: "transcribed voice",
|
||||
statusText: "transcribed voice",
|
||||
files: [
|
||||
{
|
||||
path: "/tmp/voice.ogg",
|
||||
fileName: "voice.ogg",
|
||||
mimeType: "audio/ogg",
|
||||
kind: "voice" as import("../lib/media.ts").TelegramAttachmentKind,
|
||||
isImage: false,
|
||||
},
|
||||
],
|
||||
inferImageMimeType: () => undefined,
|
||||
voiceReplyMode: "always",
|
||||
});
|
||||
assert.equal(turn.voiceReplyPreferred, false);
|
||||
assert.equal(turn.voiceReplyRequired, true);
|
||||
assert.match(
|
||||
(turn.content[0] as { type: "text"; text: string }).text,
|
||||
/\[voice\] reply mode: always/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Turn runtime tags manual mode without voice flags", async () => {
|
||||
const turn = await buildTelegramPromptTurnRuntime({
|
||||
telegramPrefix: "[telegram]",
|
||||
messages: [{ message_id: 16, chat: { id: 5 } }],
|
||||
queueOrder: 1,
|
||||
rawText: "hello",
|
||||
statusText: "hello",
|
||||
files: [
|
||||
{
|
||||
path: "/tmp/voice.ogg",
|
||||
fileName: "voice.ogg",
|
||||
mimeType: "audio/ogg",
|
||||
kind: "voice" as import("../lib/media.ts").TelegramAttachmentKind,
|
||||
isImage: false,
|
||||
},
|
||||
],
|
||||
inferImageMimeType: () => undefined,
|
||||
voiceReplyMode: "manual",
|
||||
});
|
||||
assert.equal(turn.voiceReplyPreferred, false);
|
||||
assert.equal(turn.voiceReplyRequired, false);
|
||||
assert.match(
|
||||
(turn.content[0] as { type: "text"; text: string }).text,
|
||||
/\[voice\] reply mode: manual/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Turn runtime helper reads image payloads from local files", async () => {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "pi-telegram-turn-runtime-"));
|
||||
const imagePath = join(tempDir, "image.png");
|
||||
await writeFile(imagePath, Buffer.from("demo-image"));
|
||||
const turn = await buildTelegramPromptTurnRuntime({
|
||||
telegramPrefix: "[telegram]",
|
||||
messages: [{ message_id: 11, chat: { id: 5 } }],
|
||||
queueOrder: 1,
|
||||
rawText: "see image",
|
||||
files: [
|
||||
{
|
||||
path: imagePath,
|
||||
fileName: "image.png",
|
||||
isImage: true,
|
||||
mimeType: "image/png",
|
||||
},
|
||||
],
|
||||
inferImageMimeType: () => undefined,
|
||||
});
|
||||
assert.deepEqual(turn.content[1], {
|
||||
type: "image",
|
||||
data: Buffer.from("demo-image").toString("base64"),
|
||||
mimeType: "image/png",
|
||||
});
|
||||
});
|
||||
|
||||
test("Turn helpers update matching queued prompt items only", () => {
|
||||
const prompt = {
|
||||
kind: "prompt" as const,
|
||||
chatId: 99,
|
||||
replyToMessageId: 10,
|
||||
sourceMessageIds: [10],
|
||||
queueOrder: 1,
|
||||
queueLane: "default" as const,
|
||||
laneOrder: 1,
|
||||
queuedAttachments: [],
|
||||
content: [{ type: "text" as const, text: "[telegram] old" }],
|
||||
historyText: "old",
|
||||
statusSummary: "old",
|
||||
};
|
||||
const control = {
|
||||
kind: "control" as const,
|
||||
controlType: "status" as const,
|
||||
chatId: 99,
|
||||
replyToMessageId: 11,
|
||||
queueOrder: 2,
|
||||
queueLane: "control" as const,
|
||||
laneOrder: 2,
|
||||
statusSummary: "status",
|
||||
execute: async () => {},
|
||||
};
|
||||
const result = updateQueuedTelegramPromptTurnText({
|
||||
items: [control, prompt],
|
||||
sourceMessageId: 10,
|
||||
telegramPrefix: "[telegram]",
|
||||
rawText: "edited",
|
||||
});
|
||||
assert.equal(result.changed, true);
|
||||
assert.equal(result.items[0], control);
|
||||
assert.equal(
|
||||
((result.items[1] as typeof prompt).content[0] as { text: string }).text,
|
||||
"[telegram] edited",
|
||||
);
|
||||
const unchanged = updateQueuedTelegramPromptTurnText({
|
||||
items: [control, prompt],
|
||||
sourceMessageId: 12,
|
||||
telegramPrefix: "[telegram]",
|
||||
rawText: "ignored",
|
||||
});
|
||||
assert.equal(unchanged.changed, false);
|
||||
assert.deepEqual(unchanged.items, [control, prompt]);
|
||||
});
|
||||
|
||||
test("Turn edit runtime binds queued prompt updates to status", () => {
|
||||
const events: string[] = [];
|
||||
let items = [
|
||||
{
|
||||
kind: "prompt" as const,
|
||||
chatId: 99,
|
||||
replyToMessageId: 10,
|
||||
sourceMessageIds: [10],
|
||||
queueOrder: 1,
|
||||
queueLane: "default" as const,
|
||||
laneOrder: 1,
|
||||
queuedAttachments: [],
|
||||
content: [{ type: "text" as const, text: "[telegram] old" }],
|
||||
historyText: "old",
|
||||
statusSummary: "old",
|
||||
},
|
||||
];
|
||||
const runtime = createTelegramQueuedPromptEditRuntime<
|
||||
{ message_id: number; text?: string },
|
||||
string
|
||||
>({
|
||||
getQueuedItems: () => items,
|
||||
setQueuedItems: (nextItems) => {
|
||||
items = nextItems as typeof items;
|
||||
events.push(`items:${nextItems.length}`);
|
||||
},
|
||||
updateStatus: (ctx) => {
|
||||
events.push(`status:${ctx}`);
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
runtime.updateFromEditedMessage({ message_id: 10, text: "edited" }, "ctx"),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
(items[0]?.content[0] as { text: string }).text,
|
||||
"[telegram] edited",
|
||||
);
|
||||
assert.deepEqual(events, ["items:1", "status:ctx"]);
|
||||
});
|
||||
|
||||
test("Turn edit runtime keeps reply context prompt-only when queued messages change", () => {
|
||||
let items = [
|
||||
{
|
||||
kind: "prompt" as const,
|
||||
chatId: 99,
|
||||
replyToMessageId: 10,
|
||||
sourceMessageIds: [10],
|
||||
queueOrder: 1,
|
||||
queueLane: "default" as const,
|
||||
laneOrder: 1,
|
||||
queuedAttachments: [],
|
||||
content: [{ type: "text" as const, text: "[telegram] old" }],
|
||||
historyText: "old",
|
||||
statusSummary: "old",
|
||||
},
|
||||
];
|
||||
const runtime = createTelegramQueuedPromptEditRuntime<
|
||||
{
|
||||
message_id: number;
|
||||
text?: string;
|
||||
reply_to_message?: { text?: string };
|
||||
},
|
||||
string
|
||||
>({
|
||||
getQueuedItems: () => items,
|
||||
setQueuedItems: (nextItems) => {
|
||||
items = nextItems as typeof items;
|
||||
},
|
||||
updateStatus: () => {},
|
||||
});
|
||||
runtime.updateFromEditedMessage(
|
||||
{
|
||||
message_id: 10,
|
||||
text: "edited",
|
||||
reply_to_message: { text: "quoted" },
|
||||
},
|
||||
"ctx",
|
||||
);
|
||||
assert.equal(
|
||||
(items[0]?.content[0] as { text: string }).text,
|
||||
"[telegram] edited\n\n[reply] quoted",
|
||||
);
|
||||
assert.equal(items[0]?.historyText, "edited\n\n[reply] quoted");
|
||||
assert.equal(items[0]?.statusSummary, "edited");
|
||||
});
|
||||
|
||||
test("Turn helpers preserve queued prompt attachments when captions are edited", () => {
|
||||
const turn = {
|
||||
kind: "prompt" as const,
|
||||
chatId: 99,
|
||||
replyToMessageId: 10,
|
||||
sourceMessageIds: [10],
|
||||
queueOrder: 1,
|
||||
queueLane: "default" as const,
|
||||
laneOrder: 1,
|
||||
queuedAttachments: [],
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text:
|
||||
"[telegram] old caption\n\n" +
|
||||
"[attachments] /tmp\n" +
|
||||
"- /demo.png\n" +
|
||||
"- /report.txt",
|
||||
},
|
||||
{ type: "image" as const, data: "abc", mimeType: "image/png" },
|
||||
],
|
||||
historyText:
|
||||
"old caption\n\n[attachments] /tmp\n- /demo.png\n- /report.txt",
|
||||
statusSummary: "old caption",
|
||||
};
|
||||
const updated = updateTelegramPromptTurnText({
|
||||
turn,
|
||||
telegramPrefix: "[telegram]",
|
||||
rawText: "new caption",
|
||||
});
|
||||
assert.equal(
|
||||
(updated.content[0] as { type: "text"; text: string }).text,
|
||||
"[telegram] new caption\n\n" +
|
||||
"[attachments] /tmp\n" +
|
||||
"- /demo.png\n" +
|
||||
"- /report.txt",
|
||||
);
|
||||
assert.deepEqual(updated.content[1], turn.content[1]);
|
||||
assert.equal(
|
||||
updated.historyText,
|
||||
"new caption\n\n[attachments] /tmp\n- /demo.png\n- /report.txt",
|
||||
);
|
||||
assert.equal(updated.statusSummary, "new caption");
|
||||
});
|
||||
|
||||
test("Turn helpers preserve abort-history prompt context when queued turns are edited", () => {
|
||||
const turn = {
|
||||
kind: "prompt" as const,
|
||||
chatId: 99,
|
||||
replyToMessageId: 10,
|
||||
sourceMessageIds: [10],
|
||||
queueOrder: 1,
|
||||
queueLane: "default" as const,
|
||||
laneOrder: 1,
|
||||
queuedAttachments: [],
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text:
|
||||
"[telegram]\n\n" +
|
||||
"Earlier Telegram messages arrived after an aborted turn. " +
|
||||
"Treat them as prior user messages, in order:\n\n" +
|
||||
"1. older Current Telegram message: quote\n\n" +
|
||||
"Current Telegram message:\nold current",
|
||||
},
|
||||
],
|
||||
historyText: "old current",
|
||||
statusSummary: "old current",
|
||||
};
|
||||
const updated = updateTelegramPromptTurnText({
|
||||
turn,
|
||||
telegramPrefix: "[telegram]",
|
||||
rawText: "new current",
|
||||
});
|
||||
assert.equal(
|
||||
(updated.content[0] as { type: "text"; text: string }).text,
|
||||
"[telegram]\n\n" +
|
||||
"Earlier Telegram messages arrived after an aborted turn. " +
|
||||
"Treat them as prior user messages, in order:\n\n" +
|
||||
"1. older Current Telegram message: quote\n\n" +
|
||||
"Current Telegram message:\nnew current",
|
||||
);
|
||||
assert.equal(updated.historyText, "new current");
|
||||
assert.equal(updated.statusSummary, "new current");
|
||||
});
|
||||
|
||||
test("Turn edit preserves voice reply tags", () => {
|
||||
const turn = {
|
||||
kind: "prompt" as const,
|
||||
chatId: 1,
|
||||
replyToMessageId: 10,
|
||||
sourceMessageIds: [10],
|
||||
queueOrder: 1,
|
||||
queueLane: "default" as const,
|
||||
laneOrder: 1,
|
||||
queuedAttachments: [],
|
||||
content: [{ type: "text" as const, text: "hello" }],
|
||||
historyText: "hello",
|
||||
statusSummary: "hello",
|
||||
voiceReplyPreferred: true,
|
||||
voiceReplyRequired: false,
|
||||
};
|
||||
const updated = updateTelegramPromptTurnText({
|
||||
turn,
|
||||
telegramPrefix: "[telegram]",
|
||||
rawText: "edited",
|
||||
});
|
||||
assert.equal(updated.voiceReplyPreferred, true);
|
||||
assert.equal(updated.voiceReplyRequired, false);
|
||||
});
|
||||
|
||||
test("Turn helpers assemble prompt turns with text, ids, history, and image payloads", async () => {
|
||||
const turn = await buildTelegramPromptTurn({
|
||||
telegramPrefix: "[telegram]",
|
||||
messages: [
|
||||
{ message_id: 10, chat: { id: 99 } },
|
||||
{ message_id: 11, chat: { id: 99 } },
|
||||
],
|
||||
historyTurns: [
|
||||
{
|
||||
kind: "prompt",
|
||||
chatId: 99,
|
||||
replyToMessageId: 1,
|
||||
sourceMessageIds: [1],
|
||||
queueOrder: 1,
|
||||
queueLane: "default",
|
||||
laneOrder: 1,
|
||||
queuedAttachments: [],
|
||||
content: [{ type: "text", text: "ignored" }],
|
||||
historyText: "older message",
|
||||
statusSummary: "older",
|
||||
},
|
||||
],
|
||||
queueOrder: 7,
|
||||
rawText: "current message",
|
||||
files: [
|
||||
{
|
||||
path: "/tmp/demo.png",
|
||||
fileName: "demo.png",
|
||||
isImage: true,
|
||||
mimeType: "image/png",
|
||||
},
|
||||
{
|
||||
path: "/tmp/report.txt",
|
||||
fileName: "report.txt",
|
||||
isImage: false,
|
||||
},
|
||||
],
|
||||
readBinaryFile: async () => new Uint8Array([1, 2, 3]),
|
||||
inferImageMimeType: () => undefined,
|
||||
});
|
||||
assert.equal(turn.chatId, 99);
|
||||
assert.equal(turn.replyToMessageId, 10);
|
||||
assert.deepEqual(turn.sourceMessageIds, [10, 11]);
|
||||
assert.equal(turn.queueOrder, 7);
|
||||
assert.equal(turn.statusSummary, "current message");
|
||||
assert.equal(
|
||||
turn.historyText,
|
||||
"current message\n\n[attachments] /tmp\n- /demo.png\n- /report.txt",
|
||||
);
|
||||
assert.equal(turn.content.length, 2);
|
||||
assert.equal(turn.content[0]?.type, "text");
|
||||
assert.match(
|
||||
(turn.content[0] as { type: "text"; text: string }).text,
|
||||
/Earlier Telegram messages arrived after an aborted turn/,
|
||||
);
|
||||
assert.deepEqual(turn.content[1], {
|
||||
type: "image",
|
||||
data: Buffer.from([1, 2, 3]).toString("base64"),
|
||||
mimeType: "image/png",
|
||||
});
|
||||
});
|
||||
|
||||
test("getTelegramVoiceReplyMode returns default when no config provided", () => {
|
||||
assert.equal(getTelegramVoiceReplyMode(), "manual");
|
||||
assert.equal(getTelegramVoiceReplyMode(undefined), "manual");
|
||||
});
|
||||
|
||||
test("getTelegramVoiceReplyMode reads frozen config with valid replyMode", () => {
|
||||
const frozenConfig = Object.freeze({
|
||||
voice: { replyMode: "always" as const },
|
||||
});
|
||||
assert.equal(getTelegramVoiceReplyMode(frozenConfig), "always");
|
||||
});
|
||||
|
||||
test("getTelegramVoiceReplyMode ignores frozen config with invalid replyMode", () => {
|
||||
const frozenConfig = Object.freeze({
|
||||
voice: { replyMode: "bad-mode" as any },
|
||||
});
|
||||
assert.equal(getTelegramVoiceReplyMode(frozenConfig), "manual");
|
||||
});
|
||||
|
||||
test("getTelegramVoiceReplyMode reads config voice.replyMode", () => {
|
||||
assert.equal(
|
||||
getTelegramVoiceReplyMode({ voice: { replyMode: "mirror" } }),
|
||||
"mirror",
|
||||
);
|
||||
assert.equal(
|
||||
getTelegramVoiceReplyMode({ voice: { replyMode: "always" } }),
|
||||
"always",
|
||||
);
|
||||
assert.equal(
|
||||
getTelegramVoiceReplyMode({ voice: { replyMode: "manual" } }),
|
||||
"manual",
|
||||
);
|
||||
});
|
||||
|
||||
test("getTelegramVoiceReplyMode falls back to manual for invalid or missing config", () => {
|
||||
assert.equal(
|
||||
getTelegramVoiceReplyMode({ voice: { replyMode: "invalid" as any } }),
|
||||
"manual",
|
||||
);
|
||||
assert.equal(getTelegramVoiceReplyMode({ voice: {} }), "manual");
|
||||
assert.equal(getTelegramVoiceReplyMode({}), "manual");
|
||||
assert.equal(getTelegramVoiceReplyMode(null as any), "manual");
|
||||
assert.equal(getTelegramVoiceReplyMode(undefined), "manual");
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* Tests for the voice domain.
|
||||
* Covers policy resolution, turn tagging, suppression helpers, the provider registry,
|
||||
* and voice-specific markup parsing.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { afterEach, beforeEach, test } from "node:test";
|
||||
|
||||
import {
|
||||
clearTelegramVoiceTranscriptionProviders,
|
||||
computeVoiceTurnFlags,
|
||||
getTelegramVoiceReplyMode,
|
||||
getTelegramVoiceTranscriptionProviders,
|
||||
isVoiceTurn,
|
||||
registerTelegramVoiceTranscriptionProvider,
|
||||
shouldSuppressPreviewForVoice,
|
||||
} from "../lib/voice.ts";
|
||||
|
||||
import {
|
||||
clearTelegramVoiceSynthesisProviders,
|
||||
getTelegramVoiceSynthesisProviders,
|
||||
hasTelegramVoiceSynthesisProvider,
|
||||
planTelegramVoiceReply,
|
||||
registerTelegramVoiceSynthesisProvider,
|
||||
stripTelegramCommentMarkupForPreview,
|
||||
} from "../lib/outbound.ts";
|
||||
|
||||
// --- Test Setup ---
|
||||
|
||||
beforeEach(() => {
|
||||
clearTelegramVoiceSynthesisProviders();
|
||||
clearTelegramVoiceTranscriptionProviders();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearTelegramVoiceSynthesisProviders();
|
||||
clearTelegramVoiceTranscriptionProviders();
|
||||
});
|
||||
|
||||
// --- Policy Resolution ---
|
||||
|
||||
test("getTelegramVoiceReplyMode returns 'manual' by default", () => {
|
||||
assert.equal(getTelegramVoiceReplyMode(), "manual");
|
||||
assert.equal(getTelegramVoiceReplyMode(undefined), "manual");
|
||||
assert.equal(getTelegramVoiceReplyMode({}), "manual");
|
||||
assert.equal(getTelegramVoiceReplyMode({ voice: {} }), "manual");
|
||||
});
|
||||
|
||||
test("getTelegramVoiceReplyMode reads valid mode from config", () => {
|
||||
assert.equal(
|
||||
getTelegramVoiceReplyMode({ voice: { replyMode: "mirror" } }),
|
||||
"mirror",
|
||||
);
|
||||
assert.equal(
|
||||
getTelegramVoiceReplyMode({ voice: { replyMode: "always" } }),
|
||||
"always",
|
||||
);
|
||||
assert.equal(
|
||||
getTelegramVoiceReplyMode({ voice: { replyMode: "manual" } }),
|
||||
"manual",
|
||||
);
|
||||
});
|
||||
|
||||
test("getTelegramVoiceReplyMode ignores invalid config values", () => {
|
||||
assert.equal(
|
||||
getTelegramVoiceReplyMode({ voice: { replyMode: "invalid" as any } }),
|
||||
"manual",
|
||||
);
|
||||
assert.equal(
|
||||
getTelegramVoiceReplyMode({ voice: { replyMode: "foo" as any } }),
|
||||
"manual",
|
||||
);
|
||||
});
|
||||
|
||||
test("getTelegramVoiceReplyMode ignores provider policy without config", () => {
|
||||
registerTelegramVoiceSynthesisProvider(
|
||||
{
|
||||
getVoicePolicy: () => ({ replyMode: "always" }),
|
||||
} as any,
|
||||
{ id: "test-provider-1" },
|
||||
);
|
||||
|
||||
assert.equal(getTelegramVoiceReplyMode({}), "manual");
|
||||
});
|
||||
|
||||
test("getTelegramVoiceReplyMode reads config even when provider returns invalid policy", () => {
|
||||
registerTelegramVoiceSynthesisProvider(
|
||||
{
|
||||
getVoicePolicy: () => ({ replyMode: "invalid" as any }),
|
||||
} as any,
|
||||
{ id: "bad-provider" },
|
||||
);
|
||||
|
||||
const result = getTelegramVoiceReplyMode({ voice: { replyMode: "mirror" } });
|
||||
assert.equal(result, "mirror");
|
||||
});
|
||||
|
||||
test("getTelegramVoiceReplyMode defaults to manual despite provider policies", () => {
|
||||
registerTelegramVoiceSynthesisProvider(
|
||||
{
|
||||
getVoicePolicy: () => ({ replyMode: "mirror" }),
|
||||
} as any,
|
||||
{ id: "mirror-provider" },
|
||||
);
|
||||
registerTelegramVoiceSynthesisProvider(
|
||||
{
|
||||
getVoicePolicy: () => ({ replyMode: "always" }),
|
||||
} as any,
|
||||
{ id: "always-provider" },
|
||||
);
|
||||
|
||||
assert.equal(getTelegramVoiceReplyMode(), "manual");
|
||||
});
|
||||
|
||||
// --- Turn Tagging Helpers ---
|
||||
|
||||
test("computeVoiceTurnFlags works for all modes", () => {
|
||||
assert.deepEqual(computeVoiceTurnFlags("mirror", true), {
|
||||
voiceReplyPreferred: true,
|
||||
voiceReplyRequired: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(computeVoiceTurnFlags("mirror", false), {
|
||||
voiceReplyPreferred: false,
|
||||
voiceReplyRequired: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(computeVoiceTurnFlags("always", false), {
|
||||
voiceReplyPreferred: false,
|
||||
voiceReplyRequired: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(computeVoiceTurnFlags("manual", true), {
|
||||
voiceReplyPreferred: false,
|
||||
voiceReplyRequired: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("isVoiceTurn detects voice-tagged turns correctly", () => {
|
||||
assert.equal(isVoiceTurn({ voiceReplyPreferred: true }), true);
|
||||
assert.equal(isVoiceTurn({ voiceReplyRequired: true }), true);
|
||||
assert.equal(
|
||||
isVoiceTurn({ voiceReplyPreferred: true, voiceReplyRequired: true }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isVoiceTurn({ voiceReplyPreferred: false, voiceReplyRequired: false }),
|
||||
false,
|
||||
);
|
||||
assert.equal(isVoiceTurn(null), false);
|
||||
assert.equal(isVoiceTurn(undefined), false);
|
||||
assert.equal(isVoiceTurn({}), false);
|
||||
});
|
||||
|
||||
// --- Preview Suppression ---
|
||||
|
||||
test("shouldSuppressPreviewForVoice works correctly", () => {
|
||||
assert.equal(
|
||||
shouldSuppressPreviewForVoice({ voiceReplyPreferred: true }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldSuppressPreviewForVoice({ voiceReplyRequired: true }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldSuppressPreviewForVoice({
|
||||
voiceReplyPreferred: false,
|
||||
voiceReplyRequired: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(shouldSuppressPreviewForVoice(null), false);
|
||||
assert.equal(shouldSuppressPreviewForVoice(undefined), false);
|
||||
});
|
||||
|
||||
// --- Voice Markup Parsing ---
|
||||
|
||||
test("planTelegramVoiceReply extracts simple voice text", () => {
|
||||
const result = planTelegramVoiceReply(
|
||||
"Hello\n\n<!-- telegram_voice: World -->",
|
||||
);
|
||||
assert.equal(result.voiceText, "World");
|
||||
assert.ok(result.voiceReplies?.length === 1);
|
||||
});
|
||||
|
||||
test("planTelegramVoiceReply extracts lang and rate attributes", () => {
|
||||
const result = planTelegramVoiceReply(
|
||||
'Say\n\n<!-- telegram_voice lang="de" rate="1.2": Hallo -->',
|
||||
);
|
||||
assert.equal(result.lang, "de");
|
||||
assert.equal(result.rate, "1.2");
|
||||
assert.equal(result.voiceText, "Hallo");
|
||||
});
|
||||
|
||||
test("planTelegramVoiceReply handles colon shorthand form", () => {
|
||||
const result = planTelegramVoiceReply(
|
||||
"Text\n\n<!-- telegram_voice: This is the voice text -->",
|
||||
);
|
||||
assert.equal(result.voiceText, "This is the voice text");
|
||||
assert.ok(result.voiceReplies?.length === 1);
|
||||
});
|
||||
|
||||
test("planTelegramVoiceReply handles multiple voice blocks", () => {
|
||||
const result = planTelegramVoiceReply(
|
||||
"First\n\n<!-- telegram_voice: One -->\n\nand second\n\n<!-- telegram_voice: Two -->",
|
||||
);
|
||||
assert.equal(result.voiceReplies?.length, 2);
|
||||
assert.equal(result.voiceText, "One\n\nTwo");
|
||||
assert.ok(result.markdown.includes("First"));
|
||||
assert.ok(result.markdown.includes("and second"));
|
||||
assert.ok(!result.markdown.includes("telegram_voice"));
|
||||
});
|
||||
|
||||
test("planTelegramVoiceReply returns cleaned markdown", () => {
|
||||
const result = planTelegramVoiceReply(
|
||||
"Normal\n\n<!-- telegram_voice: Voice only -->\n\ntext",
|
||||
);
|
||||
assert.ok(result.markdown.includes("Normal"));
|
||||
assert.ok(result.markdown.includes("text"));
|
||||
assert.equal(result.voiceText, "Voice only");
|
||||
assert.ok(!result.markdown.includes("telegram_voice"));
|
||||
});
|
||||
|
||||
// --- Voice Provider Registry ---
|
||||
|
||||
test("Voice synthesis provider registry - basic register / get / has / clear", () => {
|
||||
assert.equal(hasTelegramVoiceSynthesisProvider(), false);
|
||||
assert.equal(getTelegramVoiceSynthesisProviders().length, 0);
|
||||
|
||||
const dispose1 = registerTelegramVoiceSynthesisProvider(
|
||||
() => Promise.resolve("audio.mp3"),
|
||||
{ id: "p1" },
|
||||
);
|
||||
assert.equal(hasTelegramVoiceSynthesisProvider(), true);
|
||||
assert.equal(getTelegramVoiceSynthesisProviders().length, 1);
|
||||
|
||||
const dispose2 = registerTelegramVoiceSynthesisProvider(
|
||||
{
|
||||
getVoicePolicy: () => ({ replyMode: "always" }),
|
||||
getVoicePromptContribution: () => "Be concise.",
|
||||
} as any,
|
||||
{ id: "p2" },
|
||||
);
|
||||
assert.equal(getTelegramVoiceSynthesisProviders().length, 2);
|
||||
|
||||
dispose1();
|
||||
assert.equal(getTelegramVoiceSynthesisProviders().length, 1);
|
||||
|
||||
dispose2();
|
||||
assert.equal(hasTelegramVoiceSynthesisProvider(), false);
|
||||
});
|
||||
|
||||
test("Voice synthesis provider registry accepts both function and object form", () => {
|
||||
// Function form (backward compat)
|
||||
registerTelegramVoiceSynthesisProvider(() => Promise.resolve("audio1"), {
|
||||
id: "fn",
|
||||
});
|
||||
|
||||
// Object form
|
||||
registerTelegramVoiceSynthesisProvider(
|
||||
{
|
||||
getVoicePolicy: () => ({ replyMode: "mirror" }),
|
||||
} as any,
|
||||
{ id: "obj" },
|
||||
);
|
||||
|
||||
const providers = getTelegramVoiceSynthesisProviders();
|
||||
assert.equal(providers.length, 2);
|
||||
assert.equal(typeof providers[0], "function");
|
||||
assert.equal(typeof providers[1], "object");
|
||||
});
|
||||
|
||||
test("Voice synthesis generated ids skip existing registry entries", () => {
|
||||
registerTelegramVoiceSynthesisProvider(() => Promise.resolve("stable.ogg"), {
|
||||
id: "voice-synthesis-provider-0",
|
||||
});
|
||||
registerTelegramVoiceSynthesisProvider(() =>
|
||||
Promise.resolve("generated.ogg"),
|
||||
);
|
||||
assert.equal(getTelegramVoiceSynthesisProviders().length, 2);
|
||||
});
|
||||
|
||||
test("Voice synthesis stable id stale disposer does not delete replacement", () => {
|
||||
const disposeFirst = registerTelegramVoiceSynthesisProvider(
|
||||
() => Promise.resolve("first.ogg"),
|
||||
{ id: "stable" },
|
||||
);
|
||||
registerTelegramVoiceSynthesisProvider(() => Promise.resolve("second.ogg"), {
|
||||
id: "stable",
|
||||
});
|
||||
disposeFirst();
|
||||
assert.equal(getTelegramVoiceSynthesisProviders().length, 1);
|
||||
});
|
||||
|
||||
test("Voice synthesis fixture re-registers cleanly across lifecycle boundaries", () => {
|
||||
const registerFixture = (generation: string) =>
|
||||
registerTelegramVoiceSynthesisProvider(
|
||||
() => Promise.resolve(`${generation}.ogg`),
|
||||
{ id: "fixture-tts" },
|
||||
);
|
||||
const disposeSessionStart = registerFixture("session-start");
|
||||
const disposeResume = registerFixture("resume");
|
||||
const disposeReload = registerFixture("reload");
|
||||
|
||||
assert.equal(getTelegramVoiceSynthesisProviders().length, 1);
|
||||
disposeSessionStart();
|
||||
disposeResume();
|
||||
assert.equal(getTelegramVoiceSynthesisProviders().length, 1);
|
||||
disposeReload();
|
||||
assert.equal(getTelegramVoiceSynthesisProviders().length, 0);
|
||||
});
|
||||
|
||||
test("Voice synthesis generated ids do not collide after disposal", () => {
|
||||
const disposeFirst = registerTelegramVoiceSynthesisProvider(() =>
|
||||
Promise.resolve("first.ogg"),
|
||||
);
|
||||
registerTelegramVoiceSynthesisProvider(() => Promise.resolve("second.ogg"));
|
||||
disposeFirst();
|
||||
registerTelegramVoiceSynthesisProvider(() => Promise.resolve("third.ogg"));
|
||||
assert.equal(getTelegramVoiceSynthesisProviders().length, 2);
|
||||
});
|
||||
|
||||
test("Voice transcription generated ids skip existing registry entries", () => {
|
||||
registerTelegramVoiceTranscriptionProvider(() => Promise.resolve("stable"), {
|
||||
id: "voice-transcription-provider-0",
|
||||
});
|
||||
registerTelegramVoiceTranscriptionProvider(() =>
|
||||
Promise.resolve("generated"),
|
||||
);
|
||||
assert.equal(getTelegramVoiceTranscriptionProviders().length, 2);
|
||||
});
|
||||
|
||||
test("Voice transcription stable id stale disposer does not delete replacement", () => {
|
||||
const disposeFirst = registerTelegramVoiceTranscriptionProvider(
|
||||
() => Promise.resolve("first"),
|
||||
{ id: "stable" },
|
||||
);
|
||||
registerTelegramVoiceTranscriptionProvider(() => Promise.resolve("second"), {
|
||||
id: "stable",
|
||||
});
|
||||
disposeFirst();
|
||||
assert.equal(getTelegramVoiceTranscriptionProviders().length, 1);
|
||||
});
|
||||
|
||||
test("Voice transcription fixture re-registers cleanly across lifecycle boundaries", () => {
|
||||
const registerFixture = (generation: string) =>
|
||||
registerTelegramVoiceTranscriptionProvider(
|
||||
() => Promise.resolve(generation),
|
||||
{ id: "fixture-stt" },
|
||||
);
|
||||
const disposeSessionStart = registerFixture("session-start");
|
||||
const disposeResume = registerFixture("resume");
|
||||
const disposeReload = registerFixture("reload");
|
||||
|
||||
assert.equal(getTelegramVoiceTranscriptionProviders().length, 1);
|
||||
disposeSessionStart();
|
||||
disposeResume();
|
||||
assert.equal(getTelegramVoiceTranscriptionProviders().length, 1);
|
||||
disposeReload();
|
||||
assert.equal(getTelegramVoiceTranscriptionProviders().length, 0);
|
||||
});
|
||||
|
||||
test("Voice transcription generated ids do not collide after disposal", () => {
|
||||
const disposeFirst = registerTelegramVoiceTranscriptionProvider(() =>
|
||||
Promise.resolve("first"),
|
||||
);
|
||||
registerTelegramVoiceTranscriptionProvider(() => Promise.resolve("second"));
|
||||
disposeFirst();
|
||||
registerTelegramVoiceTranscriptionProvider(() => Promise.resolve("third"));
|
||||
assert.equal(getTelegramVoiceTranscriptionProviders().length, 2);
|
||||
});
|
||||
|
||||
test("Voice synthesis provider registry clear works reliably for tests", () => {
|
||||
registerTelegramVoiceSynthesisProvider(() => Promise.resolve("x"), {
|
||||
id: "tmp",
|
||||
});
|
||||
assert.equal(hasTelegramVoiceSynthesisProvider(), true);
|
||||
|
||||
clearTelegramVoiceSynthesisProviders();
|
||||
assert.equal(hasTelegramVoiceSynthesisProvider(), false);
|
||||
});
|
||||
|
||||
// --- Stripping And Generic Parser Interaction ---
|
||||
|
||||
test("stripTelegramCommentMarkupForPreview removes voice blocks and normalizes whitespace", () => {
|
||||
const input = "Hello\n\n<!-- telegram_voice: World -->\n\nWorld";
|
||||
const result = stripTelegramCommentMarkupForPreview(input);
|
||||
assert.ok(!result.includes("telegram_voice"));
|
||||
assert.ok(!result.includes("\n\n\n"));
|
||||
});
|
||||
|
||||
test("planTelegramVoiceReply works with the original generic parsers (fence + comment)", () => {
|
||||
const input = "Text\n```\ncode\n```\n<!-- telegram_voice: Spoken -->";
|
||||
const result = planTelegramVoiceReply(input);
|
||||
assert.equal(result.voiceText, "Spoken");
|
||||
assert.ok(result.markdown.includes("Text"));
|
||||
assert.ok(result.markdown.includes("code"));
|
||||
});
|
||||
Reference in new issue
Block a user