Files
oh-my-pi-telegram/tests/bindings.test.ts
Evandro Fonseca Junior d1d04001d0
Release / release (push) Failing after 5s
Validate / validate (push) Failing after 5m21s
chore: fork @llblab/[email protected] as @evandrodevbr/oh-my-pi-telegram
Fork of upstream llblab/[email protected].0 (SHA afe09c5) repackaged as a
drop-in extension for can1357/oh-my-pi (omp >= 16.5.2).

Changes vs upstream:
- Rename package: @llblab/pi-telegram -> @evandrodevbr/oh-my-pi-telegram
- Bump version: 0.22.0 -> 0.22.0-evandro.1
- Update repository/homepage/bugs URLs to evandrodevbr
- Add omp field in package.json (displayName + compatibleWith)
- README: prepend 'Oh My Pi (omp) Compatibility' section with install
  commands, verified-components table, upstream lineage, and rationale
- CHANGELOG: add '0.22.0-evandro.1' entry under Unreleased
- docs/: rename @llblab/pi-telegram imports to @evandrodevbr/oh-my-pi-telegram
- tests/public-api.test.ts: rename package import paths

Gates re-run on the fork:
- npm run typecheck  -> tsc --noEmit (success)
- npm test           -> 1229 pass / 0 fail / 1 platform-only skip
- npm run audit      -> found 0 vulnerabilities
- npm run pack:check -> evandrodevbr-oh-my-pi-telegram-0.22.0-evandro.1.tgz

Verified:
- omp install --link ./pi-telegram -> Linked OK
- omp plugin list -> @evandrodevbr/[email protected].0-evandro.1

Upstream lineage: badlogic/pi-telegram -> llblab/pi-telegram (0.22.0) -> evandrodevbr/oh-my-pi-telegram (this fork)
2026-07-15 14:18:16 -03:00

574 lines
18 KiB
TypeScript

/**
* 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",
]);
});