chore: fork @llblab/[email protected].0 as @evandrodevbr/oh-my-pi-telegram
Release / release (push) Failing after 5s
Validate / validate (push) Failing after 5m21s

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:
Evandro Fonseca Junior committed 2026-07-15 14:18:16 -03:00
commit d1d04001d0
155 files changed
+107848

No files matched your search

+546
View File
@@ -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, []);
});
+573
View File
@@ -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",
]);
});
+353
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+352
View File
@@ -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
+920
View File
@@ -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",
]);
});
+752
View File
@@ -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"]);
});
+734
View File
@@ -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, []);
});
+126
View File
@@ -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
+370
View File
@@ -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);
});
+562
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+310
View File
@@ -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 });
}
});
+452
View File
@@ -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);
});
+263
View File
@@ -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",
]);
});
+222
View File
@@ -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 } });
});
+174
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+486
View File
@@ -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\)/);
});
+833
View File
@@ -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)`,
]);
});
+125
View File
@@ -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."]);
});
+82
View File
@@ -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%");
});
+137
View File
@@ -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
+182
View File
@@ -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],
);
});
+83
View File
@@ -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",
);
});
+159
View File
@@ -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"]);
});
+920
View File
@@ -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"]);
});
+472
View File
@@ -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)}`]);
});
+736
View File
@@ -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);
});
+129
View File
@@ -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,
);
});
+206
View File
@@ -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" });
});
+112
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+664
View File
@@ -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"), "&lt;tag&gt;&amp;value");
assert.equal(
escapeHtmlAttribute(`"quoted" 'value' & <tag>`),
"&quot;quoted&quot; &#39;value&#39; &amp; &lt;tag&gt;",
);
});
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\/&quot;quoted&quot;">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,
);
}
});
+919
View File
@@ -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
+516
View File
@@ -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);
});
+670
View File
@@ -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"));
});
+397
View File
@@ -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);
});
+1141
View File
File diff suppressed because it is too large. Load diff
+1208
View File
File diff suppressed because it is too large. Load diff
+58
View File
@@ -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
+329
View File
@@ -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
+104
View File
@@ -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",
);
});
+951
View File
@@ -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
+400
View File
@@ -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"));
});