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)
574 lines
18 KiB
TypeScript
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",
|
|
]);
|
|
});
|