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

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

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

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

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

2397 lines
71 KiB
TypeScript

/**
* Regression tests for Telegram menu helpers
* Covers inline model/status/thinking menu state, callbacks, and transport payloads
*/
import assert from "node:assert/strict";
import test from "node:test";
import { createTelegramQueueMenuRuntime } from "../lib/menu-queue.ts";
import {
buildProactivePushSettingsReplyMarkup,
buildProactivePushSettingsText,
buildTelegramSettingsMenuReplyMarkup,
buildTimeInjectionModeSettingsReplyMarkup,
buildTimeInjectionModeSettingsText,
buildVoiceReplyModeSettingsReplyMarkup,
buildVoiceReplyModeSettingsText,
createTelegramSettingsMenuRuntime,
} from "../lib/menu-settings.ts";
import {
applyTelegramModelPageSelection,
applyTelegramModelScopeSelection,
buildModelMenuReplyMarkup,
buildModelPageMenuReplyMarkup,
buildStatusReplyMarkup,
buildTelegramModelCallbackPlan,
buildTelegramModelMenuRenderPayload,
buildTelegramModelMenuState,
buildTelegramModelMenuStateRuntime,
buildTelegramModelPageMenuRenderPayload,
buildTelegramStatusMenuRenderPayload,
buildTelegramThinkingMenuRenderPayload,
buildThinkingMenuReplyMarkup,
buildThinkingMenuText,
createTelegramMenuActionRuntime,
createTelegramMenuActionRuntimeWithStateBuilder,
createTelegramMenuCallbackHandler,
createTelegramMenuCallbackHandlerForContext,
createTelegramModelMenuRuntime,
createTelegramModelMenuStateBuilder,
formatScopedModelButtonText,
getModelMenuItems,
getStoredTelegramModelMenuState,
getTelegramModelMenuPage,
getTelegramModelSelection,
handleStoredTelegramMenuCallback,
handleTelegramMenuCallbackEntry,
handleTelegramMenuCallbackRuntime,
handleTelegramModelMenuCallbackAction,
handleTelegramStatusMenuCallbackAction,
handleTelegramThinkingMenuCallbackAction,
MODEL_MENU_TITLE,
MODEL_PAGE_MENU_TITLE,
openTelegramModelMenu,
openTelegramStatusMenu,
parseTelegramMenuCallbackAction,
resolveCachedTelegramModelMenuInputs,
sendTelegramModelMenuMessage,
sendTelegramStatusMessage,
storeTelegramModelMenuState,
TELEGRAM_MODEL_PAGE_SIZE,
type TelegramModelMenuState,
updateTelegramModelMenuMessage,
updateTelegramStatusMessage,
updateTelegramThinkingMenuMessage,
} from "../lib/menu.ts";
import type { MenuModel } from "../lib/model.ts";
import type { TelegramQueueItem } from "../lib/queue.ts";
import { createTelegramExtensionSectionRegistry } from "../lib/sections.ts";
function createMenuState<TModel extends MenuModel = MenuModel>(
messageId: number,
overrides: Partial<TelegramModelMenuState<TModel>> = {},
): TelegramModelMenuState<TModel> {
return {
chatId: 1,
messageId,
page: 0,
scope: "scoped",
scopedModels: [],
allModels: [],
scopedModelPatterns: [],
canMutateScope: true,
mode: "model",
...overrides,
};
}
function createMenuModel(
provider: string,
id: string,
reasoning?: boolean,
): MenuModel {
return reasoning === undefined
? { provider, id }
: { provider, id, reasoning };
}
test("Menu helpers store, refresh, prune, and bound model menu state", () => {
const menus = new Map();
storeTelegramModelMenuState(menus, createMenuState(1), {
maxAgeMs: 100,
maxStoredMenus: 2,
now: 1000,
});
storeTelegramModelMenuState(menus, createMenuState(2), {
maxAgeMs: 100,
maxStoredMenus: 2,
now: 1010,
});
assert.equal(
getStoredTelegramModelMenuState(menus, 1, {
maxAgeMs: 100,
maxStoredMenus: 2,
now: 1020,
})?.messageId,
1,
);
storeTelegramModelMenuState(menus, createMenuState(3), {
maxAgeMs: 100,
maxStoredMenus: 2,
now: 1030,
});
assert.equal(menus.has("1:2"), false);
assert.equal(menus.has("1:1"), true);
assert.equal(menus.has("1:3"), true);
assert.equal(
getStoredTelegramModelMenuState(menus, 1, {
maxAgeMs: 10,
maxStoredMenus: 2,
now: 1050,
}),
undefined,
);
});
test("Menu helpers scope stored model menu state by chat and message", () => {
const menus = new Map();
storeTelegramModelMenuState(
menus,
{ ...createMenuState(1), chatId: 10, note: "chat-10" },
{ maxAgeMs: 100, maxStoredMenus: 5, now: 1000 },
);
storeTelegramModelMenuState(
menus,
{ ...createMenuState(1), chatId: 20, note: "chat-20" },
{ maxAgeMs: 100, maxStoredMenus: 5, now: 1000 },
);
assert.equal(
getStoredTelegramModelMenuState(
menus,
1,
{ maxAgeMs: 100, maxStoredMenus: 5, now: 1010 },
20,
)?.note,
"chat-20",
);
assert.equal(
getStoredTelegramModelMenuState(
menus,
1,
{ maxAgeMs: 100, maxStoredMenus: 5, now: 1010 },
10,
)?.note,
"chat-10",
);
});
test("Menu helpers resolve and reuse cached model menu inputs", async () => {
const model = { provider: "test", id: "alpha" };
let reloadCount = 0;
const resolved = await resolveCachedTelegramModelMenuInputs(undefined, {
cacheTtlMs: 100,
now: 1000,
reloadSettings: async () => {
reloadCount += 1;
},
refreshAvailableModels: () => [model],
getConfiguredScopedModelPatterns: () => ["configured"],
getCliScopedModelPatterns: () => ["cli"],
});
assert.deepEqual(resolved, {
expiresAt: 1100,
availableModels: [model],
configuredScopedModelPatterns: ["cli"],
cliScopedModelPatterns: ["cli"],
});
assert.equal(reloadCount, 1);
assert.equal(
await resolveCachedTelegramModelMenuInputs(resolved, {
cacheTtlMs: 100,
now: 1099,
reloadSettings: async () => {
reloadCount += 1;
},
refreshAvailableModels: () => [],
getConfiguredScopedModelPatterns: () => [],
getCliScopedModelPatterns: () => undefined,
}),
resolved,
);
assert.equal(reloadCount, 1);
});
test("Menu runtime controller owns stored state and cached inputs", async () => {
let reloadCount = 0;
let refreshCount = 0;
const model = createMenuModel("openai", "gpt-5");
const runtime = createTelegramModelMenuRuntime({
maxAgeMs: 100,
maxStoredMenus: 2,
});
runtime.storeState(createMenuState(11));
assert.equal(runtime.getState(11)?.messageId, 11);
const firstState = await runtime.buildState({
chatId: 42,
activeModel: model,
ctx: {
modelRegistry: {
refresh: () => {
refreshCount += 1;
},
getAvailable: () => [model],
},
},
reloadSettings: async () => {
reloadCount += 1;
},
getConfiguredScopedModelPatterns: () => ["openai/gpt-5"],
});
const secondState = await runtime.buildState({
chatId: 42,
activeModel: model,
ctx: {
modelRegistry: {
refresh: () => {
refreshCount += 1;
},
getAvailable: () => [model],
},
},
reloadSettings: async () => {
reloadCount += 1;
},
getConfiguredScopedModelPatterns: () => ["openai/gpt-5"],
});
assert.equal(firstState.chatId, 42);
assert.equal(secondState.chatId, 42);
assert.equal(reloadCount, 1);
assert.equal(refreshCount, 1);
runtime.clearCachedInputs();
assert.equal(runtime.getState(11)?.messageId, 11);
runtime.clear();
assert.equal(runtime.getState(11), undefined);
});
test("Menu state builder wires runtime to settings and model-registry ports", async () => {
let createdForCwd = "";
let reloadCount = 0;
let refreshCount = 0;
const model = createMenuModel("openai", "gpt-5");
const runtime = createTelegramModelMenuRuntime<typeof model>();
const getModelMenuState = createTelegramModelMenuStateBuilder({
runtime,
createSettingsManager: (cwd) => {
createdForCwd = cwd;
return {
reload: async () => {
reloadCount += 1;
},
getEnabledModels: () => ["openai/gpt-5"],
};
},
getActiveModel: () => model,
});
const state = await getModelMenuState(42, {
cwd: "/tmp/project",
modelRegistry: {
refresh: () => {
refreshCount += 1;
},
getAvailable: () => [model],
},
});
assert.equal(createdForCwd, "/tmp/project");
assert.equal(reloadCount, 1);
assert.equal(refreshCount, 1);
assert.equal(state.chatId, 42);
assert.deepEqual(
state.scopedModels.map((entry) => entry.model.id),
["gpt-5"],
);
});
test("Menu runtime builds menu state from settings and model-registry ports", async () => {
let reloadCount = 0;
let refreshCount = 0;
const model = createMenuModel("openai", "gpt-5");
const result = await buildTelegramModelMenuStateRuntime({
chatId: 42,
activeModel: model,
cachedInputs: undefined,
cacheTtlMs: 5_000,
ctx: {
modelRegistry: {
refresh: () => {
refreshCount += 1;
},
getAvailable: () => [model],
},
},
reloadSettings: async () => {
reloadCount += 1;
},
getConfiguredScopedModelPatterns: () => ["configured"],
getCliScopedModelPatterns: () => ["openai/gpt-5"],
});
assert.equal(reloadCount, 1);
assert.equal(refreshCount, 1);
assert.equal(result.state.chatId, 42);
assert.deepEqual(
result.state.allModels.map((entry) => entry.model.id),
["gpt-5"],
);
assert.deepEqual(result.cachedInputs.availableModels, [model]);
});
test("Menu helpers expose UI constants", () => {
assert.equal(MODEL_MENU_TITLE, "<b>🤖 Choose a model:</b>");
assert.equal(MODEL_PAGE_MENU_TITLE, "<b>Choose a page:</b>");
assert.equal(TELEGRAM_MODEL_PAGE_SIZE, 6);
});
test("Menu helpers build model menu state and parse callback actions", () => {
const modelA = createMenuModel("openai", "gpt-5", true);
const modelB = createMenuModel("anthropic", "claude-3", false);
const state = buildTelegramModelMenuState({
chatId: 1,
activeModel: modelA,
availableModels: [modelA, modelB],
configuredScopedModelPatterns: ["missing-model"],
cliScopedModelPatterns: ["missing-model"],
});
assert.equal(state.chatId, 1);
assert.equal(state.scope, "all");
assert.match(state.note ?? "", /No CLI scoped models matched/);
assert.deepEqual(parseTelegramMenuCallbackAction("menu:model"), {
kind: "status",
action: "model",
});
assert.deepEqual(parseTelegramMenuCallbackAction("status:model"), {
kind: "status",
action: "model",
});
assert.deepEqual(parseTelegramMenuCallbackAction("menu:queue"), {
kind: "status",
action: "queue",
});
assert.deepEqual(parseTelegramMenuCallbackAction("status:queue"), {
kind: "status",
action: "queue",
});
assert.deepEqual(parseTelegramMenuCallbackAction("thinking:set:high"), {
kind: "thinking:set",
level: "high",
});
assert.deepEqual(parseTelegramMenuCallbackAction("model:pick:2"), {
kind: "model",
action: "pick",
value: "2",
});
assert.deepEqual(parseTelegramMenuCallbackAction("model:pages"), {
kind: "model",
action: "pages",
value: undefined,
});
assert.deepEqual(parseTelegramMenuCallbackAction("unknown"), {
kind: "ignore",
});
});
test("Menu helpers apply menu mutations and resolve model selections", () => {
const modelA = createMenuModel("openai", "gpt-5", true);
const state = createMenuState(2, {
scope: "all",
scopedModels: [{ model: modelA, thinkingLevel: "high" }],
allModels: [{ model: modelA }],
mode: "status",
});
assert.equal(applyTelegramModelScopeSelection(state, "scoped"), "changed");
assert.equal(state.scope, "scoped");
assert.equal(applyTelegramModelScopeSelection(state, "scoped"), "unchanged");
assert.equal(applyTelegramModelScopeSelection(state, "bad"), "invalid");
assert.equal(applyTelegramModelPageSelection(state, "2"), "changed");
assert.equal(state.page, 2);
assert.equal(applyTelegramModelPageSelection(state, "2"), "unchanged");
assert.equal(applyTelegramModelPageSelection(state, "bad"), "invalid");
assert.deepEqual(getTelegramModelSelection(state, "bad"), {
kind: "invalid",
});
assert.deepEqual(getTelegramModelSelection(state, "9"), { kind: "missing" });
assert.equal(getTelegramModelSelection(state, "0").kind, "selected");
});
test("Menu helpers omit pagination controls for one-page unscoped model lists", () => {
const modelA = createMenuModel("openai", "gpt-5");
const modelB = createMenuModel("openai", "gpt-5-mini");
const state = createMenuState<MenuModel>(2, {
scope: "all",
scopedModels: [],
allModels: [{ model: modelA }, { model: modelB }],
});
const markup = buildModelMenuReplyMarkup(state, modelA, 6);
assert.deepEqual(
markup.inline_keyboard.map((row) => row.map((button) => button.callback_data)),
[["menu:back"], ["model:open:0"], ["model:open:1"]],
);
});
test("Menu helpers derive normalized menu pages without mutating state", () => {
const modelA = createMenuModel("openai", "gpt-5");
const modelB = createMenuModel("anthropic", "claude-3");
const state = createMenuState<MenuModel>(2, {
page: 99,
scope: "all",
allModels: [{ model: modelA }, { model: modelB }],
});
const menuPage = getTelegramModelMenuPage(state, 1);
assert.equal(menuPage.page, 1);
assert.equal(menuPage.pageCount, 2);
assert.equal(menuPage.start, 1);
assert.deepEqual(menuPage.items, [{ model: modelB }]);
assert.equal(state.page, 99);
const markup = buildModelMenuReplyMarkup(state, modelA, 1);
assert.equal(markup.inline_keyboard[0]?.[0]?.text, "⬆️ Main menu");
assert.equal(markup.inline_keyboard[0]?.[0]?.callback_data, "menu:back");
assert.equal(markup.inline_keyboard[1]?.[1]?.text, "2/2");
assert.equal(markup.inline_keyboard[1]?.[1]?.callback_data, "model:pages");
assert.equal(state.page, 99);
});
test("Menu helpers build model page selector markup and payloads", () => {
const models = Array.from({ length: 31 }, (_unused, index) =>
createMenuModel("test", `model-${index + 1}`),
);
const state = createMenuState<MenuModel>(2, {
page: 1,
scope: "all",
allModels: models.map((model) => ({ model })),
mode: "model-pages",
});
const markup = buildModelPageMenuReplyMarkup(state, TELEGRAM_MODEL_PAGE_SIZE);
const payload = buildTelegramModelPageMenuRenderPayload(state);
assert.equal(markup.inline_keyboard[0]?.[0]?.text, "⬆️ Back");
assert.equal(
markup.inline_keyboard[0]?.[0]?.callback_data,
"model:pages:back",
);
assert.deepEqual(
markup.inline_keyboard[1]?.map((button) => button.text),
["1", "🟣 2", "3", "4"],
);
assert.deepEqual(
markup.inline_keyboard[2]?.map((button) => button.text),
["5", "6"],
);
assert.equal(payload.nextMode, "model-pages");
assert.equal(payload.text, "<b>Choose a page:</b>");
assert.equal(payload.mode, "html");
});
test("Menu helpers build model callback plans for paging, page menu, selection, and restart modes", () => {
const modelA = createMenuModel("openai", "gpt-5", true);
const modelB = createMenuModel("anthropic", "claude-3", false);
const state = createMenuState<MenuModel>(2, {
scope: "all",
scopedModels: [{ model: modelA, thinkingLevel: "high" }],
allModels: [{ model: modelA }, { model: modelB }],
});
assert.deepEqual(
buildTelegramModelCallbackPlan({
data: "model:pages",
state,
activeModel: modelA,
currentThinkingLevel: "medium",
isIdle: true,
canRestartBusyRun: false,
hasActiveToolExecutions: false,
}),
{ kind: "update-menu" },
);
assert.equal(state.mode, "model-pages");
assert.deepEqual(
buildTelegramModelCallbackPlan({
data: "model:pages:back",
state,
activeModel: modelA,
currentThinkingLevel: "medium",
isIdle: true,
canRestartBusyRun: false,
hasActiveToolExecutions: false,
}),
{ kind: "update-menu" },
);
assert.equal(state.mode, "model");
assert.deepEqual(
buildTelegramModelCallbackPlan({
data: "model:open:1",
state,
activeModel: modelA,
currentThinkingLevel: "medium",
isIdle: true,
canRestartBusyRun: false,
hasActiveToolExecutions: false,
}),
{ kind: "update-menu" },
);
assert.equal(state.mode, "model-detail");
assert.equal(state.selectedModelIndex, 1);
assert.equal(state.selectedModelKey, "anthropic/claude-3");
assert.deepEqual(
buildTelegramModelCallbackPlan({
data: "model:scope-toggle",
state,
activeModel: modelA,
currentThinkingLevel: "medium",
isIdle: true,
canRestartBusyRun: false,
hasActiveToolExecutions: false,
}),
{
kind: "persist-scope",
patterns: ["anthropic/claude-3"],
text: "Added to scoped models",
},
);
assert.deepEqual(
buildTelegramModelCallbackPlan({
data: "model:page:1",
state,
activeModel: modelA,
currentThinkingLevel: "medium",
isIdle: true,
canRestartBusyRun: false,
hasActiveToolExecutions: false,
}),
{ kind: "update-menu" },
);
assert.deepEqual(
buildTelegramModelCallbackPlan({
data: "model:pick:0",
state,
activeModel: modelA,
currentThinkingLevel: "medium",
isIdle: true,
canRestartBusyRun: false,
hasActiveToolExecutions: false,
}),
{
kind: "refresh-status",
selection: state.allModels[0],
callbackText: "Model: gpt-5",
shouldApplyThinkingLevel: false,
},
);
assert.deepEqual(
buildTelegramModelCallbackPlan({
data: "model:pick:1",
state,
activeModel: modelA,
currentThinkingLevel: "medium",
isIdle: false,
canRestartBusyRun: true,
hasActiveToolExecutions: true,
}),
{
kind: "switch-model",
selection: state.allModels[1],
mode: "restart-after-tool",
callbackText:
"Switched to claude-3. Restarting after the current tool finishes…",
},
);
assert.deepEqual(
buildTelegramModelCallbackPlan({
data: "model:pick:1",
state,
activeModel: modelA,
currentThinkingLevel: "medium",
isIdle: false,
canRestartBusyRun: false,
hasActiveToolExecutions: false,
}),
{ kind: "answer", text: "Pi is busy. Send /abort, /next, or /stop." },
);
});
test("Menu helpers keep model detail selection stable after scope changes", () => {
const modelA = createMenuModel("openai", "gpt-5");
const modelB = createMenuModel("anthropic", "claude-3");
const state = createMenuState<MenuModel>(2, {
scope: "scoped",
scopedModels: [{ model: modelA }, { model: modelB, thinkingLevel: "high" }],
allModels: [{ model: modelA }, { model: modelB }],
scopedModelPatterns: ["openai/gpt-5", "anthropic/claude-3:high"],
});
assert.deepEqual(
buildTelegramModelCallbackPlan({
data: "model:open:1",
state,
activeModel: modelA,
currentThinkingLevel: "medium",
isIdle: true,
canRestartBusyRun: false,
hasActiveToolExecutions: false,
}),
{ kind: "update-menu" },
);
assert.deepEqual(
buildTelegramModelCallbackPlan({
data: "model:pick-selected",
state,
activeModel: modelA,
currentThinkingLevel: "medium",
isIdle: true,
canRestartBusyRun: false,
hasActiveToolExecutions: false,
}),
{
kind: "switch-model",
selection: state.scopedModels[1],
mode: "idle",
callbackText: "Switched to claude-3",
},
);
assert.deepEqual(
buildTelegramModelCallbackPlan({
data: "model:scope-toggle",
state,
activeModel: modelA,
currentThinkingLevel: "medium",
isIdle: true,
canRestartBusyRun: false,
hasActiveToolExecutions: false,
}),
{
kind: "persist-scope",
patterns: ["openai/gpt-5"],
text: "Removed from scoped models",
},
);
assert.deepEqual(
buildTelegramModelCallbackPlan({
data: "model:pick-selected",
state,
activeModel: modelA,
currentThinkingLevel: "medium",
isIdle: true,
canRestartBusyRun: false,
hasActiveToolExecutions: false,
}),
{
kind: "switch-model",
selection: state.allModels[1],
mode: "idle",
callbackText: "Switched to claude-3",
},
);
});
test("Menu helpers expand wildcard scope patterns when disabling one matched model", () => {
const modelA = createMenuModel("openai", "gpt-5");
const modelB = createMenuModel("openai", "gpt-4");
const modelC = createMenuModel("anthropic", "claude-3");
const state = createMenuState<MenuModel>(2, {
scope: "scoped",
scopedModels: [
{ model: modelA, thinkingLevel: "high" },
{ model: modelB, thinkingLevel: "high" },
{ model: modelC },
],
allModels: [{ model: modelA }, { model: modelB }, { model: modelC }],
scopedModelPatterns: [
"openai/*:high",
"anthropic/claude-3",
"future/model",
],
selectedModelKey: "openai/gpt-5",
mode: "model-detail",
});
assert.deepEqual(
buildTelegramModelCallbackPlan({
data: "model:scope-disable",
state,
activeModel: modelA,
currentThinkingLevel: "medium",
isIdle: true,
canRestartBusyRun: false,
hasActiveToolExecutions: false,
}),
{
kind: "persist-scope",
patterns: ["openai/gpt-4:high", "anthropic/claude-3", "future/model"],
text: "Removed from scoped models",
},
);
assert.equal(
state.scopedModels.some((entry) => entry.model === modelA),
false,
);
});
test("Menu helpers apply scoped thinking from active model detail", () => {
const model = createMenuModel("openai", "gpt-5");
const state = createMenuState<MenuModel>(2, {
scope: "scoped",
scopedModels: [{ model, thinkingLevel: "high" }],
allModels: [{ model }],
selectedModelIndex: 0,
selectedModelKey: "openai/gpt-5",
mode: "model-detail",
});
assert.deepEqual(
buildTelegramModelCallbackPlan({
data: "model:pick-selected",
state,
activeModel: model,
currentThinkingLevel: "medium",
isIdle: true,
canRestartBusyRun: false,
hasActiveToolExecutions: false,
}),
{
kind: "refresh-status",
selection: state.scopedModels[0],
callbackText: "Model: gpt-5",
shouldApplyThinkingLevel: true,
},
);
assert.equal(state.mode, "model");
});
test("Menu helpers send active detail selection back to model list page", () => {
const models = Array.from({ length: 8 }, (_value, index) =>
createMenuModel("openai", `model-${index}`),
);
const activeModel = models[7]!;
const state = createMenuState<MenuModel>(2, {
scope: "all",
scopedModels: [],
allModels: models.map((model) => ({ model })),
selectedModelKey: "openai/model-7",
mode: "model-detail",
});
assert.deepEqual(
buildTelegramModelCallbackPlan({
data: "model:pick-selected",
state,
activeModel,
currentThinkingLevel: "medium",
isIdle: true,
canRestartBusyRun: false,
hasActiveToolExecutions: false,
}),
{
kind: "refresh-status",
selection: state.allModels[7],
callbackText: "Model: model-7",
shouldApplyThinkingLevel: false,
},
);
assert.equal(state.mode, "model");
assert.equal(state.page, 1);
});
test("Menu helpers open status and model menus through runtime ports", async () => {
const events: string[] = [];
const model = { provider: "test", id: "alpha", reasoning: true };
const statusState = createMenuState(0);
await openTelegramStatusMenu({
isIdle: () => true,
sendBusyMessage: async () => {
events.push("unexpected:busy-status");
},
getModelMenuState: async () => statusState,
buildStatusHtml: () => "status-html",
getActiveModel: () => model,
getThinkingLevel: () => "medium",
sendStatusMenu: async (state, html, activeModel, level) => {
events.push(`status:${state.chatId}:${html}:${activeModel?.id}:${level}`);
return 11;
},
storeModelMenuState: (state) => {
events.push(`store:${state.messageId}:${state.mode}`);
},
});
const modelState = createMenuState(0);
modelState.allModels = [{ model }];
await openTelegramModelMenu({
isIdle: () => false,
canOfferInFlightModelSwitch: () => true,
sendBusyMessage: async () => {
events.push("unexpected:busy-model");
},
sendNoModelsMessage: async () => {
events.push("unexpected:no-models");
},
getModelMenuState: async () => modelState,
getActiveModel: () => model,
sendModelMenu: async (state, activeModel) => {
events.push(`model:${state.chatId}:${activeModel?.id}`);
return 12;
},
storeModelMenuState: (state) => {
events.push(`store:${state.messageId}:${state.mode}`);
},
});
assert.deepEqual(events, [
"status:1:status-html:alpha:medium",
"store:11:status",
"model:1:alpha",
"store:12:model",
]);
});
test("Menu helpers report model-menu busy and no-model paths", async () => {
const events: string[] = [];
await openTelegramStatusMenu({
isIdle: () => false,
sendBusyMessage: async () => {
events.push("unexpected:busy-status");
},
getModelMenuState: async () => createMenuState(0),
buildStatusHtml: () => "status",
getActiveModel: () => undefined,
getThinkingLevel: () => "off",
sendStatusMenu: async () => {
events.push("status");
return 1;
},
storeModelMenuState: () => {
events.push("store-status");
},
});
await openTelegramModelMenu({
isIdle: () => true,
canOfferInFlightModelSwitch: () => false,
sendBusyMessage: async () => {
events.push("unexpected:busy-model");
},
sendNoModelsMessage: async () => {
events.push("no-models");
},
getModelMenuState: async () => createMenuState(0),
getActiveModel: () => undefined,
sendModelMenu: async () => 1,
storeModelMenuState: () => {},
});
await openTelegramModelMenu({
isIdle: () => false,
canOfferInFlightModelSwitch: () => false,
sendBusyMessage: async () => {
events.push("busy-model");
},
sendNoModelsMessage: async () => {
events.push("unexpected:no-models");
},
getModelMenuState: async () => createMenuState(0),
getActiveModel: () => undefined,
sendModelMenu: async () => 1,
storeModelMenuState: () => {},
});
assert.deepEqual(events, [
"status",
"store-status",
"no-models",
"busy-model",
]);
});
test("Menu helpers route callback entry states before action handlers", async () => {
const events: string[] = [];
await handleTelegramMenuCallbackEntry("callback-1", undefined, undefined, {
handleStatusAction: async () => false,
handleThinkingAction: async () => false,
handleModelAction: async () => false,
answerCallbackQuery: async (_id, text) => {
events.push(`answer:${text ?? ""}`);
},
});
await handleTelegramMenuCallbackEntry("callback-2", "menu:model", undefined, {
handleStatusAction: async () => false,
handleThinkingAction: async () => false,
handleModelAction: async () => false,
answerCallbackQuery: async (_id, text) => {
events.push(`answer:${text ?? ""}`);
},
});
await handleTelegramMenuCallbackEntry(
"callback-3",
"menu:model",
{
chatId: 1,
messageId: 2,
page: 0,
scope: "all",
scopedModels: [],
allModels: [],
mode: "status",
},
{
handleStatusAction: async () => {
events.push("status");
return true;
},
handleThinkingAction: async () => false,
handleModelAction: async () => false,
answerCallbackQuery: async (_id, text) => {
events.push(`answer:${text ?? ""}`);
},
},
);
assert.deepEqual(events, [
"answer:",
"answer:Interactive message expired.",
"status",
]);
});
test("Menu helpers route stored callback queries through matching action handlers", async () => {
const events: string[] = [];
const state = createMenuState(2);
await handleStoredTelegramMenuCallback(
{ id: "callback-1", data: "menu:model", message: { message_id: 2 } },
{
getStoredModelMenuState: (messageId) => {
events.push(`get:${messageId}`);
return state;
},
handleStatusAction: async (nextState) => {
events.push(`status:${nextState.messageId}`);
return true;
},
handleThinkingAction: async () => {
events.push("unexpected:thinking");
return false;
},
handleModelAction: async () => {
events.push("unexpected:model");
return false;
},
answerCallbackQuery: async (_id, text) => {
events.push(`answer:${text ?? ""}`);
},
},
);
await handleStoredTelegramMenuCallback(
{ id: "callback-2", data: "menu:model" },
{
getStoredModelMenuState: (messageId) => {
events.push(`get:${messageId ?? "none"}`);
return undefined;
},
handleStatusAction: async () => false,
handleThinkingAction: async () => false,
handleModelAction: async () => false,
answerCallbackQuery: async (_id, text) => {
events.push(`answer:${text ?? ""}`);
},
},
);
assert.deepEqual(events, [
"get:2",
"status:2",
"get:none",
"answer:Interactive message expired.",
]);
});
test("Menu runtime routes stored callback queries through callback action ports", async () => {
const events: string[] = [];
const model = createMenuModel("openai", "gpt-5", true);
const state: TelegramModelMenuState<typeof model> = {
...createMenuState<typeof model>(2),
allModels: [{ model, thinkingLevel: "high" }],
mode: "status",
};
let thinkingLevel:
| "off"
| "minimal"
| "low"
| "medium"
| "high"
| "xhigh"
| "max" = "medium";
await handleTelegramMenuCallbackRuntime(
{ id: "callback-1", data: "menu:thinking", message: { message_id: 2 } },
{ idle: true },
{
getStoredModelMenuState: (messageId) => {
events.push(`get:${messageId}`);
return state;
},
getActiveModel: () => model,
getThinkingLevel: () => thinkingLevel,
setThinkingLevel: (level) => {
thinkingLevel = level;
events.push(`thinking:${level}`);
},
updateStatus: () => {
events.push("status");
},
updateModelMenuMessage: async () => {
events.push("model-menu");
},
updateThinkingMenuMessage: async () => {
events.push("thinking-menu");
},
updateStatusMessage: async () => {
events.push("status-menu");
},
answerCallbackQuery: async (_id, text) => {
events.push(`answer:${text ?? ""}`);
},
isIdle: (ctx) => ctx.idle,
hasActiveTelegramTurn: () => true,
hasAbortHandler: () => true,
hasActiveToolExecutions: () => false,
setModel: async (nextModel) => {
events.push(`set-model:${nextModel.id}`);
return true;
},
setCurrentModel: (nextModel) => {
events.push(`current:${nextModel.id}`);
},
stagePendingModelSwitch: (selection) => {
events.push(`pending:${selection.model.id}`);
},
restartInterruptedTelegramTurn: (selection) => {
events.push(`restart:${selection.model.id}`);
return true;
},
},
);
await handleTelegramMenuCallbackRuntime(
{ id: "callback-2", data: "thinking:set:high", message: { message_id: 2 } },
{ idle: true },
{
getStoredModelMenuState: () => state,
getActiveModel: () => model,
getThinkingLevel: () => thinkingLevel,
setThinkingLevel: (level) => {
thinkingLevel = level;
events.push(`thinking:${level}`);
},
updateStatus: () => {
events.push("status");
},
updateModelMenuMessage: async () => {
events.push("unexpected:model-menu");
},
updateThinkingMenuMessage: async () => {
events.push("unexpected:thinking-menu");
},
updateStatusMessage: async () => {
events.push("status-menu");
},
answerCallbackQuery: async (_id, text) => {
events.push(`answer:${text ?? ""}`);
},
isIdle: (ctx) => ctx.idle,
hasActiveTelegramTurn: () => true,
hasAbortHandler: () => true,
hasActiveToolExecutions: () => false,
setModel: async () => true,
setCurrentModel: () => {},
stagePendingModelSwitch: () => {},
restartInterruptedTelegramTurn: () => true,
},
);
await handleTelegramMenuCallbackRuntime(
{ id: "callback-3", data: "model:pick:0", message: { message_id: 2 } },
{ idle: false },
{
getStoredModelMenuState: () => state,
getActiveModel: () => createMenuModel("anthropic", "claude-3", true),
getThinkingLevel: () => thinkingLevel,
setThinkingLevel: (level) => {
thinkingLevel = level;
events.push(`thinking:${level}`);
},
updateStatus: () => {
events.push("status");
},
updateModelMenuMessage: async () => {
events.push("model-menu");
},
updateThinkingMenuMessage: async () => {
events.push("unexpected:thinking-menu");
},
updateStatusMessage: async () => {
events.push("status-menu");
},
answerCallbackQuery: async (_id, text) => {
events.push(`answer:${text ?? ""}`);
},
isIdle: (ctx) => ctx.idle,
hasActiveTelegramTurn: () => true,
hasAbortHandler: () => true,
hasActiveToolExecutions: () => false,
setModel: async (nextModel) => {
events.push(`set-model:${nextModel.id}`);
return true;
},
setCurrentModel: (nextModel) => {
events.push(`current:${nextModel.id}`);
},
stagePendingModelSwitch: (selection) => {
events.push(`pending:${selection.model.id}`);
},
restartInterruptedTelegramTurn: (selection) => {
events.push(`restart:${selection.model.id}`);
return true;
},
},
);
assert.deepEqual(events, [
"get:2",
"thinking-menu",
"answer:",
"thinking:high",
"status",
"status-menu",
"answer:Thinking: high",
"set-model:gpt-5",
"current:gpt-5",
"thinking:high",
"status",
"model-menu",
"restart:gpt-5",
"answer:Switching to gpt-5 and continuing…",
]);
});
test("Menu callback handler captures runtime ports", async () => {
const events: string[] = [];
const state: TelegramModelMenuState = createMenuState(2);
const handleCallback = createTelegramMenuCallbackHandler<
{ id: string; data?: string; message?: { message_id?: number } },
{ idle: boolean }
>({
getStoredModelMenuState: () => state,
getActiveModel: () => createMenuModel("openai", "gpt-5"),
getThinkingLevel: () => "medium",
setThinkingLevel: (level) => {
events.push(`thinking:${level}`);
},
updateStatus: () => {
events.push("status");
},
updateModelMenuMessage: async () => {
events.push("model-menu");
},
updateThinkingMenuMessage: async () => {
events.push("thinking-menu");
},
updateStatusMessage: async () => {
events.push("status-menu");
},
answerCallbackQuery: async (_id, text) => {
events.push(`answer:${text ?? ""}`);
},
isIdle: (ctx) => ctx.idle,
hasActiveTelegramTurn: () => false,
hasAbortHandler: () => false,
hasActiveToolExecutions: () => false,
setModel: async () => true,
setCurrentModel: () => {},
stagePendingModelSwitch: () => {},
restartInterruptedTelegramTurn: () => false,
});
await handleCallback(
{ id: "callback", data: "menu:model", message: { message_id: 2 } },
{ idle: true },
);
assert.deepEqual(events, ["model-menu", "answer:"]);
});
test("Menu callback adapter converts active tool count into runtime booleans", async () => {
const events: string[] = [];
let activeToolExecutions = 1;
const getActiveToolExecutions = () => activeToolExecutions;
const state: TelegramModelMenuState = {
...createMenuState(2),
allModels: [
{ model: createMenuModel("openai", "gpt-5", true) },
{ model: createMenuModel("anthropic", "claude-3", false) },
],
};
const handleCallback = createTelegramMenuCallbackHandlerForContext<
{ id: string; data?: string; message?: { message_id?: number } },
{ idle: boolean }
>({
getStoredModelMenuState: () => state,
getActiveModel: () => createMenuModel("openai", "gpt-5"),
getThinkingLevel: () => "medium",
setThinkingLevel: () => {},
updateStatus: () => {},
updateModelMenuMessage: async () => {
events.push("model-menu");
},
updateThinkingMenuMessage: async () => {},
updateStatusMessage: async () => {},
answerCallbackQuery: async (_id, text) => {
events.push(`answer:${text ?? ""}`);
},
isIdle: () => false,
hasActiveTelegramTurn: () => true,
hasAbortHandler: () => true,
getActiveToolExecutions,
setModel: async () => true,
setCurrentModel: (nextModel) => {
events.push(`current:${nextModel.id}`);
},
stagePendingModelSwitch: (selection) => {
events.push(`pending:${selection.model.id}`);
},
restartInterruptedTelegramTurn: () => {
events.push("restart");
return true;
},
});
await handleCallback(
{ id: "callback", data: "model:pick:1", message: { message_id: 2 } },
{ idle: false },
);
activeToolExecutions = 0;
await handleCallback(
{ id: "callback", data: "model:pick:1", message: { message_id: 2 } },
{ idle: false },
);
assert.deepEqual(events, [
"current:claude-3",
"model-menu",
"pending:claude-3",
"answer:Switched to claude-3. Restarting after the current tool finishes…",
"current:claude-3",
"model-menu",
"restart",
"answer:Switching to claude-3 and continuing…",
]);
});
test("Menu helpers execute model callback actions across update, switch, and restart paths", async () => {
const events: string[] = [];
const modelA = createMenuModel("openai", "gpt-5", true);
const modelB = createMenuModel("anthropic", "claude-3", false);
const state = createMenuState<MenuModel>(2, {
scope: "all",
allModels: [{ model: modelA }, { model: modelB }],
});
assert.equal(
await handleTelegramModelMenuCallbackAction(
"callback-1",
{
data: "model:page:1",
state,
activeModel: modelA,
currentThinkingLevel: "medium",
isIdle: true,
canRestartBusyRun: false,
hasActiveToolExecutions: false,
},
{
updateModelMenuMessage: async () => {
events.push("update-menu");
},
updateStatusMessage: async () => {
events.push("status");
},
answerCallbackQuery: async (_id, text) => {
events.push(`answer:${text ?? ""}`);
},
setModel: async () => true,
setCurrentModel: (model) => {
events.push(`current:${model.id}`);
},
setThinkingLevel: (level) => {
events.push(`thinking:${level}`);
},
stagePendingModelSwitch: (selection) => {
events.push(`pending:${selection.model.id}`);
},
restartInterruptedTelegramTurn: (selection) => {
events.push(`restart:${selection.model.id}`);
return true;
},
},
),
true,
);
assert.equal(
await handleTelegramModelMenuCallbackAction(
"callback-2",
{
data: "model:pick:1",
state,
activeModel: modelA,
currentThinkingLevel: "medium",
isIdle: false,
canRestartBusyRun: true,
hasActiveToolExecutions: true,
},
{
updateModelMenuMessage: async () => {
events.push("update-menu");
},
updateStatusMessage: async () => {
events.push("unexpected:status");
},
answerCallbackQuery: async (_id, text) => {
events.push(`answer:${text ?? ""}`);
},
setModel: async () => true,
setCurrentModel: (model) => {
events.push(`current:${model.id}`);
},
setThinkingLevel: (level) => {
events.push(`thinking:${level}`);
},
stagePendingModelSwitch: (selection) => {
events.push(`pending:${selection.model.id}`);
},
restartInterruptedTelegramTurn: (selection) => {
events.push(`restart:${selection.model.id}`);
return true;
},
},
),
true,
);
assert.equal(
await handleTelegramModelMenuCallbackAction(
"callback-3",
{
data: "model:pick:1",
state,
activeModel: modelA,
currentThinkingLevel: "medium",
isIdle: false,
canRestartBusyRun: true,
hasActiveToolExecutions: false,
},
{
updateModelMenuMessage: async () => {
events.push("update-menu");
},
updateStatusMessage: async () => {
events.push("unexpected:status");
},
answerCallbackQuery: async (_id, text) => {
events.push(`answer:${text ?? ""}`);
},
setModel: async () => true,
setCurrentModel: (model) => {
events.push(`current:${model.id}`);
},
setThinkingLevel: (level) => {
events.push(`thinking:${level}`);
},
stagePendingModelSwitch: (selection) => {
events.push(`pending:${selection.model.id}`);
},
restartInterruptedTelegramTurn: (selection) => {
events.push(`restart:${selection.model.id}`);
return true;
},
},
),
true,
);
assert.equal(events[0], "update-menu");
assert.equal(events[1], "answer:");
assert.equal(events[2], "current:claude-3");
assert.equal(events[3], "update-menu");
assert.equal(events[4], "pending:claude-3");
assert.equal(
events[5],
"answer:Switched to claude-3. Restarting after the current tool finishes…",
);
assert.equal(events[6], "current:claude-3");
assert.equal(events[7], "update-menu");
assert.equal(events[8], "restart:claude-3");
assert.equal(events[9], "answer:Switching to claude-3 and continuing…");
});
test("Menu helpers handle status and thinking callback actions", async () => {
const events: string[] = [];
const reasoningModel = createMenuModel("openai", "gpt-5", true);
const plainModel = createMenuModel("openai", "gpt-4o", false);
assert.equal(
await handleTelegramStatusMenuCallbackAction(
"callback-1",
"menu:model",
reasoningModel,
{
updateModelMenuMessage: async () => {
events.push("menu:model");
},
updateThinkingMenuMessage: async () => {
events.push("menu:thinking");
},
answerCallbackQuery: async (_id, text) => {
events.push(`answer:${text ?? ""}`);
},
},
),
true,
);
assert.equal(
await handleTelegramThinkingMenuCallbackAction(
"callback-2",
"thinking:set:high",
reasoningModel,
{
setThinkingLevel: (level) => {
events.push(`set:${level}`);
},
getCurrentThinkingLevel: () => "high",
updateStatusMessage: async () => {
events.push("status:update");
},
answerCallbackQuery: async (_id, text) => {
events.push(`answer:${text ?? ""}`);
},
},
),
true,
);
assert.equal(
await handleTelegramStatusMenuCallbackAction(
"callback-3",
"menu:thinking",
plainModel,
{
updateModelMenuMessage: async () => {
events.push("unexpected:model");
},
updateThinkingMenuMessage: async () => {
events.push("unexpected:thinking");
},
answerCallbackQuery: async (_id, text) => {
events.push(`answer:${text ?? ""}`);
},
},
),
true,
);
assert.equal(events[0], "menu:model");
assert.equal(events[1], "answer:");
assert.equal(events[2], "set:high");
assert.equal(events[3], "status:update");
assert.equal(events[4], "answer:Thinking: high");
assert.equal(events[5], "answer:This model has no reasoning controls.");
});
test("Menu helpers build pure render payloads before transport", () => {
const modelA = createMenuModel("openai", "gpt-5", true);
const state = createMenuState(2, {
scope: "all",
allModels: [{ model: modelA }],
mode: "status",
});
const modelPayload = buildTelegramModelMenuRenderPayload(state, modelA);
const thinkingPayload = buildTelegramThinkingMenuRenderPayload(
modelA,
"medium",
);
const statusPayload = buildTelegramStatusMenuRenderPayload(
"<b>Status</b>",
modelA,
"medium",
);
assert.equal(modelPayload.nextMode, "model");
assert.equal(modelPayload.text, "<b>🤖 Choose a model:</b>");
assert.equal(modelPayload.mode, "html");
const pageState = createMenuState(3, {
scope: "all",
allModels: [{ model: modelA }],
mode: "model-pages",
});
const pagePayload = buildTelegramModelMenuRenderPayload(pageState, modelA);
assert.equal(pagePayload.nextMode, "model-pages");
assert.equal(pagePayload.text, "<b>Choose a page:</b>");
assert.equal(pagePayload.mode, "html");
assert.equal(thinkingPayload.nextMode, "thinking");
assert.equal(thinkingPayload.text, "<b>🧠 Choose a thinking level:</b>");
assert.equal(thinkingPayload.mode, "html");
assert.equal(statusPayload.nextMode, "status");
assert.equal(statusPayload.text, "<b>Status</b>");
assert.equal(statusPayload.mode, "html");
assert.equal(state.mode, "status");
});
test("Menu action runtime opens and updates interactive menu messages", async () => {
const events: string[] = [];
const modelA = createMenuModel("openai", "gpt-5", true);
const state = createMenuState<typeof modelA>(2, {
scope: "all",
allModels: [{ model: modelA }],
mode: "status",
});
const runtime = createTelegramMenuActionRuntime<string, typeof modelA>({
getModelMenuState: async () => state,
getActiveModel: () => modelA,
getThinkingLevel: () => "medium",
buildStatusHtml: (ctx) => `<b>Status ${ctx}</b>`,
storeModelMenuState: (nextState) => {
events.push(`store:${nextState.messageId}`);
},
isIdle: () => true,
canOfferInFlightModelSwitch: () => false,
sendTextReply: async (_chatId, _replyToMessageId, text) => {
events.push(`text:${text}`);
},
editInteractiveMessage: async (chatId, messageId, text, mode) => {
events.push(`edit:${chatId}:${messageId}:${mode}:${text}`);
},
sendInteractiveMessage: async (chatId, text, mode) => {
events.push(`send:${chatId}:${mode}:${text}`);
return 99;
},
});
await runtime.updateModelMenuMessage(state, "ctx");
await runtime.updateThinkingMenuMessage(state, "ctx");
await runtime.updateStatusMessage(state, "ctx");
await runtime.sendStatusMessage(1, 2, "ctx");
await runtime.openModelMenu(1, 2, "ctx");
assert.equal(events[0], "edit:1:2:html:<b>🤖 Choose a model:</b>");
assert.equal(events[1], "edit:1:2:html:<b>🧠 Choose a thinking level:</b>");
assert.equal(events[2], "edit:1:2:html:<b>Status ctx</b>");
assert.equal(events[3], "send:1:html:<b>Status ctx</b>");
assert.equal(events[4], "store:99");
assert.equal(events[5], "send:1:html:<b>🤖 Choose a model:</b>");
assert.equal(events[6], "store:99");
});
test("Menu action runtime with state builder opens menus from settings runtime", async () => {
const events: string[] = [];
const modelA = createMenuModel("openai", "gpt-5");
const state = createMenuState<typeof modelA>(2, {
scope: "all",
allModels: [{ model: modelA }],
});
const runtime = createTelegramMenuActionRuntimeWithStateBuilder<
typeof modelA,
{
cwd: string;
modelRegistry: {
refresh: () => void;
getAvailable: () => [typeof modelA];
};
}
>({
runtime: {
storeState: () => {},
getState: () => undefined,
clear: () => {},
clearCachedInputs: () => {},
buildState: async (options) => {
await options.reloadSettings();
events.push(
`patterns:${options.getConfiguredScopedModelPatterns()?.length}`,
);
return state;
},
},
createSettingsManager: (cwd) => ({
reload: async () => {
events.push(`reload:${cwd}`);
},
getEnabledModels: () => ["openai/*"],
}),
getActiveModel: () => modelA,
getThinkingLevel: () => "medium",
buildStatusHtml: () => "status",
storeModelMenuState: (nextState) => {
events.push(`store:${nextState.messageId}`);
},
isIdle: () => true,
canOfferInFlightModelSwitch: () => false,
sendTextReply: async () => {},
editInteractiveMessage: async () => {},
sendInteractiveMessage: async (_chatId, text) => {
events.push(`send:${text}`);
return 99;
},
});
await runtime.openModelMenu(1, 2, {
cwd: "/repo",
modelRegistry: { refresh: () => {}, getAvailable: () => [modelA] },
});
assert.deepEqual(events, [
"reload:/repo",
"patterns:1",
"send:<b>🤖 Choose a model:</b>",
"store:99",
]);
});
test("Menu helpers update and send interactive menu messages", async () => {
const events: string[] = [];
const modelA = createMenuModel("openai", "gpt-5", true);
const state = createMenuState(2, {
scope: "all",
allModels: [{ model: modelA }],
mode: "status",
});
const deps = {
editInteractiveMessage: async (
chatId: number,
messageId: number,
text: string,
mode: "markdown" | "html" | "plain",
) => {
events.push(`edit:${chatId}:${messageId}:${mode}:${text}`);
},
sendInteractiveMessage: async (
chatId: number,
text: string,
mode: "markdown" | "html" | "plain",
) => {
events.push(`send:${chatId}:${mode}:${text}`);
return 99;
},
};
await updateTelegramModelMenuMessage(state, modelA, deps);
await updateTelegramThinkingMenuMessage(state, modelA, "medium", deps);
await updateTelegramStatusMessage(
state,
"<b>Status</b>",
modelA,
"medium",
deps,
);
const sentStatusId = await sendTelegramStatusMessage(
state,
"<b>Status</b>",
modelA,
"medium",
deps,
);
const sentModelId = await sendTelegramModelMenuMessage(state, modelA, deps);
assert.equal(sentStatusId, 99);
assert.equal(sentModelId, 99);
assert.equal(events[0], "edit:1:2:html:<b>🤖 Choose a model:</b>");
assert.equal(events[1], "edit:1:2:html:<b>🧠 Choose a thinking level:</b>");
assert.equal(events[2], "edit:1:2:html:<b>Status</b>");
assert.equal(events[3], "send:1:html:<b>Status</b>");
assert.equal(events[4], "send:1:html:<b>🤖 Choose a model:</b>");
});
test("Queue menu keeps main-menu navigation on top", async () => {
const state = createMenuState(2);
const queuedItems: TelegramQueueItem<string>[] = [
{
kind: "prompt",
chatId: 1,
replyToMessageId: 10,
queueOrder: 1,
queueLane: "priority",
laneOrder: 1,
priorityEmoji: "🕊",
statusSummary: "queued <prompt>",
sourceMessageIds: [10],
queuedAttachments: [],
content: [
{ type: "text", text: "[telegram] queued <prompt>\n\nfull body" },
],
historyText: "",
},
];
const texts: string[] = [];
const modes: string[] = [];
const markups: Array<{
inline_keyboard: Array<Array<{ text: string; callback_data: string }>>;
}> = [];
const runtime = createTelegramQueueMenuRuntime<string>({
telegramQueueStore: {
getQueuedItems: () => queuedItems,
setQueuedItems: () => {},
hasQueuedItems: () => queuedItems.length > 0,
},
queueMutationRuntime: {
append: () => {},
reorder: () => {},
clear: () => 0,
removeByMessageIds: () => 0,
clearPriorityByMessageId: (messageId) => {
const item = queuedItems.find(
(entry) => entry.replyToMessageId === messageId,
);
if (!item) return false;
item.queueLane = "default";
return true;
},
prioritizeByMessageId: (messageId) => {
const item = queuedItems.find(
(entry) => entry.replyToMessageId === messageId,
);
if (!item) return false;
item.queueLane = "priority";
return true;
},
},
sendInteractiveMessage: async (_chatId, text, mode, replyMarkup) => {
texts.push(text);
modes.push(mode);
markups.push(replyMarkup);
return 99;
},
editInteractiveMessage: async (
_chatId,
_messageId,
text,
mode,
replyMarkup,
) => {
texts.push(text);
modes.push(mode);
markups.push(replyMarkup);
},
answerCallbackQuery: async () => {},
getModelMenuState: async () => state,
getStoredModelMenuState: () => state,
storeModelMenuState: () => {},
updateStatusMessage: async () => {},
updateStatus: () => {},
});
await runtime.openQueueMenu(1, 2, "ctx");
await runtime.handleCallbackQuery(
{
id: "callback",
data: "menu:queue",
message: { chat: { id: 1 }, message_id: 2 },
},
"ctx",
);
await runtime.handleCallbackQuery(
{
id: "callback",
data: "queue:pick:1:10",
message: { chat: { id: 1 }, message_id: 2 },
},
"ctx",
);
await runtime.handleCallbackQuery(
{
id: "callback",
data: "queue:prio-set:1:10:normal",
message: { chat: { id: 1 }, message_id: 2 },
},
"ctx",
);
queuedItems.length = 0;
await runtime.openQueueMenu(1, 2, "ctx");
assert.equal(markups[0]?.inline_keyboard[0]?.[0]?.callback_data, "menu:back");
assert.equal(markups[1]?.inline_keyboard[0]?.[0]?.callback_data, "menu:back");
assert.deepEqual(markups[0]?.inline_keyboard[1], [
{ text: "🌀 Refresh", callback_data: "queue:refresh" },
]);
assert.equal(
markups[0]?.inline_keyboard[2]?.[0]?.text,
"1. 🕊 queued <prompt>",
);
assert.deepEqual(markups[2]?.inline_keyboard[1], [
{ text: "🟡 Priority", callback_data: "queue:prio-set:1:10:priority" },
{ text: "⚫️ Normal", callback_data: "queue:prio-set:1:10:normal" },
]);
assert.deepEqual(markups[2]?.inline_keyboard[2], [
{ text: "🗑 Delete", callback_data: "queue:delete:1:10" },
]);
assert.deepEqual(markups[3]?.inline_keyboard[1], [
{ text: "⚫️ Priority", callback_data: "queue:prio-set:1:10:priority" },
{ text: "🟣 Normal", callback_data: "queue:prio-set:1:10:normal" },
]);
assert.deepEqual(markups[4]?.inline_keyboard, [
[{ text: "⬆️ Main menu", callback_data: "menu:back" }],
[{ text: "🌀 Refresh", callback_data: "queue:refresh:1" }],
]);
assert.equal(texts[0], "<b>⏳ Queue:</b>");
assert.equal(
texts[2],
"<b>1.</b> 🕊\n<pre>[telegram] queued &lt;prompt&gt;\n\nfull body</pre>",
);
assert.equal(
texts[3],
"<b>1.</b>\n<pre>[telegram] queued &lt;prompt&gt;\n\nfull body</pre>",
);
assert.equal(texts[4], "<b>⌛ Queue is empty.</b>");
assert.deepEqual(modes, ["html", "html", "html", "html", "html"]);
});
test("Queue item detail renders prompt as raw preformatted HTML", async () => {
const state = createMenuState(2);
const longPathPrompt =
`[telegram]\n[attachments] /home/user/.pi/agent/tmp/telegram\n` +
`- /home/user/.pi/agent/tmp/telegram/file.txt\n${"&<>".repeat(2000)}`;
const texts: string[] = [];
const runtime = createTelegramQueueMenuRuntime<string>({
telegramQueueStore: {
getQueuedItems: () => [
{
kind: "prompt",
chatId: 1,
replyToMessageId: 10,
queueOrder: 1,
queueLane: "default",
laneOrder: 1,
statusSummary: "file prompt",
sourceMessageIds: [10],
queuedAttachments: [],
content: [{ type: "text", text: longPathPrompt }],
historyText: "",
},
],
setQueuedItems: () => {},
hasQueuedItems: () => true,
},
queueMutationRuntime: {
append: () => {},
reorder: () => {},
clear: () => 0,
removeByMessageIds: () => 0,
clearPriorityByMessageId: () => false,
prioritizeByMessageId: () => false,
},
sendInteractiveMessage: async () => 99,
editInteractiveMessage: async (_chatId, _messageId, text) => {
texts.push(text);
},
answerCallbackQuery: async () => {},
getModelMenuState: async () => state,
getStoredModelMenuState: () => state,
storeModelMenuState: () => {},
updateStatusMessage: async () => {},
updateStatus: () => {},
});
await runtime.handleCallbackQuery(
{
id: "callback",
data: "queue:pick:1:10",
message: { chat: { id: 1 }, message_id: 2 },
},
"ctx",
);
assert.match(texts[0] ?? "", /^<b>1\.<\/b>\n<pre>\[telegram\]/);
assert.match(texts[0] ?? "", /\/home\/user\/\.pi\/agent\/tmp\/telegram/);
assert.match(texts[0] ?? "", /&amp;&lt;&gt;/);
assert.match(texts[0] ?? "", /… \[truncated\]<\/pre>$/);
assert.ok((texts[0] ?? "").length < 4096);
});
test("Queue item delete requires confirmation", async () => {
const state = createMenuState(2);
const queuedItems: TelegramQueueItem<string>[] = [
{
kind: "prompt",
chatId: 1,
replyToMessageId: 10,
queueOrder: 1,
queueLane: "default",
laneOrder: 1,
statusSummary: "delete me",
sourceMessageIds: [10],
queuedAttachments: [],
content: [{ type: "text", text: "[telegram] delete me" }],
historyText: "",
},
];
const texts: string[] = [];
const notices: Array<string | undefined> = [];
const markups: Array<{
inline_keyboard: Array<Array<{ text: string; callback_data: string }>>;
}> = [];
const runtime = createTelegramQueueMenuRuntime<string>({
telegramQueueStore: {
getQueuedItems: () => queuedItems,
setQueuedItems: () => {},
hasQueuedItems: () => queuedItems.length > 0,
},
queueMutationRuntime: {
append: () => {},
reorder: () => {},
clear: () => 0,
removeByMessageIds: (messageIds) => {
const index = queuedItems.findIndex((item) =>
messageIds.includes(item.replyToMessageId),
);
if (index === -1) return 0;
queuedItems.splice(index, 1);
return 1;
},
clearPriorityByMessageId: () => false,
prioritizeByMessageId: () => false,
},
sendInteractiveMessage: async () => 99,
editInteractiveMessage: async (
_chatId,
_messageId,
text,
_mode,
replyMarkup,
) => {
texts.push(text);
markups.push(replyMarkup);
},
answerCallbackQuery: async (_id, text) => {
notices.push(text);
},
getModelMenuState: async () => state,
getStoredModelMenuState: () => state,
storeModelMenuState: () => {},
updateStatusMessage: async () => {},
updateStatus: () => {},
});
await runtime.handleCallbackQuery(
{
id: "delete",
data: "queue:delete:1:10",
message: { chat: { id: 1 }, message_id: 2 },
},
"ctx",
);
assert.equal(queuedItems.length, 1);
assert.equal(texts[0], "<b>Delete this queued prompt?</b>");
assert.deepEqual(markups[0]?.inline_keyboard, [
[
{ text: "🗑 Yes, delete", callback_data: "queue:confirm-delete:1:10" },
{ text: "❌ No", callback_data: "queue:keep:1:10" },
],
]);
await runtime.handleCallbackQuery(
{
id: "legacy-cancel",
data: "queue:cancel:1:10",
message: { chat: { id: 1 }, message_id: 2 },
},
"ctx",
);
assert.equal(queuedItems.length, 1);
assert.equal(texts[1], "<b>Delete this queued prompt?</b>");
await runtime.handleCallbackQuery(
{
id: "keep",
data: "queue:keep:1:10",
message: { chat: { id: 1 }, message_id: 2 },
},
"ctx",
);
assert.equal(queuedItems.length, 1);
assert.equal(texts[2], "<b>1.</b>\n<pre>[telegram] delete me</pre>");
assert.equal(notices[2], "Kept in queue.");
await runtime.handleCallbackQuery(
{
id: "confirm",
data: "queue:confirm-delete:1:10",
message: { chat: { id: 1 }, message_id: 2 },
},
"ctx",
);
assert.equal(queuedItems.length, 0);
assert.equal(texts[3], "<b>⌛ Queue is empty.</b>");
assert.equal(notices[3], "Deleted from queue.");
});
test("Queue refresh rotates empty queue title", async () => {
const state = createMenuState(2);
const texts: string[] = [];
const markups: Array<{
inline_keyboard: Array<Array<{ text: string; callback_data: string }>>;
}> = [];
const runtime = createTelegramQueueMenuRuntime<string>({
telegramQueueStore: {
getQueuedItems: () => [],
setQueuedItems: () => {},
hasQueuedItems: () => false,
},
queueMutationRuntime: {
append: () => {},
reorder: () => {},
clear: () => 0,
removeByMessageIds: () => 0,
clearPriorityByMessageId: () => false,
prioritizeByMessageId: () => false,
},
sendInteractiveMessage: async (_chatId, text, _mode, replyMarkup) => {
texts.push(text);
markups.push(replyMarkup);
return 99;
},
editInteractiveMessage: async (
_chatId,
_messageId,
text,
_mode,
replyMarkup,
) => {
texts.push(text);
markups.push(replyMarkup);
},
answerCallbackQuery: async () => {},
getModelMenuState: async () => state,
getStoredModelMenuState: () => state,
storeModelMenuState: () => {},
updateStatusMessage: async () => {},
updateStatus: () => {},
});
await runtime.openQueueMenu(1, 2, "ctx");
await runtime.handleCallbackQuery(
{
id: "refresh-1",
data: "queue:refresh:1",
message: { chat: { id: 1 }, message_id: 99 },
},
"ctx",
);
await runtime.handleCallbackQuery(
{
id: "refresh-2",
data: "queue:refresh:2",
message: { chat: { id: 1 }, message_id: 99 },
},
"ctx",
);
assert.deepEqual(texts, [
"<b>⌛ Queue is empty.</b>",
"<b>🫙 Still nothing in queue.</b>",
"<b>🍃 Queue remains empty.</b>",
]);
assert.equal(
markups[0]?.inline_keyboard[1]?.[0]?.callback_data,
"queue:refresh:1",
);
assert.equal(
markups[1]?.inline_keyboard[1]?.[0]?.callback_data,
"queue:refresh:2",
);
assert.equal(
markups[2]?.inline_keyboard[1]?.[0]?.callback_data,
"queue:refresh:3",
);
});
test("Menu helpers build model, thinking, and status UI payloads", () => {
const modelA = createMenuModel("openai", "gpt-5", true);
const modelB = createMenuModel("anthropic", "claude-3", false);
const state = createMenuState<MenuModel>(2, {
scopedModels: [{ model: modelA, thinkingLevel: "high" }],
allModels: [{ model: modelB }],
});
assert.deepEqual(getModelMenuItems(state), state.scopedModels);
assert.match(
formatScopedModelButtonText(state.scopedModels[0], modelA),
/^🟢 /,
);
const modelMarkup = buildModelMenuReplyMarkup(state, modelA, 6);
state.mode = "model-detail";
state.selectedModelKey = "openai/gpt-5";
state.allModels = [{ model: modelA }];
const detailPayload = buildTelegramModelMenuRenderPayload(state, modelA);
assert.deepEqual(detailPayload.replyMarkup.inline_keyboard[1], [
{ text: "🟢 Active", callback_data: "model:pick-selected" },
]);
assert.deepEqual(detailPayload.replyMarkup.inline_keyboard[2], [
{ text: "🟡 Scoped", callback_data: "model:scope-enable" },
{ text: "⚫️ All", callback_data: "model:scope-disable" },
]);
const disabledDetailPayload = buildTelegramModelMenuRenderPayload(
state,
modelB,
);
assert.deepEqual(disabledDetailPayload.replyMarkup.inline_keyboard[1], [
{ text: "☑️ Activate", callback_data: "model:pick-selected" },
]);
assert.equal(modelMarkup.inline_keyboard[0]?.[0]?.callback_data, "menu:back");
assert.deepEqual(modelMarkup.inline_keyboard[1], [
{ text: "🟡 Scoped", callback_data: "model:scope:scoped" },
{ text: "⚫️ All", callback_data: "model:scope:all" },
]);
assert.equal(
modelMarkup.inline_keyboard[2]?.[0]?.callback_data,
"model:open:0",
);
const thinkingText = buildThinkingMenuText();
assert.equal(thinkingText, "<b>🧠 Choose a thinking level:</b>");
const thinkingMarkup = buildThinkingMenuReplyMarkup("medium");
assert.equal(thinkingMarkup.inline_keyboard[0]?.[0]?.text, "⬆️ Main menu");
assert.equal(
thinkingMarkup.inline_keyboard[0]?.[0]?.callback_data,
"menu:back",
);
assert.equal(
thinkingMarkup.inline_keyboard.some((row) => row[0]?.text === "🟢 medium"),
true,
);
const statusMarkup = buildStatusReplyMarkup(modelA, "medium", 3);
const statusCallbackData = statusMarkup.inline_keyboard.flatMap((row) =>
row.map((button) => button.callback_data),
);
assert.equal(statusMarkup.inline_keyboard.length, 4);
assert.equal(
statusMarkup.inline_keyboard[0]?.[0]?.text.startsWith("🤖 Model"),
true,
);
assert.equal(
statusMarkup.inline_keyboard[1]?.[0]?.text.startsWith("🧠 Thinking"),
true,
);
assert.equal(statusMarkup.inline_keyboard[2]?.[0]?.text, "⏳ Queue: 3");
assert.equal(statusMarkup.inline_keyboard.at(-1)?.[0]?.text, "⚙️ Settings");
assert.equal(
buildStatusReplyMarkup(undefined, "off", 0).inline_keyboard[1]?.[0]?.text,
"⌛ Queue: 0",
);
assert.deepEqual(statusCallbackData, [
"menu:model",
"menu:thinking",
"menu:queue",
"menu:settings",
]);
assert.equal(
statusCallbackData.some((callbackData) =>
callbackData.startsWith("status:"),
),
false,
);
const noReasoningMarkup = buildStatusReplyMarkup(modelB, "medium");
assert.equal(noReasoningMarkup.inline_keyboard.length, 3);
});
test("Section callback actions preserve callback thread target", async () => {
const registry = createTelegramExtensionSectionRegistry();
const enqueued: Array<unknown> = [];
const opened: Array<unknown> = [];
registry.register({
id: "demo",
label: "Demo",
render: () => ({ text: "Demo" }),
handleCallback: async (ctx) => {
await ctx.enqueuePrompt("section prompt");
await ctx.open({ text: "Opened" });
return "handled" as const;
},
});
await handleTelegramMenuCallbackRuntime(
{
id: "cb-section",
data: "section:0:run",
message: {
message_id: 2,
message_thread_id: 55,
chat: { id: -1001 },
},
},
{},
{
getStoredModelMenuState: () => createMenuState(2),
getActiveModel: () => undefined,
getThinkingLevel: () => "medium",
setThinkingLevel: () => {},
updateStatus: () => {},
updateModelMenuMessage: async () => {},
updateThinkingMenuMessage: async () => {},
updateStatusMessage: async () => {},
answerCallbackQuery: async () => {},
isIdle: () => true,
hasActiveTelegramTurn: () => false,
hasAbortHandler: () => false,
hasActiveToolExecutions: () => false,
setModel: async () => true,
setCurrentModel: () => {},
stagePendingModelSwitch: () => {},
restartInterruptedTelegramTurn: () => false,
sectionRegistry: registry,
sendInteractiveMessage: async (
_chatId,
text,
_mode,
_markup,
options,
) => {
opened.push({ text, target: options?.target });
return 3;
},
enqueueSectionPrompt: async (prompt, _ctx, target) => {
enqueued.push({ prompt, target });
},
},
);
assert.deepEqual(enqueued, [
{ prompt: "section prompt", target: { chatId: -1001, threadId: 55 } },
]);
assert.deepEqual(opened, [
{ text: "Opened", target: { chatId: -1001, threadId: 55 } },
]);
});
test("Settings menu labels proactive push with state text", () => {
assert.deepEqual(
buildTelegramSettingsMenuReplyMarkup(true, false, "manual", "hidden")
.inline_keyboard[5],
[
{
text: "📌 Proactive push: on",
callback_data: "settings:open:proactive",
},
],
);
assert.deepEqual(
buildTelegramSettingsMenuReplyMarkup(false, false, "manual", "hidden")
.inline_keyboard[5],
[
{
text: "📌 Proactive push: off",
callback_data: "settings:open:proactive",
},
],
);
});
test("Settings menu exposes time injection mode selection", () => {
assert.deepEqual(
buildTelegramSettingsMenuReplyMarkup(false, false, "manual", "hidden")
.inline_keyboard[2],
[
{
text: "🕒 Time injection: hidden",
callback_data: "settings:open:time-injection",
},
],
);
assert.deepEqual(
buildTelegramSettingsMenuReplyMarkup(false, false, "manual", "interval")
.inline_keyboard[2],
[
{
text: "🕒 Time injection: interval",
callback_data: "settings:open:time-injection",
},
],
);
assert.deepEqual(
buildTimeInjectionModeSettingsReplyMarkup("hidden").inline_keyboard[1],
[
{
text: "🟢 hidden",
callback_data: "settings:set:time-injection:hidden",
},
],
);
assert.deepEqual(
buildTimeInjectionModeSettingsReplyMarkup("always").inline_keyboard[2],
[
{
text: "🟢 always",
callback_data: "settings:set:time-injection:always",
},
],
);
});
test("Settings menu marks voice mode selection with model-style dot", () => {
assert.deepEqual(
buildVoiceReplyModeSettingsReplyMarkup("manual", false).inline_keyboard[1],
[{ text: "🟢 hidden", callback_data: "settings:set:voice-reply:hidden" }],
);
assert.deepEqual(
buildVoiceReplyModeSettingsReplyMarkup("mirror").inline_keyboard[3],
[{ text: "🟢 mirror", callback_data: "settings:set:voice-reply:mirror" }],
);
assert.deepEqual(
buildTelegramSettingsMenuReplyMarkup(
false,
false,
"manual",
"hidden",
undefined,
false,
).inline_keyboard[1],
[
{
text: "👄 Voice reply: hidden",
callback_data: "settings:open:voice-reply",
},
],
);
assert.deepEqual(
buildTelegramSettingsMenuReplyMarkup(false, false, "always", "hidden")
.inline_keyboard[1],
[
{
text: "👄 Voice reply: always",
callback_data: "settings:open:voice-reply",
},
],
);
});
test("Settings menu persists voice mode even when the menu message state expired", async () => {
let mode: "manual" | "mirror" | "always" | undefined = "always";
let configured = true;
const answers: string[] = [];
const runtime = createTelegramSettingsMenuRuntime({
getModelMenuState: async () => ({
chatId: 1,
messageId: 2,
mode: "settings" as const,
page: 0,
scope: "all" as const,
scopedModels: [],
allModels: [],
}),
getStoredModelMenuState: () => undefined,
storeModelMenuState: () => {},
editInteractiveMessage: async () => {},
sendInteractiveMessage: async () => 2,
answerCallbackQuery: async (_id, text) => {
answers.push(text ?? "");
},
isProactivePushEnabled: () => false,
areDraftPreviewsEnabled: () => false,
getAssistantRenderingMode: () => "rich" as const,
getTimeInjectionMode: () => "hidden",
getVoiceReplyMode: () => mode ?? "manual",
isVoiceReplyModeConfigured: () => configured,
setProactivePushEnabled: async () => {},
setDraftPreviewsEnabled: async () => {},
setAssistantRenderingMode: async () => {},
setVoiceReplyMode: async (nextMode) => {
mode = nextMode;
configured = nextMode !== undefined;
},
setTimeInjectionMode: async () => {},
});
assert.equal(
await runtime.handleCallbackQuery(
{
id: "cb1",
data: "settings:set:voice-reply:manual",
message: { message_id: 99 },
},
{},
),
true,
);
assert.equal(mode, "manual");
assert.deepEqual(answers, ["Voice reply mode: manual"]);
});
test("Settings submenu headings include current values", () => {
assert.match(
buildProactivePushSettingsText(true),
/^<b>📌 Proactive push:<\/b> <code>on<\/code>/,
);
assert.match(
buildTimeInjectionModeSettingsText("interval"),
/^<b>🕒 Time injection mode:<\/b> <code>interval<\/code>/,
);
assert.match(
buildVoiceReplyModeSettingsText("manual", false),
/^<b>👄 Voice reply mode:<\/b> <code>hidden<\/code>/,
);
});
test("Settings menu marks one-line on/off checkbox controls symmetrically", () => {
assert.deepEqual(
buildProactivePushSettingsReplyMarkup(true).inline_keyboard[1],
[
{ text: "🟢 On", callback_data: "settings:set:proactive:on" },
{ text: "⚫️ Off", callback_data: "settings:set:proactive:off" },
],
);
assert.deepEqual(
buildProactivePushSettingsReplyMarkup(false).inline_keyboard[1],
[
{ text: "⚫️ On", callback_data: "settings:set:proactive:on" },
{ text: "🟡 Off", callback_data: "settings:set:proactive:off" },
],
);
});