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)
2783 lines
88 KiB
TypeScript
2783 lines
88 KiB
TypeScript
/**
|
|
* Cross-domain integration tests for the Telegram extension
|
|
* Exercises extension-level polling, queue/lifecycle wiring, previews, reactions, compaction, and model switching
|
|
*/
|
|
|
|
import assert from "node:assert/strict";
|
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import testRoot, { mock, type TestContext } from "node:test";
|
|
|
|
import { registerTelegramActivityHandler } from "../api/activity.ts";
|
|
import * as BusApi from "../lib/bus-api.ts";
|
|
import * as BusLeader from "../lib/bus-leader.ts";
|
|
import * as Bus from "../lib/bus.ts";
|
|
import * as Delivery from "../lib/delivery.ts";
|
|
import type { TelegramBridgeApiRuntime } from "../lib/telegram-api.ts";
|
|
|
|
type RuntimeTestHandler = (context: TestContext) => void | Promise<void>;
|
|
type RuntimeTelegramExtension = (typeof import("../index.ts"))["default"];
|
|
|
|
function test(name: string, fn: RuntimeTestHandler): void {
|
|
void testRoot(name, { concurrency: false, timeout: 5000 }, fn);
|
|
}
|
|
|
|
let runtimeTelegramExtension: RuntimeTelegramExtension | undefined;
|
|
let runtimeAgentDir: string | undefined;
|
|
|
|
async function ensureRuntimeAgentDir(): Promise<string> {
|
|
if (!runtimeAgentDir) {
|
|
runtimeAgentDir = await mkdtemp(
|
|
join(tmpdir(), "pi-telegram-runtime-agent-"),
|
|
);
|
|
process.env.PI_CODING_AGENT_DIR = runtimeAgentDir;
|
|
}
|
|
return runtimeAgentDir;
|
|
}
|
|
|
|
async function getRuntimeTelegramExtension(): Promise<RuntimeTelegramExtension> {
|
|
if (runtimeTelegramExtension) return runtimeTelegramExtension;
|
|
await ensureRuntimeAgentDir();
|
|
runtimeTelegramExtension = (await import("../index.ts")).default;
|
|
return runtimeTelegramExtension;
|
|
}
|
|
|
|
async function flushMicrotasks(iterations = 10): Promise<void> {
|
|
for (let i = 0; i < iterations; i++) {
|
|
await Promise.resolve();
|
|
}
|
|
}
|
|
|
|
async function waitForEventLoopCondition(
|
|
predicate: () => boolean,
|
|
iterations = 100,
|
|
): Promise<void> {
|
|
for (let i = 0; i < iterations; i++) {
|
|
if (predicate()) return;
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
}
|
|
throw new Error("Timed out waiting for event-loop condition");
|
|
}
|
|
|
|
async function waitForCondition(
|
|
predicate: () => boolean,
|
|
timeoutMs = 2000,
|
|
): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
if (predicate()) return;
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
}
|
|
throw new Error("Timed out waiting for condition");
|
|
}
|
|
|
|
function parseJsonRequestBody(
|
|
init: RequestInit | undefined,
|
|
): Record<string, unknown> | undefined {
|
|
if (typeof init?.body !== "string") return undefined;
|
|
return JSON.parse(init.body) as Record<string, unknown>;
|
|
}
|
|
|
|
function getRuntimeTelegramApiMethod(input: string | URL | Request): string {
|
|
const url = typeof input === "string" ? input : input.toString();
|
|
return url.split("/").at(-1) ?? "";
|
|
}
|
|
|
|
function getRuntimeTelegramApiText(
|
|
body: Record<string, unknown> | undefined,
|
|
): string {
|
|
const richMessage = body?.rich_message as
|
|
| { html?: string; markdown?: string }
|
|
| undefined;
|
|
return String(body?.text ?? richMessage?.html ?? richMessage?.markdown ?? "");
|
|
}
|
|
|
|
function setRuntimeTestFetch(fetchImpl: typeof fetch): () => void {
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = fetchImpl;
|
|
return () => {
|
|
globalThis.fetch = originalFetch;
|
|
};
|
|
}
|
|
|
|
async function createRuntimeTelegramConfigFixture() {
|
|
const agentDir = await ensureRuntimeAgentDir();
|
|
const configPath = join(agentDir, "telegram.json");
|
|
const previousConfig = await readFile(configPath, "utf8").catch(
|
|
() => undefined,
|
|
);
|
|
const isolated = process.env.PI_CODING_AGENT_DIR === agentDir;
|
|
return {
|
|
write: async (config: Record<string, unknown>) => {
|
|
await mkdir(agentDir, { recursive: true });
|
|
await writeFile(
|
|
configPath,
|
|
JSON.stringify(config, null, "\t") + "\n",
|
|
"utf8",
|
|
);
|
|
},
|
|
restore: async () => {
|
|
if (isolated) return;
|
|
if (previousConfig === undefined) {
|
|
await rm(configPath, { force: true });
|
|
return;
|
|
}
|
|
await writeFile(configPath, previousConfig, "utf8");
|
|
},
|
|
};
|
|
}
|
|
|
|
async function writeRuntimeTelegramLocks(
|
|
locks: Record<string, unknown>,
|
|
): Promise<void> {
|
|
const agentDir = await ensureRuntimeAgentDir();
|
|
await mkdir(agentDir, { recursive: true });
|
|
await writeFile(
|
|
join(agentDir, "locks.json"),
|
|
JSON.stringify(locks, null, "\t") + "\n",
|
|
"utf8",
|
|
);
|
|
}
|
|
|
|
function createRuntimeDeferredResponse() {
|
|
let resolve: (value: Response) => void = () => {};
|
|
const promise = new Promise<Response>((promiseResolve) => {
|
|
resolve = promiseResolve;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
function createRuntimeTelegramApiResponse(result: unknown): Response {
|
|
return { json: async () => ({ ok: true, result }) } as Response;
|
|
}
|
|
|
|
function createRuntimeTelegramApiErrorResponse(
|
|
status: number,
|
|
description: string,
|
|
): Response {
|
|
return {
|
|
ok: false,
|
|
status,
|
|
headers: new Headers({ "retry-after": "0" }),
|
|
text: async () => JSON.stringify({ ok: false, description }),
|
|
} as Response;
|
|
}
|
|
|
|
function createRuntimeExtensionContext(
|
|
overrides: Record<string, unknown> = {},
|
|
) {
|
|
return {
|
|
hasUI: true,
|
|
cwd: process.cwd(),
|
|
model: undefined,
|
|
signal: undefined,
|
|
ui: {
|
|
theme: {
|
|
fg: (_token: string, text: string) => text,
|
|
},
|
|
setStatus: () => {},
|
|
notify: () => {},
|
|
},
|
|
isIdle: () => true,
|
|
hasPendingMessages: () => false,
|
|
abort: () => {},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
type RuntimeModelFixture = {
|
|
provider: string;
|
|
id: string;
|
|
reasoning?: boolean;
|
|
};
|
|
|
|
function createRuntimeModel(
|
|
provider: string,
|
|
id: string,
|
|
reasoning?: boolean,
|
|
): RuntimeModelFixture {
|
|
return reasoning === undefined
|
|
? { provider, id }
|
|
: { provider, id, reasoning };
|
|
}
|
|
|
|
type RuntimeModelContextOptions = {
|
|
model?: RuntimeModelFixture;
|
|
availableModels: RuntimeModelFixture[];
|
|
isIdle?: () => boolean;
|
|
abort?: () => void;
|
|
setStatus?: (slot: string, text: string) => void;
|
|
};
|
|
|
|
function createRuntimeModelContext(options: RuntimeModelContextOptions) {
|
|
return createRuntimeExtensionContext({
|
|
cwd: process.cwd(),
|
|
model: options.model,
|
|
ui: {
|
|
theme: {
|
|
fg: (_token: string, text: string) => text,
|
|
},
|
|
setStatus: options.setStatus ?? (() => {}),
|
|
notify: () => {},
|
|
},
|
|
sessionManager: {
|
|
getEntries: () => [],
|
|
},
|
|
modelRegistry: {
|
|
refresh: () => {},
|
|
getAvailable: () => options.availableModels,
|
|
isUsingOAuth: () => false,
|
|
},
|
|
getContextUsage: () => undefined,
|
|
isIdle: options.isIdle ?? (() => true),
|
|
abort: options.abort ?? (() => {}),
|
|
});
|
|
}
|
|
|
|
type RuntimeHarnessTextBlock = { type: string; text?: string };
|
|
type RuntimeHarnessMessage = string | RuntimeHarnessTextBlock[];
|
|
|
|
function getRuntimeHarnessTextBlock(
|
|
content: RuntimeHarnessMessage | undefined,
|
|
): RuntimeHarnessTextBlock {
|
|
assert.equal(Array.isArray(content), true);
|
|
if (!Array.isArray(content)) throw new Error("Expected text-block message");
|
|
return content[0] ?? { type: "" };
|
|
}
|
|
|
|
function getRuntimeHarnessMessageText(content: RuntimeHarnessMessage): string {
|
|
if (typeof content === "string") return content;
|
|
return getRuntimeHarnessTextBlock(content).text ?? "";
|
|
}
|
|
|
|
function recordRuntimeDispatchEvent(
|
|
events: string[],
|
|
content: RuntimeHarnessMessage,
|
|
): void {
|
|
events.push(`dispatch:${getRuntimeHarnessMessageText(content)}`);
|
|
}
|
|
|
|
type RuntimeHarnessHandler = (event: unknown, ctx: unknown) => Promise<unknown>;
|
|
type RuntimeHarnessCommand = {
|
|
handler: (args: string, ctx: unknown) => Promise<void>;
|
|
};
|
|
type RuntimePiHarnessOptions = {
|
|
sendUserMessage?: (content: RuntimeHarnessMessage) => void;
|
|
getThinkingLevel?: () => string;
|
|
setModel?: (model: { provider: string; id: string }) => Promise<boolean>;
|
|
setThinkingLevel?: (level: string) => void;
|
|
getCommands?: () => unknown[];
|
|
};
|
|
|
|
function createRuntimePiHarness(options: RuntimePiHarnessOptions = {}) {
|
|
const handlers = new Map<string, RuntimeHarnessHandler>();
|
|
const commands = new Map<string, RuntimeHarnessCommand>();
|
|
const pi = {
|
|
on: (event: string, handler: RuntimeHarnessHandler) => {
|
|
handlers.set(event, handler);
|
|
},
|
|
registerCommand: (name: string, definition: RuntimeHarnessCommand) => {
|
|
commands.set(name, definition);
|
|
},
|
|
registerTool: () => {},
|
|
sendUserMessage: options.sendUserMessage ?? (() => {}),
|
|
getCommands: options.getCommands ?? (() => []),
|
|
getThinkingLevel: options.getThinkingLevel ?? (() => "medium"),
|
|
...(options.setModel ? { setModel: options.setModel } : {}),
|
|
...(options.setThinkingLevel
|
|
? { setThinkingLevel: options.setThinkingLevel }
|
|
: {}),
|
|
};
|
|
return { handlers, commands, pi: pi as never };
|
|
}
|
|
|
|
test("Public activity delivery reaches the classic instance without blocking agent start", async () => {
|
|
const telegramConfig = await createRuntimeTelegramConfigFixture();
|
|
const { handlers, commands, pi } = createRuntimePiHarness();
|
|
const activitySend = createRuntimeDeferredResponse();
|
|
const sentBodies: Array<Record<string, unknown>> = [];
|
|
let activityHandledCount = 0;
|
|
let unregisterActivity: (() => void) | undefined;
|
|
const restoreFetch = setRuntimeTestFetch(async (input, init) => {
|
|
const method = getRuntimeTelegramApiMethod(input);
|
|
const body = parseJsonRequestBody(init);
|
|
if (method === "deleteWebhook" || method === "setMyCommands") {
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
if (method === "getUpdates") {
|
|
return await new Promise<Response>((_resolve, reject) => {
|
|
init?.signal?.addEventListener("abort", () => {
|
|
reject(new DOMException("stop", "AbortError"));
|
|
});
|
|
});
|
|
}
|
|
if (method === "sendChatAction") {
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
if (method === "sendMessage") {
|
|
sentBodies.push(body ?? {});
|
|
if (body?.text === "Activity from local") return activitySend.promise;
|
|
return createRuntimeTelegramApiResponse({ message_id: 90 });
|
|
}
|
|
throw new Error(`Unexpected Telegram API method: ${method}`);
|
|
});
|
|
try {
|
|
await telegramConfig.write({
|
|
botToken: "123:abc",
|
|
botId: 123,
|
|
botUsername: "test_bot",
|
|
allowedUserId: 77,
|
|
lastUpdateId: 0,
|
|
});
|
|
await writeRuntimeTelegramLocks({});
|
|
(await getRuntimeTelegramExtension())(pi);
|
|
const ctx = createRuntimeExtensionContext();
|
|
await handlers.get("session_start")?.({}, ctx);
|
|
await commands.get("telegram-connect")?.handler("", ctx);
|
|
unregisterActivity = registerTelegramActivityHandler({
|
|
id: "integration-classic-activity",
|
|
handle: async (event, activityCtx) => {
|
|
if (event.type !== "agent-start") return;
|
|
await activityCtx.send({ text: "Activity from local" });
|
|
activityHandledCount += 1;
|
|
},
|
|
});
|
|
|
|
await handlers.get("input")?.({ source: "interactive" }, ctx);
|
|
await handlers.get("agent_start")?.({}, ctx);
|
|
await waitForEventLoopCondition(() =>
|
|
sentBodies.some((body) => body.text === "Activity from local"),
|
|
);
|
|
assert.equal(
|
|
activityHandledCount,
|
|
0,
|
|
"agent_start must not await extension-owned activity delivery",
|
|
);
|
|
const activityBody = sentBodies.find(
|
|
(body) => body.text === "Activity from local",
|
|
);
|
|
assert.equal(activityBody?.chat_id, 77);
|
|
assert.equal(activityBody?.message_thread_id, undefined);
|
|
|
|
activitySend.resolve(
|
|
createRuntimeTelegramApiResponse({ message_id: 91 }),
|
|
);
|
|
await waitForEventLoopCondition(() => activityHandledCount === 1);
|
|
await handlers.get("agent_settled")?.({}, ctx);
|
|
await handlers.get("session_shutdown")?.({}, ctx);
|
|
|
|
await handlers.get("session_start")?.({}, ctx);
|
|
await handlers.get("input")?.({ source: "interactive" }, ctx);
|
|
await handlers.get("agent_start")?.({}, ctx);
|
|
await waitForEventLoopCondition(() => activityHandledCount === 2);
|
|
await handlers.get("agent_settled")?.({}, ctx);
|
|
await handlers.get("session_shutdown")?.({}, ctx);
|
|
} finally {
|
|
unregisterActivity?.();
|
|
restoreFetch();
|
|
await telegramConfig.restore();
|
|
}
|
|
});
|
|
|
|
test("Follower aggregate delivery crosses the authorized leader transport", async () => {
|
|
const follower = {
|
|
instanceId: "follower-one",
|
|
connectedAtMs: 1,
|
|
lastHeartbeatMs: 1,
|
|
target: { chatId: 77, threadId: 12 },
|
|
};
|
|
const directBodies: Array<Record<string, unknown>> = [];
|
|
const leaderProxy = BusLeader.createTelegramBusLeaderApiProxy({
|
|
async call(method, body) {
|
|
assert.equal(method, "sendMessage");
|
|
directBodies.push(body);
|
|
return { message_id: 301 };
|
|
},
|
|
async callMultipart() {
|
|
throw new Error("Multipart transport is not expected");
|
|
},
|
|
async downloadFile() {
|
|
throw new Error("Download transport is not expected");
|
|
},
|
|
});
|
|
const busAwareApi = BusApi.createTelegramBusAwareApiRuntime({
|
|
directRuntime: {} as TelegramBridgeApiRuntime,
|
|
ownsDirect: () => false,
|
|
getDefaultTarget: () => follower.target,
|
|
async callFollowerApi(method, args) {
|
|
assert.equal(
|
|
Bus.isTelegramFollowerApiCallAllowed({ follower, method, args }),
|
|
true,
|
|
);
|
|
return leaderProxy(method, args);
|
|
},
|
|
});
|
|
const runtime = Delivery.createTelegramBridgeDeliveryRuntime({
|
|
generation: "follower-generation",
|
|
getTargetPolicyView: () => ({
|
|
canDeliver: true,
|
|
ownsDirect: false,
|
|
allowedChatId: 77,
|
|
followerTarget: follower.target,
|
|
}),
|
|
getActiveTurnTarget: () => follower.target,
|
|
api: busAwareApi,
|
|
recordOwnership() {},
|
|
});
|
|
|
|
const result = await runtime.sendView(
|
|
{ text: "Aggregate activity" },
|
|
{ scope: { kind: "aggregate" } },
|
|
);
|
|
|
|
assert.equal(result.ok, true);
|
|
assert.deepEqual(directBodies, [
|
|
{ chat_id: 77, text: "Aggregate activity" },
|
|
]);
|
|
});
|
|
|
|
test("Extension runtime polls, pairs, and dispatches an inbound Telegram turn into pi", async () => {
|
|
const telegramConfig = await createRuntimeTelegramConfigFixture();
|
|
const sentMessages: RuntimeHarnessMessage[] = [];
|
|
let resolveDispatch: ((value: RuntimeHarnessMessage) => void) | undefined;
|
|
const dispatched = new Promise<RuntimeHarnessMessage>((resolve) => {
|
|
resolveDispatch = resolve;
|
|
});
|
|
const { handlers, commands, pi } = createRuntimePiHarness({
|
|
sendUserMessage: (content) => {
|
|
sentMessages.push(content);
|
|
resolveDispatch?.(content);
|
|
},
|
|
});
|
|
let getUpdatesCalls = 0;
|
|
let sendMessageCalls = 0;
|
|
const apiCalls: string[] = [];
|
|
const restoreFetch = setRuntimeTestFetch(async (input) => {
|
|
const method = getRuntimeTelegramApiMethod(input);
|
|
apiCalls.push(method);
|
|
if (method === "deleteWebhook") {
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
if (method === "getUpdates") {
|
|
getUpdatesCalls += 1;
|
|
if (getUpdatesCalls === 1) {
|
|
return createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 1,
|
|
message: {
|
|
message_id: 42,
|
|
chat: { id: 99, type: "private" },
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
text: "hello from telegram",
|
|
},
|
|
},
|
|
]);
|
|
}
|
|
throw new DOMException("stop", "AbortError");
|
|
}
|
|
if (method === "sendMessage" || method === "sendRichMessage") {
|
|
sendMessageCalls += 1;
|
|
if (sendMessageCalls === 1) {
|
|
return createRuntimeTelegramApiErrorResponse(
|
|
429,
|
|
"temporary rate limit",
|
|
);
|
|
}
|
|
return createRuntimeTelegramApiResponse({ message_id: 100 });
|
|
}
|
|
if (method === "sendChatAction") {
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
throw new Error(`Unexpected Telegram API method: ${method}`);
|
|
});
|
|
try {
|
|
await telegramConfig.write({ botToken: "123:abc", lastUpdateId: 0 });
|
|
(await getRuntimeTelegramExtension())(pi);
|
|
const ctx = createRuntimeExtensionContext();
|
|
await handlers.get("session_start")?.({}, ctx);
|
|
await commands.get("telegram-connect")?.handler("", ctx);
|
|
const dispatchedContent = await dispatched;
|
|
assert.equal(sentMessages.length, 1);
|
|
assert.equal(Array.isArray(dispatchedContent), true);
|
|
assert.equal(apiCalls.includes("sendMessage"), true);
|
|
assert.equal(sendMessageCalls, 2);
|
|
assert.equal(apiCalls.includes("sendChatAction"), true);
|
|
const promptBlock = getRuntimeHarnessTextBlock(dispatchedContent);
|
|
assert.equal(promptBlock.type, "text");
|
|
assert.match(promptBlock.text ?? "", /^\[telegram\] hello from telegram$/);
|
|
await handlers.get("session_shutdown")?.({}, ctx);
|
|
} finally {
|
|
restoreFetch();
|
|
await telegramConfig.restore();
|
|
}
|
|
});
|
|
|
|
test("Extension runtime finalizes queued turn after polling ownership moves away", async () => {
|
|
const telegramConfig = await createRuntimeTelegramConfigFixture();
|
|
let resolveDispatch: (() => void) | undefined;
|
|
const dispatched = new Promise<void>((resolve) => {
|
|
resolveDispatch = resolve;
|
|
});
|
|
const draftTexts: string[] = [];
|
|
const sentTexts: string[] = [];
|
|
const sentBodies: Array<Record<string, unknown>> = [];
|
|
const editedTexts: string[] = [];
|
|
const { handlers, commands, pi } = createRuntimePiHarness({
|
|
sendUserMessage: () => {
|
|
resolveDispatch?.();
|
|
},
|
|
});
|
|
let getUpdatesCalls = 0;
|
|
const restoreFetch = setRuntimeTestFetch(async (input, init) => {
|
|
const method = getRuntimeTelegramApiMethod(input);
|
|
const body = parseJsonRequestBody(init);
|
|
if (method === "deleteWebhook") {
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
if (method === "getUpdates") {
|
|
getUpdatesCalls += 1;
|
|
if (getUpdatesCalls === 1) {
|
|
return createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 1,
|
|
message: {
|
|
message_id: 7,
|
|
chat: { id: 99, type: "private" },
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
text: "please answer",
|
|
},
|
|
},
|
|
]);
|
|
}
|
|
throw new DOMException("stop", "AbortError");
|
|
}
|
|
if (method === "sendMessageDraft") {
|
|
draftTexts.push(String(body?.text ?? ""));
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
if (method === "sendRichMessageDraft") {
|
|
const richMessage = body?.rich_message as
|
|
| { markdown?: string }
|
|
| undefined;
|
|
draftTexts.push(String(richMessage?.markdown ?? ""));
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
if (method === "sendRichMessage") {
|
|
const richMessage = body?.rich_message as
|
|
| { markdown?: string }
|
|
| undefined;
|
|
sentTexts.push(String(richMessage?.markdown ?? ""));
|
|
sentBodies.push(body ?? {});
|
|
return createRuntimeTelegramApiResponse({
|
|
message_id: 100 + sentTexts.length,
|
|
});
|
|
}
|
|
if (method === "sendMessage" || method === "sendRichMessage") {
|
|
sentTexts.push(String(body?.text ?? ""));
|
|
sentBodies.push(body ?? {});
|
|
return createRuntimeTelegramApiResponse({
|
|
message_id: 100 + sentTexts.length,
|
|
});
|
|
}
|
|
if (method === "sendChatAction") {
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
if (method === "editMessageText") {
|
|
editedTexts.push(String(body?.text ?? ""));
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
throw new Error(`Unexpected Telegram API method: ${method}`);
|
|
});
|
|
try {
|
|
mock.timers.enable({ apis: ["setTimeout"] });
|
|
await telegramConfig.write({
|
|
botToken: "123:abc",
|
|
allowedUserId: 77,
|
|
lastUpdateId: 0,
|
|
});
|
|
(await getRuntimeTelegramExtension())(pi);
|
|
const ctx = createRuntimeExtensionContext();
|
|
await handlers.get("session_start")?.({}, ctx);
|
|
await commands.get("telegram-connect")?.handler("", ctx);
|
|
await dispatched;
|
|
await handlers.get("agent_start")?.({}, ctx);
|
|
await writeFile(
|
|
join(await ensureRuntimeAgentDir(), "locks.json"),
|
|
JSON.stringify(
|
|
{
|
|
"@llblab/pi-telegram": {
|
|
pid: process.pid + 1_000_000,
|
|
cwd: "/tmp/other-pi-instance",
|
|
},
|
|
},
|
|
null,
|
|
"\t",
|
|
) + "\n",
|
|
"utf8",
|
|
);
|
|
mock.timers.tick(1100);
|
|
await flushMicrotasks(20);
|
|
await handlers.get("message_update")?.(
|
|
{
|
|
message: {
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "Draft **preview**" }],
|
|
},
|
|
},
|
|
ctx,
|
|
);
|
|
mock.timers.tick(1000);
|
|
await flushMicrotasks(50);
|
|
await handlers.get("agent_end")?.(
|
|
{
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "Final **answer**" }],
|
|
},
|
|
],
|
|
},
|
|
ctx,
|
|
);
|
|
mock.timers.tick(0);
|
|
await flushMicrotasks(20);
|
|
assert.deepEqual(draftTexts, []);
|
|
assert.equal(sentTexts.length, 1);
|
|
assert.match(sentTexts[0] ?? "", /Final \*\*answer\*\*/);
|
|
assert.deepEqual(sentBodies[0]?.reply_parameters, {
|
|
message_id: 7,
|
|
allow_sending_without_reply: true,
|
|
});
|
|
assert.deepEqual(editedTexts, []);
|
|
await handlers.get("session_shutdown")?.({}, ctx);
|
|
} finally {
|
|
mock.timers.reset();
|
|
restoreFetch();
|
|
await telegramConfig.restore();
|
|
}
|
|
});
|
|
|
|
test("Extension runtime keeps local queue progress but fences delivery after ownership moves away", async () => {
|
|
const telegramConfig = await createRuntimeTelegramConfigFixture();
|
|
const sentMessages: RuntimeHarnessMessage[] = [];
|
|
const sentBodies: Array<Record<string, unknown>> = [];
|
|
const { handlers, commands, pi } = createRuntimePiHarness({
|
|
sendUserMessage: (content) => {
|
|
sentMessages.push(content);
|
|
},
|
|
});
|
|
let getUpdatesCalls = 0;
|
|
const restoreFetch = setRuntimeTestFetch(async (input, init) => {
|
|
const method = getRuntimeTelegramApiMethod(input);
|
|
const body = parseJsonRequestBody(init);
|
|
if (method === "deleteWebhook") {
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
if (method === "getUpdates") {
|
|
getUpdatesCalls += 1;
|
|
if (getUpdatesCalls === 1) {
|
|
return createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 1,
|
|
message: {
|
|
message_id: 7,
|
|
chat: { id: 99, type: "private" },
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
text: "first accepted",
|
|
},
|
|
},
|
|
{
|
|
_: "other",
|
|
update_id: 2,
|
|
message: {
|
|
message_id: 8,
|
|
chat: { id: 99, type: "private" },
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
text: "second queued",
|
|
},
|
|
},
|
|
]);
|
|
}
|
|
throw new DOMException("stop", "AbortError");
|
|
}
|
|
if (method === "sendRichMessage") {
|
|
sentBodies.push(body ?? {});
|
|
return createRuntimeTelegramApiResponse({ message_id: 100 });
|
|
}
|
|
if (method === "sendMessage" || method === "sendRichMessage") {
|
|
sentBodies.push(body ?? {});
|
|
return createRuntimeTelegramApiResponse({ message_id: 100 });
|
|
}
|
|
if (method === "sendChatAction") {
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
throw new Error(`Unexpected Telegram API method: ${method}`);
|
|
});
|
|
try {
|
|
await telegramConfig.write({
|
|
botToken: "123:abc",
|
|
allowedUserId: 77,
|
|
lastUpdateId: 0,
|
|
});
|
|
await writeRuntimeTelegramLocks({});
|
|
(await getRuntimeTelegramExtension())(pi);
|
|
const ctx = createRuntimeExtensionContext({
|
|
cwd: "/repo/queue-owner-a",
|
|
});
|
|
await handlers.get("session_start")?.({}, ctx);
|
|
await commands.get("telegram-connect")?.handler("", ctx);
|
|
await waitForCondition(() => sentMessages.length === 1);
|
|
assert.match(
|
|
getRuntimeHarnessMessageText(sentMessages[0] as RuntimeHarnessMessage),
|
|
/^\[telegram\] first accepted$/,
|
|
);
|
|
await handlers.get("agent_start")?.({}, ctx);
|
|
await writeRuntimeTelegramLocks({
|
|
"@llblab/pi-telegram": {
|
|
pid: process.pid + 1_000_000,
|
|
cwd: "/repo/queue-owner-b",
|
|
},
|
|
});
|
|
await handlers.get("agent_end")?.(
|
|
{
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "First **final**" }],
|
|
},
|
|
],
|
|
},
|
|
ctx,
|
|
);
|
|
// Follow-up dispatch is intentionally routed through the session-bound
|
|
// deferred queue timer; wait on real time instead of setImmediate turns.
|
|
await waitForCondition(() => sentMessages.length === 2);
|
|
assert.deepEqual(sentBodies, []);
|
|
assert.match(
|
|
getRuntimeHarnessMessageText(sentMessages[1] as RuntimeHarnessMessage),
|
|
/^\[telegram\] second queued$/,
|
|
);
|
|
await handlers.get("session_shutdown")?.({}, ctx);
|
|
} finally {
|
|
restoreFetch();
|
|
await telegramConfig.restore();
|
|
}
|
|
});
|
|
|
|
test("Extension runtime keeps proactive local result disabled even with Telegram lock ownership", async () => {
|
|
const telegramConfig = await createRuntimeTelegramConfigFixture();
|
|
const sentBodies: Array<Record<string, unknown>> = [];
|
|
const { handlers, commands, pi } = createRuntimePiHarness();
|
|
const restoreFetch = setRuntimeTestFetch(async (input, init) => {
|
|
const method = getRuntimeTelegramApiMethod(input);
|
|
if (method === "deleteWebhook") {
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
if (method === "getUpdates") {
|
|
throw new DOMException("stop", "AbortError");
|
|
}
|
|
if (method === "sendMessage" || method === "sendRichMessage") {
|
|
sentBodies.push(parseJsonRequestBody(init) ?? {});
|
|
return createRuntimeTelegramApiResponse({ message_id: 100 });
|
|
}
|
|
throw new Error(`Unexpected Telegram API method: ${method}`);
|
|
});
|
|
try {
|
|
await telegramConfig.write({
|
|
botToken: "123:abc",
|
|
allowedUserId: 77,
|
|
lastUpdateId: 0,
|
|
proactivePush: false,
|
|
});
|
|
await writeRuntimeTelegramLocks({});
|
|
(await getRuntimeTelegramExtension())(pi);
|
|
const ctx = createRuntimeExtensionContext({
|
|
cwd: "/repo/proactive-disabled-owner",
|
|
});
|
|
await handlers.get("session_start")?.({}, ctx);
|
|
await commands.get("telegram-connect")?.handler("", ctx);
|
|
await flushMicrotasks(20);
|
|
await handlers.get("agent_end")?.(
|
|
{
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "Local **done**" }],
|
|
},
|
|
],
|
|
},
|
|
ctx,
|
|
);
|
|
assert.deepEqual(sentBodies, []);
|
|
await commands.get("telegram-disconnect")?.handler("", ctx);
|
|
await handlers.get("session_shutdown")?.({}, ctx);
|
|
} finally {
|
|
restoreFetch();
|
|
await telegramConfig.restore();
|
|
}
|
|
});
|
|
|
|
test("Extension runtime resolves stale same-cwd lock before proactive local result", async () => {
|
|
const telegramConfig = await createRuntimeTelegramConfigFixture();
|
|
const sentBodies: Array<Record<string, unknown>> = [];
|
|
let getUpdatesCalls = 0;
|
|
const { handlers, pi } = createRuntimePiHarness();
|
|
const restoreFetch = setRuntimeTestFetch(async (input, init) => {
|
|
const method = getRuntimeTelegramApiMethod(input);
|
|
if (method === "deleteWebhook") {
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
if (method === "getUpdates") {
|
|
getUpdatesCalls += 1;
|
|
throw new DOMException("stop", "AbortError");
|
|
}
|
|
if (method === "sendRichMessage") {
|
|
sentBodies.push(parseJsonRequestBody(init) ?? {});
|
|
return createRuntimeTelegramApiResponse({ message_id: 100 });
|
|
}
|
|
if (method === "sendMessage" || method === "sendRichMessage") {
|
|
sentBodies.push(parseJsonRequestBody(init) ?? {});
|
|
return createRuntimeTelegramApiResponse({ message_id: 100 });
|
|
}
|
|
throw new Error(`Unexpected Telegram API method: ${method}`);
|
|
});
|
|
try {
|
|
const cwd = "/repo/proactive-stale-owner";
|
|
await telegramConfig.write({
|
|
botToken: "123:abc",
|
|
allowedUserId: 77,
|
|
lastUpdateId: 0,
|
|
proactivePush: true,
|
|
});
|
|
await writeRuntimeTelegramLocks({
|
|
"@llblab/pi-telegram": {
|
|
pid: process.pid + 1_000_000,
|
|
cwd,
|
|
},
|
|
});
|
|
(await getRuntimeTelegramExtension())(pi);
|
|
const ctx = createRuntimeExtensionContext({ cwd });
|
|
await handlers.get("session_start")?.({}, ctx);
|
|
await waitForEventLoopCondition(() => getUpdatesCalls >= 1, 5000);
|
|
await handlers.get("agent_end")?.(
|
|
{
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "Local **done**" }],
|
|
},
|
|
],
|
|
},
|
|
ctx,
|
|
);
|
|
assert.equal(sentBodies.length, 1);
|
|
assert.equal(sentBodies[0]?.chat_id, 77);
|
|
assert.match(
|
|
String(
|
|
(sentBodies[0]?.rich_message as { markdown?: string } | undefined)
|
|
?.markdown ?? "",
|
|
),
|
|
/Local \*\*done\*\*/,
|
|
);
|
|
await handlers.get("session_shutdown")?.({}, ctx);
|
|
} finally {
|
|
restoreFetch();
|
|
await telegramConfig.restore();
|
|
}
|
|
});
|
|
|
|
test("Extension runtime sends proactive local result only while owning Telegram lock", async () => {
|
|
const telegramConfig = await createRuntimeTelegramConfigFixture();
|
|
const sentBodies: Array<Record<string, unknown>> = [];
|
|
const { handlers, commands, pi } = createRuntimePiHarness();
|
|
const restoreFetch = setRuntimeTestFetch(async (input, init) => {
|
|
const method = getRuntimeTelegramApiMethod(input);
|
|
if (method === "deleteWebhook") {
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
if (method === "getUpdates") {
|
|
throw new DOMException("stop", "AbortError");
|
|
}
|
|
if (method === "sendRichMessage") {
|
|
sentBodies.push(parseJsonRequestBody(init) ?? {});
|
|
return createRuntimeTelegramApiResponse({ message_id: 100 });
|
|
}
|
|
if (method === "sendMessage" || method === "sendRichMessage") {
|
|
sentBodies.push(parseJsonRequestBody(init) ?? {});
|
|
return createRuntimeTelegramApiResponse({ message_id: 100 });
|
|
}
|
|
throw new Error(`Unexpected Telegram API method: ${method}`);
|
|
});
|
|
try {
|
|
await telegramConfig.write({
|
|
botToken: "123:abc",
|
|
allowedUserId: 77,
|
|
lastUpdateId: 0,
|
|
proactivePush: true,
|
|
});
|
|
await writeRuntimeTelegramLocks({});
|
|
(await getRuntimeTelegramExtension())(pi);
|
|
const ctx = createRuntimeExtensionContext({
|
|
cwd: "/repo/proactive-owner",
|
|
});
|
|
await handlers.get("session_start")?.({}, ctx);
|
|
await commands.get("telegram-connect")?.handler("", ctx);
|
|
await flushMicrotasks(20);
|
|
await handlers.get("agent_end")?.(
|
|
{
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "Local **done**" }],
|
|
},
|
|
],
|
|
},
|
|
ctx,
|
|
);
|
|
assert.equal(sentBodies.length, 1);
|
|
assert.equal(sentBodies[0]?.chat_id, 77);
|
|
assert.match(
|
|
String(
|
|
(sentBodies[0]?.rich_message as { markdown?: string } | undefined)
|
|
?.markdown ?? "",
|
|
),
|
|
/Local \*\*done\*\*/,
|
|
);
|
|
await commands.get("telegram-disconnect")?.handler("", ctx);
|
|
await handlers.get("session_shutdown")?.({}, ctx);
|
|
} finally {
|
|
restoreFetch();
|
|
await telegramConfig.restore();
|
|
}
|
|
});
|
|
|
|
test("Extension runtime skips proactive local result without Telegram lock ownership", async () => {
|
|
const telegramConfig = await createRuntimeTelegramConfigFixture();
|
|
const sentBodies: Array<Record<string, unknown>> = [];
|
|
const { handlers, pi } = createRuntimePiHarness();
|
|
const restoreFetch = setRuntimeTestFetch(async (input, init) => {
|
|
const method = getRuntimeTelegramApiMethod(input);
|
|
if (method === "sendMessage" || method === "sendRichMessage") {
|
|
sentBodies.push(parseJsonRequestBody(init) ?? {});
|
|
return createRuntimeTelegramApiResponse({ message_id: 100 });
|
|
}
|
|
throw new Error(`Unexpected Telegram API method: ${method}`);
|
|
});
|
|
try {
|
|
await telegramConfig.write({
|
|
botToken: "123:abc",
|
|
allowedUserId: 77,
|
|
lastUpdateId: 0,
|
|
proactivePush: true,
|
|
});
|
|
await writeRuntimeTelegramLocks({
|
|
"@llblab/pi-telegram": {
|
|
pid: process.pid + 1_000_000,
|
|
cwd: "/repo/another-instance",
|
|
},
|
|
});
|
|
(await getRuntimeTelegramExtension())(pi);
|
|
const ctx = createRuntimeExtensionContext({
|
|
cwd: "/repo/proactive-non-owner",
|
|
});
|
|
await handlers.get("session_start")?.({}, ctx);
|
|
await handlers.get("agent_end")?.(
|
|
{
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "Local **done**" }],
|
|
},
|
|
],
|
|
},
|
|
ctx,
|
|
);
|
|
assert.deepEqual(sentBodies, []);
|
|
await handlers.get("session_shutdown")?.({}, ctx);
|
|
} finally {
|
|
restoreFetch();
|
|
await telegramConfig.restore();
|
|
}
|
|
});
|
|
|
|
test("Extension runtime clears queued follow-ups after a Telegram stop", async () => {
|
|
const telegramConfig = await createRuntimeTelegramConfigFixture();
|
|
const sentMessages: RuntimeHarnessMessage[] = [];
|
|
let firstDispatchResolved = false;
|
|
const secondUpdates = createRuntimeDeferredResponse();
|
|
const thirdUpdates = createRuntimeDeferredResponse();
|
|
const fourthUpdates = createRuntimeDeferredResponse();
|
|
const { handlers, commands, pi } = createRuntimePiHarness({
|
|
sendUserMessage: (content) => {
|
|
sentMessages.push(content);
|
|
firstDispatchResolved = true;
|
|
},
|
|
});
|
|
let getUpdatesCalls = 0;
|
|
const sendTexts: string[] = [];
|
|
const restoreFetch = setRuntimeTestFetch(async (input, init) => {
|
|
const method = getRuntimeTelegramApiMethod(input);
|
|
const body = parseJsonRequestBody(init);
|
|
if (method === "deleteWebhook") {
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
if (method === "getUpdates") {
|
|
getUpdatesCalls += 1;
|
|
if (getUpdatesCalls === 1) {
|
|
return createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 1,
|
|
message: {
|
|
message_id: 10,
|
|
chat: { id: 99, type: "private" },
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
text: "first request",
|
|
},
|
|
},
|
|
]);
|
|
}
|
|
if (getUpdatesCalls === 2) return secondUpdates.promise;
|
|
if (getUpdatesCalls === 3) return thirdUpdates.promise;
|
|
if (getUpdatesCalls === 4) return fourthUpdates.promise;
|
|
throw new DOMException("stop", "AbortError");
|
|
}
|
|
if (method === "sendMessage" || method === "sendRichMessage") {
|
|
sendTexts.push(getRuntimeTelegramApiText(body));
|
|
return createRuntimeTelegramApiResponse({
|
|
message_id: 100 + sendTexts.length,
|
|
});
|
|
}
|
|
if (method === "sendChatAction") {
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
throw new Error(`Unexpected Telegram API method: ${method}`);
|
|
});
|
|
try {
|
|
await telegramConfig.write({
|
|
botToken: "123:abc",
|
|
allowedUserId: 77,
|
|
lastUpdateId: 0,
|
|
});
|
|
(await getRuntimeTelegramExtension())(pi);
|
|
const idleCtx = createRuntimeExtensionContext();
|
|
let aborted = false;
|
|
const activeCtx = createRuntimeExtensionContext({
|
|
isIdle: () => false,
|
|
abort: () => {
|
|
aborted = true;
|
|
},
|
|
});
|
|
await handlers.get("session_start")?.({}, idleCtx);
|
|
await commands.get("telegram-connect")?.handler("", idleCtx);
|
|
await waitForCondition(() => firstDispatchResolved);
|
|
await handlers.get("agent_start")?.({}, activeCtx);
|
|
secondUpdates.resolve(
|
|
createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 2,
|
|
message: {
|
|
message_id: 11,
|
|
chat: { id: 99, type: "private" },
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
text: "follow up",
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
await waitForCondition(() => getUpdatesCalls >= 3);
|
|
thirdUpdates.resolve(
|
|
createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 3,
|
|
message: {
|
|
message_id: 12,
|
|
chat: { id: 99, type: "private" },
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
text: "/stop",
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
await waitForCondition(() => aborted);
|
|
await handlers.get("agent_end")?.(
|
|
{
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
stopReason: "aborted",
|
|
content: [{ type: "text", text: "" }],
|
|
},
|
|
],
|
|
},
|
|
idleCtx,
|
|
);
|
|
const dispatchCountBeforeNextTurn = sentMessages.length;
|
|
fourthUpdates.resolve(
|
|
createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 4,
|
|
message: {
|
|
message_id: 13,
|
|
chat: { id: 99, type: "private" },
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
text: "new request",
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
await waitForCondition(
|
|
() => sentMessages.length === dispatchCountBeforeNextTurn + 1,
|
|
);
|
|
const promptText =
|
|
getRuntimeHarnessTextBlock(sentMessages.at(-1)).text ?? "";
|
|
assert.equal(promptText, "[telegram] new request");
|
|
assert.equal(promptText.includes("follow up"), false);
|
|
assert.equal(
|
|
sendTexts.includes("Aborted current turn. Cleared 1 queued turn."),
|
|
true,
|
|
);
|
|
await handlers.get("session_shutdown")?.({}, idleCtx);
|
|
} finally {
|
|
restoreFetch();
|
|
await telegramConfig.restore();
|
|
}
|
|
});
|
|
|
|
test("Extension runtime handles immediate status before queued prompt after agent end", async () => {
|
|
const telegramConfig = await createRuntimeTelegramConfigFixture();
|
|
const runtimeEvents: string[] = [];
|
|
let firstDispatchResolved = false;
|
|
let shutdownCtx: unknown;
|
|
const secondUpdates = createRuntimeDeferredResponse();
|
|
const thirdUpdates = createRuntimeDeferredResponse();
|
|
const { handlers, commands, pi } = createRuntimePiHarness({
|
|
sendUserMessage: (content) => {
|
|
recordRuntimeDispatchEvent(runtimeEvents, content);
|
|
firstDispatchResolved = true;
|
|
},
|
|
});
|
|
let getUpdatesCalls = 0;
|
|
const restoreFetch = setRuntimeTestFetch(async (input, init) => {
|
|
const method = getRuntimeTelegramApiMethod(input);
|
|
const body = parseJsonRequestBody(init);
|
|
if (method === "deleteWebhook")
|
|
return createRuntimeTelegramApiResponse(true);
|
|
if (method === "getUpdates") {
|
|
getUpdatesCalls += 1;
|
|
if (getUpdatesCalls === 1) {
|
|
return createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 1,
|
|
message: {
|
|
message_id: 20,
|
|
chat: { id: 99, type: "private" },
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
text: "first request",
|
|
},
|
|
},
|
|
]);
|
|
}
|
|
if (getUpdatesCalls === 2) return secondUpdates.promise;
|
|
if (getUpdatesCalls === 3) return thirdUpdates.promise;
|
|
throw new DOMException("stop", "AbortError");
|
|
}
|
|
if (method === "sendMessage" || method === "sendRichMessage") {
|
|
runtimeEvents.push(`send:${getRuntimeTelegramApiText(body)}`);
|
|
return createRuntimeTelegramApiResponse({
|
|
message_id: 100 + runtimeEvents.length,
|
|
});
|
|
}
|
|
if (method === "sendChatAction")
|
|
return createRuntimeTelegramApiResponse(true);
|
|
throw new Error(`Unexpected Telegram API method: ${method}`);
|
|
});
|
|
try {
|
|
await telegramConfig.write({
|
|
botToken: "123:abc",
|
|
allowedUserId: 77,
|
|
lastUpdateId: 0,
|
|
});
|
|
(await getRuntimeTelegramExtension())(pi);
|
|
const baseCtx = createRuntimeExtensionContext({
|
|
cwd: process.cwd(),
|
|
sessionManager: {
|
|
getEntries: () => [],
|
|
},
|
|
modelRegistry: {
|
|
refresh: () => {},
|
|
getAvailable: () => [],
|
|
isUsingOAuth: () => false,
|
|
},
|
|
getContextUsage: () => undefined,
|
|
});
|
|
const idleCtx = {
|
|
...baseCtx,
|
|
isIdle: () => true,
|
|
};
|
|
const activeCtx = {
|
|
...baseCtx,
|
|
isIdle: () => false,
|
|
};
|
|
shutdownCtx = idleCtx;
|
|
await handlers.get("session_start")?.({}, idleCtx);
|
|
await commands.get("telegram-connect")?.handler("", idleCtx);
|
|
await waitForCondition(() => firstDispatchResolved);
|
|
await handlers.get("agent_start")?.({}, activeCtx);
|
|
secondUpdates.resolve(
|
|
createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 2,
|
|
message: {
|
|
message_id: 21,
|
|
chat: { id: 99, type: "private" },
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
text: "/status",
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
await waitForCondition(() => getUpdatesCalls >= 3);
|
|
thirdUpdates.resolve(
|
|
createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 3,
|
|
message: {
|
|
message_id: 22,
|
|
chat: { id: 99, type: "private" },
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
text: "follow up after status",
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
await waitForCondition(() => runtimeEvents.length >= 1);
|
|
await handlers.get("agent_end")?.(
|
|
{
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "" }],
|
|
},
|
|
],
|
|
},
|
|
idleCtx,
|
|
);
|
|
await waitForCondition(() => runtimeEvents.length >= 3);
|
|
assert.equal(runtimeEvents[0], "dispatch:[telegram] first request");
|
|
assert.match(runtimeEvents[1] ?? "", /^send:<b>Pi Telegram<\/b>/);
|
|
assert.equal(
|
|
runtimeEvents[2],
|
|
"dispatch:[telegram] follow up after status",
|
|
);
|
|
} finally {
|
|
if (shutdownCtx) await handlers.get("session_shutdown")?.({}, shutdownCtx);
|
|
restoreFetch();
|
|
await telegramConfig.restore();
|
|
}
|
|
});
|
|
|
|
test("Extension runtime opens immediate model menu before queued prompt after agent end", async () => {
|
|
const telegramConfig = await createRuntimeTelegramConfigFixture();
|
|
const runtimeEvents: string[] = [];
|
|
const modelA = createRuntimeModel("openai", "gpt-a", true);
|
|
const modelB = createRuntimeModel("anthropic", "claude-b", false);
|
|
let firstDispatchResolved = false;
|
|
const secondUpdates = createRuntimeDeferredResponse();
|
|
const thirdUpdates = createRuntimeDeferredResponse();
|
|
const { handlers, commands, pi } = createRuntimePiHarness({
|
|
sendUserMessage: (content) => {
|
|
recordRuntimeDispatchEvent(runtimeEvents, content);
|
|
firstDispatchResolved = true;
|
|
},
|
|
});
|
|
let getUpdatesCalls = 0;
|
|
const restoreFetch = setRuntimeTestFetch(async (input, init) => {
|
|
const method = getRuntimeTelegramApiMethod(input);
|
|
const body = parseJsonRequestBody(init);
|
|
if (method === "deleteWebhook") {
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
if (method === "getUpdates") {
|
|
getUpdatesCalls += 1;
|
|
if (getUpdatesCalls === 1) {
|
|
return createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 1,
|
|
message: {
|
|
message_id: 23,
|
|
chat: { id: 99, type: "private" },
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
text: "first request",
|
|
},
|
|
},
|
|
]);
|
|
}
|
|
if (getUpdatesCalls === 2) return secondUpdates.promise;
|
|
if (getUpdatesCalls === 3) return thirdUpdates.promise;
|
|
throw new DOMException("stop", "AbortError");
|
|
}
|
|
if (method === "sendMessage" || method === "sendRichMessage") {
|
|
runtimeEvents.push(`send:${getRuntimeTelegramApiText(body)}`);
|
|
return createRuntimeTelegramApiResponse({
|
|
message_id: 100 + runtimeEvents.length,
|
|
});
|
|
}
|
|
if (method === "sendChatAction") {
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
throw new Error(`Unexpected Telegram API method: ${method}`);
|
|
});
|
|
try {
|
|
await telegramConfig.write({
|
|
botToken: "123:abc",
|
|
allowedUserId: 77,
|
|
lastUpdateId: 0,
|
|
});
|
|
(await getRuntimeTelegramExtension())(pi);
|
|
const baseCtx = createRuntimeExtensionContext({
|
|
cwd: process.cwd(),
|
|
model: modelA,
|
|
sessionManager: {
|
|
getEntries: () => [],
|
|
},
|
|
modelRegistry: {
|
|
refresh: () => {},
|
|
getAvailable: () => [modelA, modelB],
|
|
isUsingOAuth: () => false,
|
|
},
|
|
getContextUsage: () => undefined,
|
|
});
|
|
const idleCtx = {
|
|
...baseCtx,
|
|
isIdle: () => true,
|
|
};
|
|
const activeCtx = {
|
|
...baseCtx,
|
|
isIdle: () => false,
|
|
};
|
|
await handlers.get("session_start")?.({}, idleCtx);
|
|
await commands.get("telegram-connect")?.handler("", idleCtx);
|
|
await waitForCondition(() => firstDispatchResolved);
|
|
await handlers.get("agent_start")?.({}, activeCtx);
|
|
secondUpdates.resolve(
|
|
createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 2,
|
|
message: {
|
|
message_id: 24,
|
|
chat: { id: 99, type: "private" },
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
text: "/model",
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
await waitForCondition(() => getUpdatesCalls >= 3);
|
|
thirdUpdates.resolve(
|
|
createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 3,
|
|
message: {
|
|
message_id: 25,
|
|
chat: { id: 99, type: "private" },
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
text: "follow up after model",
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
await waitForCondition(() => runtimeEvents.length >= 1);
|
|
await handlers.get("agent_end")?.(
|
|
{
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "" }],
|
|
},
|
|
],
|
|
},
|
|
idleCtx,
|
|
);
|
|
await waitForCondition(() => runtimeEvents.length >= 3);
|
|
assert.equal(runtimeEvents[0], "dispatch:[telegram] first request");
|
|
assert.equal(runtimeEvents[1], "send:<b>🤖 Choose a model:</b>");
|
|
assert.equal(runtimeEvents[2], "dispatch:[telegram] follow up after model");
|
|
await handlers.get("session_shutdown")?.({}, idleCtx);
|
|
} finally {
|
|
restoreFetch();
|
|
await telegramConfig.restore();
|
|
}
|
|
});
|
|
|
|
test("Extension runtime keeps queued turns blocked until compaction completes", async () => {
|
|
const telegramConfig = await createRuntimeTelegramConfigFixture();
|
|
const runtimeEvents: string[] = [];
|
|
let compactHooks:
|
|
| {
|
|
onComplete: () => void;
|
|
onError: (error: unknown) => void;
|
|
}
|
|
| undefined;
|
|
const secondUpdates = createRuntimeDeferredResponse();
|
|
const thirdUpdates = createRuntimeDeferredResponse();
|
|
const { handlers, commands, pi } = createRuntimePiHarness({
|
|
sendUserMessage: (content) => {
|
|
recordRuntimeDispatchEvent(runtimeEvents, content);
|
|
},
|
|
});
|
|
let getUpdatesCalls = 0;
|
|
const restoreFetch = setRuntimeTestFetch(async (input, init) => {
|
|
const method = getRuntimeTelegramApiMethod(input);
|
|
const body = parseJsonRequestBody(init);
|
|
if (method === "deleteWebhook") {
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
if (method === "getUpdates") {
|
|
getUpdatesCalls += 1;
|
|
if (getUpdatesCalls === 1) {
|
|
return createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 1,
|
|
message: {
|
|
message_id: 30,
|
|
chat: { id: 99, type: "private" },
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
text: "/compact",
|
|
},
|
|
},
|
|
]);
|
|
}
|
|
if (getUpdatesCalls === 2) {
|
|
return secondUpdates.promise;
|
|
}
|
|
if (getUpdatesCalls === 3) {
|
|
return thirdUpdates.promise;
|
|
}
|
|
throw new DOMException("stop", "AbortError");
|
|
}
|
|
if (method === "sendMessage" || method === "sendRichMessage") {
|
|
runtimeEvents.push(`send:${getRuntimeTelegramApiText(body)}`);
|
|
return createRuntimeTelegramApiResponse({
|
|
message_id: 100 + runtimeEvents.length,
|
|
});
|
|
}
|
|
if (method === "sendChatAction") {
|
|
runtimeEvents.push(
|
|
`typing:${String(body?.chat_id ?? "")}:${String(body?.action ?? "")}`,
|
|
);
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
if (method === "editMessageText") {
|
|
runtimeEvents.push(`edit:${getRuntimeTelegramApiText(body)}`);
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
if (method === "answerCallbackQuery") {
|
|
runtimeEvents.push(`answer:${String(body?.callback_query_id ?? "")}`);
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
throw new Error(`Unexpected Telegram API method: ${method}`);
|
|
});
|
|
try {
|
|
await telegramConfig.write({
|
|
botToken: "123:abc",
|
|
allowedUserId: 77,
|
|
lastUpdateId: 0,
|
|
});
|
|
(await getRuntimeTelegramExtension())(pi);
|
|
const ctx = createRuntimeExtensionContext({
|
|
compact: (hooks: {
|
|
onComplete: () => void;
|
|
onError: (error: unknown) => void;
|
|
}) => {
|
|
compactHooks = hooks;
|
|
runtimeEvents.push("compact:start");
|
|
},
|
|
});
|
|
await handlers.get("session_start")?.({}, ctx);
|
|
await commands.get("telegram-connect")?.handler("", ctx);
|
|
await waitForCondition(() =>
|
|
runtimeEvents.includes("send:<b>Compact session?</b>"),
|
|
);
|
|
secondUpdates.resolve(
|
|
createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 2,
|
|
callback_query: {
|
|
id: "confirm-compact",
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
message: {
|
|
message_id: 101,
|
|
chat: { id: 99, type: "private" },
|
|
},
|
|
data: "compact:confirm",
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
await waitForCondition(() => runtimeEvents.includes("compact:start"));
|
|
await waitForCondition(
|
|
() =>
|
|
runtimeEvents.includes("edit:🗜 Compaction started.") &&
|
|
runtimeEvents.includes("typing:99:typing"),
|
|
);
|
|
assert.equal(
|
|
runtimeEvents.indexOf("edit:🗜 Compaction started.") <
|
|
runtimeEvents.indexOf("typing:99:typing"),
|
|
true,
|
|
);
|
|
|
|
thirdUpdates.resolve(
|
|
createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 3,
|
|
message: {
|
|
message_id: 31,
|
|
chat: { id: 99, type: "private" },
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
text: "follow up after compaction",
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
await waitForCondition(() => getUpdatesCalls >= 3);
|
|
assert.equal(
|
|
runtimeEvents.some(
|
|
(event) => event === "dispatch:[telegram] follow up after compaction",
|
|
),
|
|
false,
|
|
);
|
|
compactHooks?.onComplete();
|
|
await waitForCondition(() =>
|
|
runtimeEvents.includes("dispatch:[telegram] follow up after compaction"),
|
|
);
|
|
await waitForCondition(() =>
|
|
runtimeEvents.includes("send:✅ Compaction completed."),
|
|
);
|
|
await handlers.get("session_shutdown")?.({}, ctx);
|
|
} finally {
|
|
restoreFetch();
|
|
await telegramConfig.restore();
|
|
}
|
|
});
|
|
|
|
test("Extension runtime blocks queued dispatch during observed auto-compaction", async () => {
|
|
const telegramConfig = await createRuntimeTelegramConfigFixture();
|
|
const runtimeEvents: string[] = [];
|
|
let firstDispatchResolve: (() => void) | undefined;
|
|
const firstDispatched = new Promise<void>((resolve) => {
|
|
firstDispatchResolve = resolve;
|
|
});
|
|
const secondUpdates = createRuntimeDeferredResponse();
|
|
const { handlers, commands, pi } = createRuntimePiHarness({
|
|
sendUserMessage: (content) => {
|
|
recordRuntimeDispatchEvent(runtimeEvents, content);
|
|
firstDispatchResolve?.();
|
|
},
|
|
});
|
|
let getUpdatesCalls = 0;
|
|
const restoreFetch = setRuntimeTestFetch(async (input, init) => {
|
|
const method = getRuntimeTelegramApiMethod(input);
|
|
const body = parseJsonRequestBody(init);
|
|
if (method === "deleteWebhook")
|
|
return createRuntimeTelegramApiResponse(true);
|
|
if (method === "getUpdates") {
|
|
getUpdatesCalls += 1;
|
|
if (getUpdatesCalls === 1) {
|
|
return createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 1,
|
|
message: {
|
|
message_id: 41,
|
|
chat: { id: 99, type: "private" },
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
text: "first telegram turn",
|
|
},
|
|
},
|
|
]);
|
|
}
|
|
if (getUpdatesCalls === 2) return secondUpdates.promise;
|
|
throw new DOMException("stop", "AbortError");
|
|
}
|
|
if (method === "sendMessage" || method === "sendRichMessage") {
|
|
runtimeEvents.push(`send:${getRuntimeTelegramApiText(body)}`);
|
|
return createRuntimeTelegramApiResponse({
|
|
message_id: 100 + runtimeEvents.length,
|
|
});
|
|
}
|
|
if (
|
|
method === "sendMessageDraft" ||
|
|
method === "sendChatAction" ||
|
|
method === "editMessageText"
|
|
) {
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
throw new Error(`Unexpected Telegram API method: ${method}`);
|
|
});
|
|
try {
|
|
await telegramConfig.write({
|
|
botToken: "123:abc",
|
|
allowedUserId: 77,
|
|
lastUpdateId: 0,
|
|
});
|
|
(await getRuntimeTelegramExtension())(pi);
|
|
const ctx = createRuntimeExtensionContext();
|
|
await handlers.get("session_start")?.({}, ctx);
|
|
await commands.get("telegram-connect")?.handler("", ctx);
|
|
await firstDispatched;
|
|
await handlers.get("agent_start")?.({}, ctx);
|
|
secondUpdates.resolve(
|
|
createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 2,
|
|
message: {
|
|
message_id: 42,
|
|
chat: { id: 99, type: "private" },
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
text: "queued during active turn",
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
await waitForCondition(() => getUpdatesCalls >= 3);
|
|
await handlers.get("agent_end")?.(
|
|
{
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "done" }],
|
|
},
|
|
],
|
|
},
|
|
ctx,
|
|
);
|
|
await handlers.get("session_before_compact")?.(
|
|
{ signal: new AbortController().signal },
|
|
ctx,
|
|
);
|
|
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
assert.equal(
|
|
runtimeEvents.includes("dispatch:[telegram] queued during active turn"),
|
|
false,
|
|
);
|
|
await handlers.get("session_compact")?.({}, ctx);
|
|
await waitForCondition(() =>
|
|
runtimeEvents.includes("dispatch:[telegram] queued during active turn"),
|
|
);
|
|
await handlers.get("session_shutdown")?.({}, ctx);
|
|
} finally {
|
|
restoreFetch();
|
|
await telegramConfig.restore();
|
|
}
|
|
});
|
|
|
|
test("Extension runtime coalesces media-group updates into one delayed dispatch", async () => {
|
|
const telegramConfig = await createRuntimeTelegramConfigFixture();
|
|
const runtimeEvents: string[] = [];
|
|
const { handlers, commands, pi } = createRuntimePiHarness({
|
|
sendUserMessage: (content) => {
|
|
recordRuntimeDispatchEvent(runtimeEvents, content);
|
|
},
|
|
});
|
|
let getUpdatesCalls = 0;
|
|
const restoreFetch = setRuntimeTestFetch(async (input) => {
|
|
const method = getRuntimeTelegramApiMethod(input);
|
|
if (method === "deleteWebhook") {
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
if (method === "getUpdates") {
|
|
getUpdatesCalls += 1;
|
|
if (getUpdatesCalls === 1) {
|
|
return createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 1,
|
|
message: {
|
|
message_id: 40,
|
|
media_group_id: "album-1",
|
|
chat: { id: 99, type: "private" },
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
caption: "first caption",
|
|
},
|
|
},
|
|
{
|
|
_: "other",
|
|
update_id: 2,
|
|
message: {
|
|
message_id: 41,
|
|
media_group_id: "album-1",
|
|
chat: { id: 99, type: "private" },
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
caption: "second caption",
|
|
},
|
|
},
|
|
]);
|
|
}
|
|
throw new DOMException("stop", "AbortError");
|
|
}
|
|
throw new Error(`Unexpected Telegram API method: ${method}`);
|
|
});
|
|
try {
|
|
await telegramConfig.write({
|
|
botToken: "123:abc",
|
|
allowedUserId: 77,
|
|
lastUpdateId: 0,
|
|
});
|
|
(await getRuntimeTelegramExtension())(pi);
|
|
const ctx = createRuntimeExtensionContext();
|
|
await handlers.get("session_start")?.({}, ctx);
|
|
await commands.get("telegram-connect")?.handler("", ctx);
|
|
await waitForEventLoopCondition(() => getUpdatesCalls >= 2, 5000);
|
|
assert.equal(runtimeEvents.length, 0);
|
|
await waitForCondition(() => runtimeEvents.length === 1, 3000);
|
|
assert.equal(
|
|
runtimeEvents[0],
|
|
"dispatch:[telegram] first caption\n\nsecond caption",
|
|
);
|
|
await handlers.get("session_shutdown")?.({}, ctx);
|
|
} finally {
|
|
restoreFetch();
|
|
await telegramConfig.restore();
|
|
}
|
|
});
|
|
|
|
test("Extension runtime coalesces likely split long text updates into one dispatch", async () => {
|
|
const telegramConfig = await createRuntimeTelegramConfigFixture();
|
|
const runtimeEvents: string[] = [];
|
|
const { handlers, commands, pi } = createRuntimePiHarness({
|
|
sendUserMessage: (content) => {
|
|
recordRuntimeDispatchEvent(runtimeEvents, content);
|
|
},
|
|
});
|
|
let getUpdatesCalls = 0;
|
|
const restoreFetch = setRuntimeTestFetch(async (input) => {
|
|
const method = getRuntimeTelegramApiMethod(input);
|
|
if (method === "deleteWebhook") {
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
if (method === "getUpdates") {
|
|
getUpdatesCalls += 1;
|
|
if (getUpdatesCalls === 1) {
|
|
return createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 1,
|
|
message: {
|
|
message_id: 50,
|
|
chat: { id: 99, type: "private" },
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
text: "x".repeat(3600),
|
|
},
|
|
},
|
|
{
|
|
_: "other",
|
|
update_id: 2,
|
|
message: {
|
|
message_id: 51,
|
|
chat: { id: 99, type: "private" },
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
text: "tail",
|
|
},
|
|
},
|
|
]);
|
|
}
|
|
throw new DOMException("stop", "AbortError");
|
|
}
|
|
throw new Error(`Unexpected Telegram API method: ${method}`);
|
|
});
|
|
try {
|
|
await telegramConfig.write({
|
|
botToken: "123:abc",
|
|
allowedUserId: 77,
|
|
lastUpdateId: 0,
|
|
});
|
|
(await getRuntimeTelegramExtension())(pi);
|
|
const ctx = createRuntimeExtensionContext();
|
|
await handlers.get("session_start")?.({}, ctx);
|
|
await commands.get("telegram-connect")?.handler("", ctx);
|
|
await waitForEventLoopCondition(() => getUpdatesCalls >= 1, 5000);
|
|
await flushMicrotasks();
|
|
assert.equal(runtimeEvents.length, 0);
|
|
await waitForCondition(() => runtimeEvents.length === 1, 3000);
|
|
assert.equal(
|
|
runtimeEvents[0],
|
|
`dispatch:[telegram] ${"x".repeat(3600)}\n\ntail`,
|
|
);
|
|
await handlers.get("session_shutdown")?.({}, ctx);
|
|
} finally {
|
|
restoreFetch();
|
|
await telegramConfig.restore();
|
|
}
|
|
});
|
|
|
|
test("Extension runtime clears pending split-text dispatch on shutdown", async () => {
|
|
const telegramConfig = await createRuntimeTelegramConfigFixture();
|
|
const runtimeEvents: string[] = [];
|
|
const { handlers, commands, pi } = createRuntimePiHarness({
|
|
sendUserMessage: (content) => {
|
|
recordRuntimeDispatchEvent(runtimeEvents, content);
|
|
},
|
|
});
|
|
let getUpdatesCalls = 0;
|
|
const restoreFetch = setRuntimeTestFetch(async (input) => {
|
|
const method = getRuntimeTelegramApiMethod(input);
|
|
if (method === "deleteWebhook") {
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
if (method === "getUpdates") {
|
|
getUpdatesCalls += 1;
|
|
if (getUpdatesCalls === 1) {
|
|
return createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 1,
|
|
message: {
|
|
message_id: 60,
|
|
chat: { id: 99, type: "private" },
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
text: "x".repeat(3600),
|
|
},
|
|
},
|
|
]);
|
|
}
|
|
throw new DOMException("stop", "AbortError");
|
|
}
|
|
throw new Error(`Unexpected Telegram API method: ${method}`);
|
|
});
|
|
try {
|
|
await telegramConfig.write({
|
|
botToken: "123:abc",
|
|
allowedUserId: 77,
|
|
lastUpdateId: 0,
|
|
});
|
|
(await getRuntimeTelegramExtension())(pi);
|
|
const ctx = createRuntimeExtensionContext();
|
|
await handlers.get("session_start")?.({}, ctx);
|
|
await commands.get("telegram-connect")?.handler("", ctx);
|
|
await waitForEventLoopCondition(() => getUpdatesCalls >= 2, 5000);
|
|
await handlers.get("session_shutdown")?.({}, ctx);
|
|
await new Promise((resolve) => setTimeout(resolve, 900));
|
|
assert.deepEqual(runtimeEvents, []);
|
|
} finally {
|
|
restoreFetch();
|
|
await telegramConfig.restore();
|
|
}
|
|
});
|
|
|
|
test("Extension runtime clears pending media-group dispatch on shutdown", async () => {
|
|
const telegramConfig = await createRuntimeTelegramConfigFixture();
|
|
const runtimeEvents: string[] = [];
|
|
const { handlers, commands, pi } = createRuntimePiHarness({
|
|
sendUserMessage: (content) => {
|
|
recordRuntimeDispatchEvent(runtimeEvents, content);
|
|
},
|
|
});
|
|
let getUpdatesCalls = 0;
|
|
const restoreFetch = setRuntimeTestFetch(async (input) => {
|
|
const method = getRuntimeTelegramApiMethod(input);
|
|
if (method === "deleteWebhook") {
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
if (method === "getUpdates") {
|
|
getUpdatesCalls += 1;
|
|
if (getUpdatesCalls === 1) {
|
|
return createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 1,
|
|
message: {
|
|
message_id: 61,
|
|
chat: { id: 99, type: "private" },
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
media_group_id: "album-1",
|
|
text: "album item",
|
|
},
|
|
},
|
|
]);
|
|
}
|
|
throw new DOMException("stop", "AbortError");
|
|
}
|
|
throw new Error(`Unexpected Telegram API method: ${method}`);
|
|
});
|
|
try {
|
|
await telegramConfig.write({
|
|
botToken: "123:abc",
|
|
allowedUserId: 77,
|
|
lastUpdateId: 0,
|
|
});
|
|
(await getRuntimeTelegramExtension())(pi);
|
|
const ctx = createRuntimeExtensionContext();
|
|
await handlers.get("session_start")?.({}, ctx);
|
|
await commands.get("telegram-connect")?.handler("", ctx);
|
|
await waitForEventLoopCondition(() => getUpdatesCalls >= 2, 5000);
|
|
await handlers.get("session_shutdown")?.({}, ctx);
|
|
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
assert.deepEqual(runtimeEvents, []);
|
|
} finally {
|
|
restoreFetch();
|
|
await telegramConfig.restore();
|
|
}
|
|
});
|
|
|
|
test("Extension runtime applies reaction priority and removal before the next dispatch", async () => {
|
|
const telegramConfig = await createRuntimeTelegramConfigFixture();
|
|
const runtimeEvents: string[] = [];
|
|
let firstDispatchResolved = false;
|
|
const secondUpdates = createRuntimeDeferredResponse();
|
|
const thirdUpdates = createRuntimeDeferredResponse();
|
|
const fourthUpdates = createRuntimeDeferredResponse();
|
|
const fifthUpdates = createRuntimeDeferredResponse();
|
|
const { handlers, commands, pi } = createRuntimePiHarness({
|
|
sendUserMessage: (content) => {
|
|
recordRuntimeDispatchEvent(runtimeEvents, content);
|
|
firstDispatchResolved = true;
|
|
},
|
|
});
|
|
let getUpdatesCalls = 0;
|
|
const restoreFetch = setRuntimeTestFetch(async (input) => {
|
|
const method = getRuntimeTelegramApiMethod(input);
|
|
if (method === "deleteWebhook") {
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
if (method === "getUpdates") {
|
|
getUpdatesCalls += 1;
|
|
if (getUpdatesCalls === 1) {
|
|
return createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 1,
|
|
message: {
|
|
message_id: 30,
|
|
chat: { id: 99, type: "private" },
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
text: "first request",
|
|
},
|
|
},
|
|
]);
|
|
}
|
|
if (getUpdatesCalls === 2) return secondUpdates.promise;
|
|
if (getUpdatesCalls === 3) return thirdUpdates.promise;
|
|
if (getUpdatesCalls === 4) return fourthUpdates.promise;
|
|
if (getUpdatesCalls === 5) return fifthUpdates.promise;
|
|
throw new DOMException("stop", "AbortError");
|
|
}
|
|
if (method === "sendChatAction") {
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
throw new Error(`Unexpected Telegram API method: ${method}`);
|
|
});
|
|
try {
|
|
await telegramConfig.write({
|
|
botToken: "123:abc",
|
|
allowedUserId: 77,
|
|
lastUpdateId: 0,
|
|
});
|
|
(await getRuntimeTelegramExtension())(pi);
|
|
const idleCtx = createRuntimeExtensionContext();
|
|
const activeCtx = createRuntimeExtensionContext({
|
|
isIdle: () => false,
|
|
});
|
|
await handlers.get("session_start")?.({}, idleCtx);
|
|
await commands.get("telegram-connect")?.handler("", idleCtx);
|
|
await waitForCondition(() => firstDispatchResolved);
|
|
await handlers.get("agent_start")?.({}, activeCtx);
|
|
secondUpdates.resolve(
|
|
createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 2,
|
|
message: {
|
|
message_id: 31,
|
|
chat: { id: 99, type: "private" },
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
text: "older waiting",
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
await waitForCondition(() => getUpdatesCalls >= 3);
|
|
thirdUpdates.resolve(
|
|
createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 3,
|
|
message: {
|
|
message_id: 32,
|
|
chat: { id: 99, type: "private" },
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
text: "newer waiting",
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
await waitForCondition(() => getUpdatesCalls >= 4);
|
|
fourthUpdates.resolve(
|
|
createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 4,
|
|
message_reaction: {
|
|
chat: { id: 99, type: "private" },
|
|
message_id: 32,
|
|
user: { id: 77, is_bot: false, first_name: "Test" },
|
|
old_reaction: [],
|
|
new_reaction: [{ type: "emoji", emoji: "👍" }],
|
|
date: 1,
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
await waitForCondition(() => getUpdatesCalls >= 5);
|
|
fifthUpdates.resolve(
|
|
createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 5,
|
|
message_reaction: {
|
|
chat: { id: 99, type: "private" },
|
|
message_id: 31,
|
|
user: { id: 77, is_bot: false, first_name: "Test" },
|
|
old_reaction: [],
|
|
new_reaction: [{ type: "emoji", emoji: "👎" }],
|
|
date: 2,
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
await waitForCondition(() => getUpdatesCalls >= 6);
|
|
await flushMicrotasks(50);
|
|
await handlers.get("agent_end")?.(
|
|
{
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "" }],
|
|
},
|
|
],
|
|
},
|
|
idleCtx,
|
|
);
|
|
await waitForCondition(() => runtimeEvents.length === 2);
|
|
assert.equal(runtimeEvents[0], "dispatch:[telegram] first request");
|
|
assert.equal(runtimeEvents[1], "dispatch:[telegram] newer waiting");
|
|
await handlers.get("agent_start")?.({}, activeCtx);
|
|
await handlers.get("agent_end")?.(
|
|
{
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "" }],
|
|
},
|
|
],
|
|
},
|
|
idleCtx,
|
|
);
|
|
await flushMicrotasks();
|
|
assert.deepEqual(runtimeEvents, [
|
|
"dispatch:[telegram] first request",
|
|
"dispatch:[telegram] newer waiting",
|
|
]);
|
|
await handlers.get("session_shutdown")?.({}, idleCtx);
|
|
} finally {
|
|
restoreFetch();
|
|
await telegramConfig.restore();
|
|
}
|
|
});
|
|
|
|
test("Extension runtime applies idle model picks immediately and refreshes status", async () => {
|
|
const telegramConfig = await createRuntimeTelegramConfigFixture();
|
|
const previousArgv = [...process.argv];
|
|
const runtimeEvents: string[] = [];
|
|
const statusEvents: string[] = [];
|
|
const modelA = createRuntimeModel("openai", "gpt-a", true);
|
|
const modelB = createRuntimeModel("anthropic", "claude-b", true);
|
|
const setModels: Array<string> = [];
|
|
const thinkingLevels: Array<string> = [];
|
|
let shutdownCtx: unknown;
|
|
const secondUpdates = createRuntimeDeferredResponse();
|
|
const { handlers, commands, pi } = createRuntimePiHarness({
|
|
getThinkingLevel: () => thinkingLevels.at(-1) ?? "medium",
|
|
setModel: async (model) => {
|
|
setModels.push(`${model.provider}/${model.id}`);
|
|
return true;
|
|
},
|
|
setThinkingLevel: (level) => {
|
|
thinkingLevels.push(level);
|
|
},
|
|
});
|
|
let getUpdatesCalls = 0;
|
|
let nextMessageId = 100;
|
|
const callbackAnswers: string[] = [];
|
|
const restoreFetch = setRuntimeTestFetch(async (input, init) => {
|
|
const method = getRuntimeTelegramApiMethod(input);
|
|
const body = parseJsonRequestBody(init);
|
|
if (method === "deleteWebhook")
|
|
return createRuntimeTelegramApiResponse(true);
|
|
if (method === "getUpdates") {
|
|
getUpdatesCalls += 1;
|
|
if (getUpdatesCalls === 1) {
|
|
return createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 1,
|
|
message: {
|
|
message_id: 60,
|
|
chat: { id: 99, type: "private" },
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
text: "/model",
|
|
},
|
|
},
|
|
]);
|
|
}
|
|
if (getUpdatesCalls === 2) return secondUpdates.promise;
|
|
throw new DOMException("stop", "AbortError");
|
|
}
|
|
if (method === "sendMessage" || method === "sendRichMessage") {
|
|
runtimeEvents.push(`send:${getRuntimeTelegramApiText(body)}`);
|
|
return createRuntimeTelegramApiResponse({ message_id: nextMessageId++ });
|
|
}
|
|
if (method === "editMessageText") {
|
|
runtimeEvents.push(`edit:${getRuntimeTelegramApiText(body)}`);
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
if (method === "answerCallbackQuery") {
|
|
callbackAnswers.push(String(body?.text ?? ""));
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
if (method === "sendChatAction")
|
|
return createRuntimeTelegramApiResponse(true);
|
|
throw new Error(`Unexpected Telegram API method: ${method}`);
|
|
});
|
|
try {
|
|
process.argv = [
|
|
previousArgv[0] ?? "node",
|
|
previousArgv[1] ?? "index.ts",
|
|
"--models=anthropic/claude-b:high",
|
|
];
|
|
await telegramConfig.write({
|
|
botToken: "123:abc",
|
|
allowedUserId: 77,
|
|
lastUpdateId: 0,
|
|
});
|
|
(await getRuntimeTelegramExtension())(pi);
|
|
const ctx = createRuntimeModelContext({
|
|
model: modelA,
|
|
availableModels: [modelA, modelB],
|
|
setStatus: (_slot, text) => {
|
|
statusEvents.push(text);
|
|
},
|
|
});
|
|
shutdownCtx = ctx;
|
|
await handlers.get("session_start")?.({}, ctx);
|
|
await commands.get("telegram-connect")?.handler("", ctx);
|
|
await waitForCondition(() =>
|
|
runtimeEvents.some((event) => event === "send:<b>🤖 Choose a model:</b>"),
|
|
);
|
|
const statusCountBeforePick = statusEvents.length;
|
|
secondUpdates.resolve(
|
|
createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 2,
|
|
callback_query: {
|
|
id: "cb-idle-1",
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
data: "model:pick:0",
|
|
message: {
|
|
message_id: 100,
|
|
chat: { id: 99, type: "private" },
|
|
},
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
await waitForCondition(() => setModels.length === 1);
|
|
assert.deepEqual(setModels, ["anthropic/claude-b"]);
|
|
assert.deepEqual(thinkingLevels, ["high"]);
|
|
assert.equal(callbackAnswers.includes("Switched to claude-b"), true);
|
|
assert.equal(statusEvents.length > statusCountBeforePick, true);
|
|
assert.equal(
|
|
runtimeEvents.some(
|
|
(event) =>
|
|
event.startsWith("edit:<b>Pi Telegram</b>") ||
|
|
event.startsWith("edit:<b>🤖 Choose a model:</b>"),
|
|
),
|
|
true,
|
|
);
|
|
} finally {
|
|
if (shutdownCtx) await handlers.get("session_shutdown")?.({}, shutdownCtx);
|
|
process.argv = previousArgv;
|
|
restoreFetch();
|
|
await telegramConfig.restore();
|
|
}
|
|
});
|
|
|
|
test("Extension runtime switches model in flight and dispatches a continuation turn after abort", async () => {
|
|
const telegramConfig = await createRuntimeTelegramConfigFixture();
|
|
const runtimeEvents: string[] = [];
|
|
const modelA = createRuntimeModel("openai", "gpt-a", true);
|
|
const modelB = createRuntimeModel("anthropic", "claude-b", false);
|
|
let idle = true;
|
|
let aborted = false;
|
|
const setModels: Array<string> = [];
|
|
const secondUpdates = createRuntimeDeferredResponse();
|
|
const thirdUpdates = createRuntimeDeferredResponse();
|
|
const { handlers, commands, pi } = createRuntimePiHarness({
|
|
sendUserMessage: (content) => {
|
|
recordRuntimeDispatchEvent(runtimeEvents, content);
|
|
},
|
|
setModel: async (model) => {
|
|
setModels.push(`${model.provider}/${model.id}`);
|
|
return true;
|
|
},
|
|
setThinkingLevel: () => {},
|
|
});
|
|
let getUpdatesCalls = 0;
|
|
let nextMessageId = 100;
|
|
const callbackAnswers: string[] = [];
|
|
const restoreFetch = setRuntimeTestFetch(async (input, init) => {
|
|
const method = getRuntimeTelegramApiMethod(input);
|
|
const body = parseJsonRequestBody(init);
|
|
if (method === "deleteWebhook") {
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
if (method === "getUpdates") {
|
|
getUpdatesCalls += 1;
|
|
if (getUpdatesCalls === 1) {
|
|
return createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 1,
|
|
message: {
|
|
message_id: 40,
|
|
chat: { id: 99, type: "private" },
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
text: "/model",
|
|
},
|
|
},
|
|
]);
|
|
}
|
|
if (getUpdatesCalls === 2) return secondUpdates.promise;
|
|
if (getUpdatesCalls === 3) return thirdUpdates.promise;
|
|
throw new DOMException("stop", "AbortError");
|
|
}
|
|
if (method === "sendMessage" || method === "sendRichMessage") {
|
|
runtimeEvents.push(`send:${getRuntimeTelegramApiText(body)}`);
|
|
return createRuntimeTelegramApiResponse({ message_id: nextMessageId++ });
|
|
}
|
|
if (method === "editMessageText") {
|
|
runtimeEvents.push(`edit:${getRuntimeTelegramApiText(body)}`);
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
if (method === "answerCallbackQuery") {
|
|
callbackAnswers.push(String(body?.text ?? ""));
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
if (method === "sendChatAction") {
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
throw new Error(`Unexpected Telegram API method: ${method}`);
|
|
});
|
|
try {
|
|
await telegramConfig.write({
|
|
botToken: "123:abc",
|
|
allowedUserId: 77,
|
|
lastUpdateId: 0,
|
|
});
|
|
(await getRuntimeTelegramExtension())(pi);
|
|
const ctx = createRuntimeModelContext({
|
|
model: modelA,
|
|
availableModels: [modelA, modelB],
|
|
isIdle: () => idle,
|
|
abort: () => {
|
|
aborted = true;
|
|
},
|
|
});
|
|
await handlers.get("session_start")?.({}, ctx);
|
|
await commands.get("telegram-connect")?.handler("", ctx);
|
|
await waitForCondition(() =>
|
|
runtimeEvents.some((event) => event === "send:<b>🤖 Choose a model:</b>"),
|
|
);
|
|
secondUpdates.resolve(
|
|
createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 2,
|
|
message: {
|
|
message_id: 41,
|
|
chat: { id: 99, type: "private" },
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
text: "first request",
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
await waitForCondition(() =>
|
|
runtimeEvents.some(
|
|
(event) => event === "dispatch:[telegram] first request",
|
|
),
|
|
);
|
|
idle = false;
|
|
await handlers.get("agent_start")?.({}, ctx);
|
|
thirdUpdates.resolve(
|
|
createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 3,
|
|
callback_query: {
|
|
id: "cb-1",
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
data: "model:pick:1",
|
|
message: {
|
|
message_id: 100,
|
|
chat: { id: 99, type: "private" },
|
|
},
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
await waitForCondition(() => aborted);
|
|
assert.deepEqual(setModels, ["anthropic/claude-b"]);
|
|
assert.equal(
|
|
callbackAnswers.includes("Switching to claude-b and continuing…"),
|
|
true,
|
|
);
|
|
idle = true;
|
|
await handlers.get("agent_end")?.(
|
|
{
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
stopReason: "aborted",
|
|
content: [{ type: "text", text: "" }],
|
|
},
|
|
],
|
|
},
|
|
ctx,
|
|
);
|
|
await waitForCondition(() =>
|
|
runtimeEvents.some((event) =>
|
|
event.includes(
|
|
"Continue the interrupted previous Telegram request using the newly selected model (anthropic/claude-b)",
|
|
),
|
|
),
|
|
);
|
|
assert.equal(
|
|
runtimeEvents.includes("dispatch:[telegram] first request"),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
runtimeEvents.some((event) =>
|
|
event.includes(
|
|
"dispatch:[telegram] Continue the interrupted previous Telegram request using the newly selected model (anthropic/claude-b)",
|
|
),
|
|
),
|
|
true,
|
|
);
|
|
await handlers.get("session_shutdown")?.({}, ctx);
|
|
} finally {
|
|
restoreFetch();
|
|
await telegramConfig.restore();
|
|
}
|
|
});
|
|
|
|
test("Extension runtime preserves long-session queue through abort, next, and model switch", async () => {
|
|
const telegramConfig = await createRuntimeTelegramConfigFixture();
|
|
const runtimeEvents: string[] = [];
|
|
const modelA = createRuntimeModel("openai", "gpt-a", true);
|
|
const modelB = createRuntimeModel("anthropic", "claude-b", false);
|
|
let idle = true;
|
|
let abortCount = 0;
|
|
const setModels: Array<string> = [];
|
|
const updates = Array.from({ length: 5 }, () =>
|
|
createRuntimeDeferredResponse(),
|
|
);
|
|
const { handlers, commands, pi } = createRuntimePiHarness({
|
|
sendUserMessage: (content) => {
|
|
recordRuntimeDispatchEvent(runtimeEvents, content);
|
|
},
|
|
setModel: async (model) => {
|
|
setModels.push(`${model.provider}/${model.id}`);
|
|
return true;
|
|
},
|
|
setThinkingLevel: () => {},
|
|
});
|
|
let getUpdatesCalls = 0;
|
|
let nextMessageId = 100;
|
|
const callbackAnswers: string[] = [];
|
|
const restoreFetch = setRuntimeTestFetch(async (input, init) => {
|
|
const method = getRuntimeTelegramApiMethod(input);
|
|
const body = parseJsonRequestBody(init);
|
|
if (method === "deleteWebhook")
|
|
return createRuntimeTelegramApiResponse(true);
|
|
if (method === "getUpdates") {
|
|
getUpdatesCalls += 1;
|
|
if (getUpdatesCalls === 1) {
|
|
return createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 1,
|
|
message: {
|
|
message_id: 70,
|
|
chat: { id: 99, type: "private" },
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
text: "/model",
|
|
},
|
|
},
|
|
]);
|
|
}
|
|
const update = updates[getUpdatesCalls - 2];
|
|
if (update) return update.promise;
|
|
throw new DOMException("stop", "AbortError");
|
|
}
|
|
if (method === "sendMessage" || method === "sendRichMessage") {
|
|
runtimeEvents.push(`send:${getRuntimeTelegramApiText(body)}`);
|
|
return createRuntimeTelegramApiResponse({ message_id: nextMessageId++ });
|
|
}
|
|
if (method === "editMessageText") {
|
|
runtimeEvents.push(`edit:${getRuntimeTelegramApiText(body)}`);
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
if (method === "answerCallbackQuery") {
|
|
callbackAnswers.push(String(body?.text ?? ""));
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
if (method === "sendChatAction")
|
|
return createRuntimeTelegramApiResponse(true);
|
|
throw new Error(`Unexpected Telegram API method: ${method}`);
|
|
});
|
|
try {
|
|
await telegramConfig.write({
|
|
botToken: "123:abc",
|
|
allowedUserId: 77,
|
|
lastUpdateId: 0,
|
|
});
|
|
(await getRuntimeTelegramExtension())(pi);
|
|
const ctx = createRuntimeModelContext({
|
|
model: modelA,
|
|
availableModels: [modelA, modelB],
|
|
isIdle: () => idle,
|
|
abort: () => {
|
|
abortCount += 1;
|
|
},
|
|
});
|
|
await handlers.get("session_start")?.({}, ctx);
|
|
await commands.get("telegram-connect")?.handler("", ctx);
|
|
await waitForCondition(() =>
|
|
runtimeEvents.includes("send:<b>🤖 Choose a model:</b>"),
|
|
);
|
|
updates[0].resolve(
|
|
createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 2,
|
|
message: {
|
|
message_id: 71,
|
|
chat: { id: 99, type: "private" },
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
text: "first long-session request",
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
await waitForCondition(() =>
|
|
runtimeEvents.includes("dispatch:[telegram] first long-session request"),
|
|
);
|
|
idle = false;
|
|
await handlers.get("agent_start")?.({}, ctx);
|
|
updates[1].resolve(
|
|
createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 3,
|
|
message: {
|
|
message_id: 72,
|
|
chat: { id: 99, type: "private" },
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
text: "queued after abort",
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
await waitForCondition(() => getUpdatesCalls >= 4);
|
|
assert.equal(
|
|
runtimeEvents.includes("dispatch:[telegram] queued after abort"),
|
|
false,
|
|
);
|
|
updates[2].resolve(
|
|
createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 4,
|
|
message: {
|
|
message_id: 73,
|
|
chat: { id: 99, type: "private" },
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
text: "/abort",
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
await waitForCondition(() => abortCount === 1);
|
|
idle = true;
|
|
await handlers.get("agent_end")?.(
|
|
{
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
stopReason: "aborted",
|
|
content: [{ type: "text", text: "" }],
|
|
},
|
|
],
|
|
},
|
|
ctx,
|
|
);
|
|
assert.equal(
|
|
runtimeEvents.includes("dispatch:[telegram] queued after abort"),
|
|
false,
|
|
);
|
|
updates[3].resolve(
|
|
createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 5,
|
|
message: {
|
|
message_id: 74,
|
|
chat: { id: 99, type: "private" },
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
text: "/next",
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
await waitForCondition(() =>
|
|
runtimeEvents.includes("dispatch:[telegram] queued after abort"),
|
|
);
|
|
idle = false;
|
|
await handlers.get("agent_start")?.({}, ctx);
|
|
updates[4].resolve(
|
|
createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 6,
|
|
callback_query: {
|
|
id: "cb-long-session",
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
data: "model:pick:1",
|
|
message: {
|
|
message_id: 100,
|
|
chat: { id: 99, type: "private" },
|
|
},
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
await waitForCondition(() => abortCount === 2);
|
|
assert.deepEqual(setModels, ["anthropic/claude-b"]);
|
|
assert.equal(
|
|
callbackAnswers.includes("Switching to claude-b and continuing…"),
|
|
true,
|
|
);
|
|
idle = true;
|
|
await handlers.get("agent_end")?.(
|
|
{
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
stopReason: "aborted",
|
|
content: [{ type: "text", text: "" }],
|
|
},
|
|
],
|
|
},
|
|
ctx,
|
|
);
|
|
assert.equal(
|
|
runtimeEvents.includes("dispatch:[telegram] queued after abort"),
|
|
true,
|
|
);
|
|
await handlers.get("session_shutdown")?.({}, ctx);
|
|
} finally {
|
|
restoreFetch();
|
|
await telegramConfig.restore();
|
|
}
|
|
});
|
|
|
|
test("Extension runtime delays model-switch abort until the active tool finishes", async () => {
|
|
const telegramConfig = await createRuntimeTelegramConfigFixture();
|
|
const runtimeEvents: string[] = [];
|
|
const modelA = createRuntimeModel("openai", "gpt-a", true);
|
|
const modelB = createRuntimeModel("anthropic", "claude-b", false);
|
|
let idle = true;
|
|
let aborted = false;
|
|
const setModels: Array<string> = [];
|
|
const secondUpdates = createRuntimeDeferredResponse();
|
|
const thirdUpdates = createRuntimeDeferredResponse();
|
|
const { handlers, commands, pi } = createRuntimePiHarness({
|
|
sendUserMessage: (content) => {
|
|
recordRuntimeDispatchEvent(runtimeEvents, content);
|
|
},
|
|
setModel: async (model) => {
|
|
setModels.push(`${model.provider}/${model.id}`);
|
|
return true;
|
|
},
|
|
setThinkingLevel: () => {},
|
|
});
|
|
let getUpdatesCalls = 0;
|
|
let nextMessageId = 100;
|
|
const callbackAnswers: string[] = [];
|
|
const restoreFetch = setRuntimeTestFetch(async (input, init) => {
|
|
const method = getRuntimeTelegramApiMethod(input);
|
|
const body = parseJsonRequestBody(init);
|
|
if (method === "deleteWebhook") {
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
if (method === "getUpdates") {
|
|
getUpdatesCalls += 1;
|
|
if (getUpdatesCalls === 1) {
|
|
return createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 1,
|
|
message: {
|
|
message_id: 50,
|
|
chat: { id: 99, type: "private" },
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
text: "/model",
|
|
},
|
|
},
|
|
]);
|
|
}
|
|
if (getUpdatesCalls === 2) return secondUpdates.promise;
|
|
if (getUpdatesCalls === 3) return thirdUpdates.promise;
|
|
throw new DOMException("stop", "AbortError");
|
|
}
|
|
if (method === "sendMessage" || method === "sendRichMessage") {
|
|
runtimeEvents.push(`send:${getRuntimeTelegramApiText(body)}`);
|
|
return createRuntimeTelegramApiResponse({ message_id: nextMessageId++ });
|
|
}
|
|
if (method === "editMessageText") {
|
|
runtimeEvents.push(`edit:${getRuntimeTelegramApiText(body)}`);
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
if (method === "answerCallbackQuery") {
|
|
callbackAnswers.push(String(body?.text ?? ""));
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
if (method === "sendChatAction") {
|
|
return createRuntimeTelegramApiResponse(true);
|
|
}
|
|
throw new Error(`Unexpected Telegram API method: ${method}`);
|
|
});
|
|
try {
|
|
await telegramConfig.write({
|
|
botToken: "123:abc",
|
|
allowedUserId: 77,
|
|
lastUpdateId: 0,
|
|
});
|
|
(await getRuntimeTelegramExtension())(pi);
|
|
const ctx = createRuntimeModelContext({
|
|
model: modelA,
|
|
availableModels: [modelA, modelB],
|
|
isIdle: () => idle,
|
|
abort: () => {
|
|
aborted = true;
|
|
},
|
|
});
|
|
await handlers.get("session_start")?.({}, ctx);
|
|
await commands.get("telegram-connect")?.handler("", ctx);
|
|
await waitForCondition(() =>
|
|
runtimeEvents.some((event) => event === "send:<b>🤖 Choose a model:</b>"),
|
|
);
|
|
secondUpdates.resolve(
|
|
createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 2,
|
|
message: {
|
|
message_id: 51,
|
|
chat: { id: 99, type: "private" },
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
text: "first request",
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
await waitForCondition(() =>
|
|
runtimeEvents.some(
|
|
(event) => event === "dispatch:[telegram] first request",
|
|
),
|
|
);
|
|
idle = false;
|
|
await handlers.get("agent_start")?.({}, ctx);
|
|
await handlers.get("tool_execution_start")?.({}, ctx);
|
|
thirdUpdates.resolve(
|
|
createRuntimeTelegramApiResponse([
|
|
{
|
|
_: "other",
|
|
update_id: 3,
|
|
callback_query: {
|
|
id: "cb-2",
|
|
from: { id: 77, is_bot: false, first_name: "Test" },
|
|
data: "model:pick:1",
|
|
message: {
|
|
message_id: 100,
|
|
chat: { id: 99, type: "private" },
|
|
},
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
await waitForCondition(() =>
|
|
callbackAnswers.includes(
|
|
"Switched to claude-b. Restarting after the current tool finishes…",
|
|
),
|
|
);
|
|
assert.deepEqual(setModels, ["anthropic/claude-b"]);
|
|
assert.equal(aborted, false);
|
|
await handlers.get("tool_execution_end")?.({}, ctx);
|
|
await waitForCondition(() => aborted);
|
|
idle = true;
|
|
await handlers.get("agent_end")?.(
|
|
{
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
stopReason: "aborted",
|
|
content: [{ type: "text", text: "" }],
|
|
},
|
|
],
|
|
},
|
|
ctx,
|
|
);
|
|
await waitForCondition(() =>
|
|
runtimeEvents.some((event) =>
|
|
event.includes(
|
|
"dispatch:[telegram] Continue the interrupted previous Telegram request using the newly selected model (anthropic/claude-b)",
|
|
),
|
|
),
|
|
);
|
|
await handlers.get("session_shutdown")?.({}, ctx);
|
|
} finally {
|
|
restoreFetch();
|
|
await telegramConfig.restore();
|
|
}
|
|
});
|