0.2.5: model menu updates fix

This commit is contained in:
LLB
2026-04-11 12:32:53 +04:00
parent 31a538db64
commit f6194693e5
6 changed files with 224 additions and 7 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
- `[Docs]` Added short responsibility header comments to every project `.ts` file. Impact: file boundaries are easier to understand while navigating the growing `/lib` split.
- `[Naming]` Renamed extracted domain modules and mirrored regression suites to use repo-scoped bare domain filenames such as `api.ts`, `queue.ts`, and `queue.test.ts` instead of repeating `telegram-*` in every path. Impact: the internal topology is easier to scan and stays aligned with the repository-level Telegram scope.
- `[Controls]` Expanded Telegram session controls with a richer `/status` view, inline model selection, and thinking-level controls. Impact: more bridge configuration can be managed directly from Telegram.
- `[Controls]` Expanded Telegram session controls with a richer `/status` view, inline model selection, and thinking-level controls, and fixed the callback-selection path so idle model and thinking picks apply immediately instead of only becoming visible after a later Telegram interaction. Impact: more bridge configuration can be managed directly from Telegram with more predictable immediate feedback.
- `[Queue]` Upgraded Telegram turn queueing with previews, reaction-driven prioritization/removal, media-group handling, aborted-turn history preservation, and safer dispatch gating. Impact: follow-up handling is more transparent and less prone to lifecycle races.
- `[Rendering]` Added Telegram-oriented Markdown rendering and hardened reply streaming/chunking behavior, including narrower monospace Markdown table output without outer side borders, monospace list markers for unordered and ordered lists, and flattened nested quote indentation inside a single Telegram blockquote. Impact: formatted replies render more reliably while preserving literal code blocks and using width more efficiently on narrow Telegram clients.
- `[Runtime]` Hardened attachment delivery, polling/runtime behavior, Telegram session integration, preview-finalization and reply-transport routing into the replies domain, lazy Telegram API client routing into the Telegram API domain, turn-building extraction into its own domain, menu/model-resolution plus menu-state, pure menu-page derivation, pure menu render-payload builders, menu-message runtime, callback parsing, callback entry handling, callback mutation helpers, full model-callback planning and execution, and interface-polished callback effect ports into the menu domain, direct execute-from-update routing into the updates domain, model-switch restart glue extraction into the model-switch domain, and tool/command/lifecycle-hook registration extraction into a dedicated registration domain. Impact: the bridge is more robust as a daily Telegram frontend for pi.
+1 -1
View File
@@ -110,7 +110,7 @@ Chat with your bot in Telegram DMs.
Additional fork-specific controls:
- `/status` now has a richer view with inline buttons for model and thinking controls, and joins the high-priority control queue when pi is busy
- `/model` opens the interactive model selector, joins the high-priority control queue when pi is busy, and can restart the active Telegram-owned run on the newly selected model, waiting for the current tool call to finish when needed
- `/model` opens the interactive model selector, applies idle selections immediately, joins the high-priority control queue when pi is busy, and can restart the active Telegram-owned run on the newly selected model, waiting for the current tool call to finish when needed
- `/compact` starts session compaction when pi and the Telegram queue are idle
- Queue reactions: `👍` prioritizes a waiting turn, `👎` removes it
+1 -1
View File
@@ -122,7 +122,7 @@ The bridge exposes Telegram-side session controls in addition to regular chat fo
Current operator controls include:
- `/status` for model, usage, cost, and context visibility, queued as a high-priority control item when needed
- Inline status buttons for model and thinking adjustments
- Inline status buttons for model and thinking adjustments, applying idle selections immediately while still respecting busy-run restart rules
- `/model` for interactive model selection, queued as a high-priority control item when needed and supporting in-flight restart of the active Telegram-owned run on a newly selected model
- `/compact` for Telegram-triggered pi session compaction when the bridge is idle
- Queue reactions using `👍` and `👎`
+12 -3
View File
@@ -1180,7 +1180,10 @@ export default function (pi: ExtensionAPI) {
query.data,
getCurrentTelegramModel(ctx),
{
setThinkingLevel: (level) => pi.setThinkingLevel(level),
setThinkingLevel: (level) => {
pi.setThinkingLevel(level);
updateStatus(ctx);
},
getCurrentThinkingLevel: () => pi.getThinkingLevel(),
updateStatusMessage: async () => showStatusMessage(state, ctx),
answerCallbackQuery,
@@ -1213,10 +1216,15 @@ export default function (pi: ExtensionAPI) {
setModel: (model) => pi.setModel(model),
setCurrentModel: (model) => {
currentTelegramModel = model;
updateStatus(ctx);
},
setThinkingLevel: (level) => {
pi.setThinkingLevel(level);
updateStatus(ctx);
},
setThinkingLevel: (level) => pi.setThinkingLevel(level),
stagePendingModelSwitch: (selection) => {
pendingTelegramModelSwitch = selection;
updateStatus(ctx);
},
restartInterruptedTelegramTurn: (selection) => {
return restartTelegramModelSwitchContinuation({
@@ -1825,8 +1833,9 @@ export default function (pi: ExtensionAPI) {
systemPrompt: nextEvent.systemPrompt + suffix,
};
},
onModelSelect: (event) => {
onModelSelect: (event, ctx) => {
currentTelegramModel = (event as { model: Model<any> }).model;
updateStatus(ctx);
},
onAgentStart: async (_event, ctx) => {
currentAbort = () => ctx.abort();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@llblab/pi-telegram",
"version": "0.2.4",
"version": "0.2.5",
"private": false,
"description": "Better Telegram DM bridge extension for pi",
"type": "module",
+208
View File
@@ -2467,6 +2467,214 @@ test("Extension runtime applies reaction priority and removal before the next di
}
});
test("Extension runtime applies idle model picks immediately and refreshes status", async () => {
const agentDir = join(homedir(), ".pi", "agent");
const configPath = join(agentDir, "telegram.json");
const previousConfig = await readFile(configPath, "utf8").catch(
() => undefined,
);
const previousArgv = [...process.argv];
const handlers = new Map<
string,
(event: unknown, ctx: unknown) => Promise<unknown>
>();
const commands = new Map<
string,
{ handler: (args: string, ctx: unknown) => Promise<void> }
>();
const runtimeEvents: string[] = [];
const statusEvents: string[] = [];
const modelA = {
provider: "openai",
id: "gpt-a",
reasoning: true,
} as const;
const modelB = {
provider: "anthropic",
id: "claude-b",
reasoning: true,
} as const;
const setModels: Array<string> = [];
const thinkingLevels: Array<string> = [];
let secondUpdatesResolve: ((value: Response) => void) | undefined;
const secondUpdates = new Promise<Response>((resolve) => {
secondUpdatesResolve = resolve;
});
const pi = {
on: (
event: string,
handler: (event: unknown, ctx: unknown) => Promise<unknown>,
) => {
handlers.set(event, handler);
},
registerCommand: (
name: string,
definition: { handler: (args: string, ctx: unknown) => Promise<void> },
) => {
commands.set(name, definition);
},
registerTool: () => {},
sendUserMessage: () => {},
getThinkingLevel: () => thinkingLevels.at(-1) ?? "medium",
setModel: async (model: { provider: string; id: string }) => {
setModels.push(`${model.provider}/${model.id}`);
return true;
},
setThinkingLevel: (level: string) => {
thinkingLevels.push(level);
},
} as never;
const originalFetch = globalThis.fetch;
let getUpdatesCalls = 0;
let nextMessageId = 100;
const callbackAnswers: string[] = [];
globalThis.fetch = async (input, init) => {
const url = typeof input === "string" ? input : input.toString();
const method = url.split("/").at(-1) ?? "";
const body =
typeof init?.body === "string"
? (JSON.parse(init.body) as Record<string, unknown>)
: undefined;
if (method === "deleteWebhook") {
return { json: async () => ({ ok: true, result: true }) } as Response;
}
if (method === "getUpdates") {
getUpdatesCalls += 1;
if (getUpdatesCalls === 1) {
return {
json: async () => ({
ok: true,
result: [
{
_: "other",
update_id: 1,
message: {
message_id: 60,
chat: { id: 99, type: "private" },
from: { id: 77, is_bot: false, first_name: "Test" },
text: "/model",
},
},
],
}),
} as Response;
}
if (getUpdatesCalls === 2) return secondUpdates;
throw new DOMException("stop", "AbortError");
}
if (method === "sendMessage") {
runtimeEvents.push(`send:${String(body?.text ?? "")}`);
return {
json: async () => ({
ok: true,
result: { message_id: nextMessageId++ },
}),
} as Response;
}
if (method === "editMessageText") {
runtimeEvents.push(`edit:${String(body?.text ?? "")}`);
return { json: async () => ({ ok: true, result: true }) } as Response;
}
if (method === "answerCallbackQuery") {
callbackAnswers.push(String(body?.text ?? ""));
return { json: async () => ({ ok: true, result: true }) } as Response;
}
if (method === "sendChatAction") {
return { json: async () => ({ ok: true, result: true }) } as Response;
}
throw new Error(`Unexpected Telegram API method: ${method}`);
};
try {
process.argv = [
previousArgv[0] ?? "node",
previousArgv[1] ?? "index.ts",
"--models=anthropic/claude-b:high",
];
await mkdir(agentDir, { recursive: true });
await writeFile(
configPath,
JSON.stringify(
{ botToken: "123:abc", allowedUserId: 77, lastUpdateId: 0 },
null,
"\t",
) + "\n",
"utf8",
);
telegramExtension(pi);
const ctx = {
hasUI: true,
cwd: process.cwd(),
model: modelA,
signal: undefined,
ui: {
theme: {
fg: (_token: string, text: string) => text,
},
setStatus: (_slot: string, text: string) => {
statusEvents.push(text);
},
notify: () => {},
},
sessionManager: {
getEntries: () => [],
},
modelRegistry: {
refresh: () => {},
getAvailable: () => [modelA, modelB],
isUsingOAuth: () => false,
},
getContextUsage: () => undefined,
hasPendingMessages: () => false,
isIdle: () => true,
abort: () => {},
} as never;
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;
secondUpdatesResolve?.({
json: async () => ({
ok: true,
result: [
{
_: "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" },
},
},
},
],
}),
} as Response);
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>Context:")),
true,
);
await handlers.get("session_shutdown")?.({}, ctx);
} finally {
process.argv = previousArgv;
globalThis.fetch = originalFetch;
if (previousConfig === undefined) {
await rm(configPath, { force: true });
} else {
await writeFile(configPath, previousConfig, "utf8");
}
}
});
test("Extension runtime switches model in flight and dispatches a continuation turn after abort", async () => {
const agentDir = join(homedir(), ".pi", "agent");
const configPath = join(agentDir, "telegram.json");