agents & in-flight model switching

This commit is contained in:
LLB
2026-04-11 01:19:56 +04:00
parent e6d6eba2de
commit a2bb588c3b
10 changed files with 619 additions and 30 deletions
+81
View File
@@ -0,0 +1,81 @@
# Project Context
## 0. Meta-Protocol Principles
- `Constraint-Driven Evolution`: Add structure when the bridge gains real operator or runtime constraints
- `Single Source of Truth`: Keep durable rules in `AGENTS.md`, open work in `BACKLOG.md`, completed delivery in `CHANGELOG.md`, and deeper technical detail in `/docs`
- `Boundary Clarity`: Separate Telegram transport concerns, pi integration concerns, rendering behavior, and release/documentation state
- `Runtime Safety`: Prefer queue and rendering behavior that fails predictably over clever behavior that can desynchronize the Telegram bridge from pi session state
## 1. Concept
`pi-telegram` is a pi extension that turns a Telegram DM into a session-local frontend for pi, including text/file forwarding, streaming previews, queued follow-ups, model controls, and outbound attachment delivery.
## 2. Identity & Naming Contract
- `Telegram turn`: One unit of Telegram input processed by pi; this may represent one message or a coalesced media group
- `Queued Telegram turn`: A Telegram turn accepted by the bridge but not yet active in pi
- `Active Telegram turn`: The Telegram turn currently bound to the running pi agent loop
- `Preview`: The transient streamed response shown through Telegram drafts or editable messages before the final reply lands
- `Scoped models`: The subset of models exposed to Telegram model selection when pi settings or CLI flags limit the available list
## 3. Project Topology
- `/index.ts`: Main extension runtime, Telegram API integration, queueing, rendering, previews, menus, and tool wiring
- `/docs/README.md`: Documentation index for technical project docs
- `/docs/architecture.md`: Runtime and subsystem overview for the bridge
- `/README.md`: User-facing project entry point, install guide, and fork summary
- `/AGENTS.md`: Durable engineering and runtime conventions
- `/BACKLOG.md`: Canonical open work
- `/CHANGELOG.md`: Completed delivery history
## 4. Core Entities
- `TelegramConfig`: Persisted bot/session pairing state
- `PendingTelegramTurn` / `ActiveTelegramTurn`: Queue and active-turn state for Telegram-originated work
- `TelegramPreviewState`: Streaming preview state for drafts or editable Telegram messages
- `TelegramModelMenuState`: Inline menu state for status/model/thinking controls
- `QueuedAttachment`: Outbound files staged for delivery through `telegram_attach`
## 5. Architectural Decisions
- The extension ships as a single runtime file for simple pi packaging, so logical sections inside `index.ts` are the primary module boundaries
- The bridge is session-local and intentionally pairs with a single allowed Telegram user per config
- Telegram queue state is tracked locally and must stay aligned with pi agent lifecycle hooks; dispatch must respect active turns, pending dispatch, compaction, and pi pending-message state
- In-flight `/model` switching is supported only for Telegram-owned active turns and is implemented as set-model plus synthetic continuation turn plus abort; if a tool call is active, the abort is delayed until that tool finishes instead of interrupting the tool mid-flight
- Telegram replies render through Telegram HTML, not raw Markdown; real code blocks must stay literal and escaped
- `telegram_attach` is the canonical outbound file-delivery path for Telegram-originated requests
## 6. Engineering Conventions
- Treat queue handling, compaction interaction, and lifecycle-hook state transitions as regression-prone areas; validate them after changing dispatch logic
- Treat Markdown rendering as Telegram-specific output work, not generic Markdown rendering; preserve literal code content and avoid HTML chunk splits that break tags
- Keep comments and user-facing docs in English unless the surrounding file already follows another convention
- Prefer targeted edits inside `index.ts` over broad rewrites unless a section boundary is being intentionally restructured
## 7. Operational Conventions
- When Telegram-visible behavior changes, sync `README.md` and the relevant `/docs` entry in the same pass
- When durable runtime constraints or repeat bug patterns emerge, record them here instead of burying them in changelog prose
- When fork identity changes, keep `README.md`, package metadata, and docs aligned so the published package does not point back at stale upstream coordinates
## 8. Integration Protocols
- Telegram API methods currently used include polling, message editing, draft streaming, callback queries, reactions, file download, and media upload endpoints
- pi integration depends on lifecycle hooks such as `before_agent_start`, `agent_start`, `message_start`, `message_update`, and `agent_end`
- Status/model/thinking controls are driven through Telegram inline keyboards and callback queries
- Inbound files may become pi image inputs; outbound files must flow through `telegram_attach`
## 9. Pre-Task Preparation Protocol
- Read `README.md` for current user-facing behavior and fork positioning
- Read `BACKLOG.md` before changing runtime behavior or documentation so open work stays truthful
- Read `/docs/architecture.md` before restructuring queue, preview, rendering, or command-handling logic
- Inspect the relevant `index.ts` section before editing because most bridge behavior is stateful and cross-linked
## 10. Task Completion Protocol
- Run the smallest meaningful validation for the touched area; `npm test` is the default regression suite once rendering or queue logic changes
- For rendering changes, ensure regressions still cover nested lists, code blocks, underscore-heavy text, and long-message chunking
- For queue/dispatch changes, validate abort, compaction, pending-dispatch, and pi pending-message guard behavior
- Sync `README.md`, `CHANGELOG.md`, `BACKLOG.md`, and `/docs` whenever user-visible behavior or real open-work state changes
+5
View File
@@ -0,0 +1,5 @@
# Project Backlog
## Open Backlog
- No open backlog items right now
+11
View File
@@ -0,0 +1,11 @@
# Changelog
## Current
- `[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.
- `[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. Impact: formatted replies render more reliably while preserving literal code blocks.
- `[Runtime]` Hardened attachment delivery, polling/runtime behavior, and Telegram session integration. Impact: the bridge is more robust as a daily Telegram frontend for pi.
- `[Metadata]` Updated package repository metadata to point at the `llblab/pi-telegram` fork. Impact: published package links no longer send users to stale upstream coordinates.
- `[Validation]` Added lightweight regression tests for Telegram Markdown rendering and queue/compaction dispatch guards. Impact: key renderer and queue invariants now have repeatable automated coverage.
- `[Model Switching]` Enabled `/model` during an active Telegram-owned run by applying the new model and continuing on the new model automatically, delaying the abort until the current tool finishes when needed. Impact: Telegram can now approximate pi's manual stop-switch-continue workflow with fewer mid-tool aborts.
+9 -2
View File
@@ -9,12 +9,19 @@ Telegram DM bridge for pi.
This repository is a fork of the original [`badlogic/pi-telegram`](https://github.com/badlogic/pi-telegram).
It started from upstream commit [`cb34008460b6c1ca036d92322f69d87f626be0fc`](https://github.com/badlogic/pi-telegram/commit/cb34008460b6c1ca036d92322f69d87f626be0fc) and has since diverged substantially.
## Start Here
- [Project Context](./AGENTS.md)
- [Open Backlog](./BACKLOG.md)
- [Changelog](./CHANGELOG.md)
- [Documentation](./docs/README.md)
## What Changed In This Fork
Compared to upstream commit `cb34008`, this fork significantly extends and hardens the extension.
- Better Telegram control UI, including an improved `/status` view with inline buttons for model and thinking selection
- Interactive model selection improvements, including scoped model lists and thinking-level control for reasoning-capable models
- Interactive model selection improvements, including scoped model lists, thinking-level control for reasoning-capable models, and in-flight restart on a newly selected model for active Telegram-owned runs
- Queueing and interaction upgrades, including queue previews, reaction-based prioritization/removal, media-group handling, and safer dispatch behavior
- Markdown and reply rendering improvements, with richer formatting support and multiple fixes for incorrect Telegram rendering and chunking edge cases
- Streaming, attachment, and delivery workflow hardening, including more robust preview updates and file handling
@@ -97,7 +104,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
- `/model` opens the interactive model selector
- `/model` opens the interactive model selector 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
+9
View File
@@ -0,0 +1,9 @@
# Documentation Index
Living index of project documentation in `/docs`.
## Documents
| Document | Description |
| --- | --- |
| [architecture.md](./architecture.md) | Overview of the Telegram bridge runtime, queueing model, rendering pipeline, and interactive controls |
+110
View File
@@ -0,0 +1,110 @@
# Telegram Bridge Architecture
## Overview
`pi-telegram` is a session-local pi extension that binds one Telegram DM to one running pi session. The bridge owns four main responsibilities:
- Poll Telegram updates and enforce single-user pairing
- Translate Telegram messages and media into pi inputs
- Stream and deliver pi responses back to Telegram
- Manage Telegram-specific controls such as queue reactions, `/status`, `/model`, and `/compact`
## Runtime Structure
The implementation currently lives in `index.ts` and is organized by logical sections rather than physical modules.
Main runtime areas:
- Telegram API types and local bridge state
- Generic utilities and Markdown/rendering helpers
- Message delivery, previews, and attachment sending
- Interactive model/status menu state and callback handling
- Queue management for pending and active Telegram turns
- Polling loop and pi lifecycle-hook integration
## Message And Queue Flow
### Inbound Path
1. Telegram updates are polled through `getUpdates`
2. The bridge filters to the paired private user
3. Media groups are coalesced into a single Telegram turn when needed
4. Files are downloaded into `~/.pi/agent/tmp/telegram`
5. A `PendingTelegramTurn` is created and queued locally
6. The queue dispatcher sends the turn into pi only when dispatch is safe
### Queue Safety Model
The bridge keeps its own Telegram queue and does not rely only on pi's internal pending-message state.
Dispatch is gated by:
- No active Telegram turn
- No pending Telegram dispatch already sent to pi
- No compaction in progress
- `ctx.isIdle()` being true
- `ctx.hasPendingMessages()` being false
This prevents queue races around rapid follow-ups, `/compact`, and mixed local plus Telegram activity.
### Abort Behavior
When `/stop` aborts an active Telegram turn, queued follow-up Telegram messages can be preserved as prior-user history for the next turn. This keeps later Telegram input from being silently dropped after an interrupted run.
## Rendering Model
Telegram replies are rendered as Telegram HTML rather than raw Markdown.
Key rules:
- Rich text should render cleanly in Telegram chats
- Real code blocks must remain literal and escaped
- Long replies must be split below Telegram's 4096-character limit
- Chunking should avoid breaking HTML structure where possible
- Preview rendering is intentionally simpler than final rich rendering
The renderer is a Telegram-specific formatter, not a general Markdown engine, so rendering changes should be treated as regression-prone.
## Streaming And Delivery
During generation, the bridge streams previews back to Telegram.
Preferred order:
1. Try `sendMessageDraft`
2. Fall back to `sendMessage` plus `editMessageText`
3. Replace the preview with the final rendered reply when generation ends
Outbound files are sent only after the active Telegram turn completes and must be staged through the `telegram_attach` tool.
## Interactive Controls
The bridge exposes Telegram-side session controls in addition to regular chat forwarding.
Current operator controls include:
- `/status` for model, usage, cost, and context visibility
- Inline status buttons for model and thinking adjustments
- `/model` for interactive model selection, including 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 `👎`
## In-Flight Model Switching
When `/model` is used during an active Telegram-owned run, the bridge can emulate the interactive pi workflow of stopping, switching model, and continuing.
The current implementation does this by:
1. Applying the newly selected model immediately
2. Queuing or staging a synthetic Telegram continuation turn
3. Aborting the active Telegram turn immediately, or delaying the abort until the current tool finishes when a tool call is in flight
4. Dispatching the continuation turn after the abort completes
This behavior is intentionally limited to runs currently owned by the Telegram bridge. If pi is busy with non-Telegram work, the bridge still refuses the switch instead of hijacking unrelated session activity.
## Related
- [README.md](../README.md)
- [Project Context](../AGENTS.md)
- [Project Backlog](../BACKLOG.md)
- [Changelog](../CHANGELOG.md)
+206 -25
View File
@@ -233,6 +233,20 @@ interface TelegramUsageStats {
totalCost: number;
}
interface TelegramDispatchGuardState {
compactionInProgress: boolean;
hasActiveTelegramTurn: boolean;
hasPendingTelegramDispatch: boolean;
isIdle: boolean;
hasPendingMessages: boolean;
}
interface TelegramInFlightModelSwitchState {
isIdle: boolean;
hasActiveTelegramTurn: boolean;
hasAbortHandler: boolean;
}
type TelegramReplyMarkup = {
inline_keyboard: Array<Array<{ text: string; callback_data: string }>>;
};
@@ -322,7 +336,9 @@ function modelsMatch(
return !!a && !!b && a.provider === b.provider && a.id === b.id;
}
function getCanonicalModelId(model: Model<any>): string {
function getCanonicalModelId(
model: Pick<Model<any>, "provider" | "id">,
): string {
return `${model.provider}/${model.id}`;
}
@@ -1291,6 +1307,34 @@ function renderTelegramMessage(
}));
}
function canDispatchTelegramTurnState(
state: TelegramDispatchGuardState,
): boolean {
return (
!state.compactionInProgress &&
!state.hasActiveTelegramTurn &&
!state.hasPendingTelegramDispatch &&
state.isIdle &&
!state.hasPendingMessages
);
}
function canRestartTelegramTurnForModelSwitch(
state: TelegramInFlightModelSwitchState,
): boolean {
return !state.isIdle && state.hasActiveTelegramTurn && state.hasAbortHandler;
}
function buildTelegramModelSwitchContinuationText<
TModel extends Pick<Model<any>, "provider" | "id">,
>(model: TModel, thinkingLevel?: ThinkingLevel): string {
const modelLabel = getCanonicalModelId(model);
const thinkingSuffix = thinkingLevel
? ` Keep the selected thinking level (${thinkingLevel}) if it still applies.`
: "";
return `${TELEGRAM_PREFIX} Continue the interrupted previous Telegram request using the newly selected model (${modelLabel}). Resume from the last unfinished step instead of restarting from scratch unless necessary.${thinkingSuffix}`;
}
// --- Persistence ---
async function readConfig(): Promise<TelegramConfig> {
@@ -1314,14 +1358,25 @@ async function writeConfig(config: TelegramConfig): Promise<void> {
// --- Extension Runtime ---
export const __telegramTestUtils = {
MAX_MESSAGE_LENGTH,
renderTelegramMessage,
canDispatchTelegramTurnState,
canRestartTelegramTurnForModelSwitch,
buildTelegramModelSwitchContinuationText,
};
export default function (pi: ExtensionAPI) {
let config: TelegramConfig = {};
let pollingController: AbortController | undefined;
let pollingPromise: Promise<void> | undefined;
let queuedTelegramTurns: PendingTelegramTurn[] = [];
let nextQueuedTelegramTurnOrder = 0;
let nextSyntheticTelegramTurnOrder = -1;
let nextPriorityReactionOrder = 0;
let activeTelegramTurn: ActiveTelegramTurn | undefined;
let activeTelegramToolExecutions = 0;
let pendingTelegramModelSwitch: ScopedTelegramModel | undefined;
let telegramTurnDispatchPending = false;
let typingInterval: ReturnType<typeof setInterval> | undefined;
let currentAbort: (() => void) | undefined;
@@ -1343,13 +1398,13 @@ export default function (pi: ExtensionAPI) {
}
function canDispatchQueuedTelegramTurn(ctx: ExtensionContext): boolean {
return (
!compactionInProgress &&
!activeTelegramTurn &&
!telegramTurnDispatchPending &&
ctx.isIdle() &&
!ctx.hasPendingMessages()
);
return canDispatchTelegramTurnState({
compactionInProgress,
hasActiveTelegramTurn: !!activeTelegramTurn,
hasPendingTelegramDispatch: telegramTurnDispatchPending,
isIdle: ctx.isIdle(),
hasPendingMessages: ctx.hasPendingMessages(),
});
}
function dispatchNextQueuedTelegramTurn(ctx: ExtensionContext): void {
@@ -1419,7 +1474,11 @@ export default function (pi: ExtensionAPI) {
);
return;
}
if (activeTelegramTurn || telegramTurnDispatchPending || queuedTelegramTurns.length > 0) {
if (
activeTelegramTurn ||
telegramTurnDispatchPending ||
queuedTelegramTurns.length > 0
) {
const queued = theme.fg(
"muted",
formatQueuedTelegramTurnsStatus(queuedTelegramTurns),
@@ -2133,18 +2192,86 @@ export default function (pi: ExtensionAPI) {
modelMenus.set(messageId, state);
}
function canOfferInFlightTelegramModelSwitch(ctx: ExtensionContext): boolean {
return canRestartTelegramTurnForModelSwitch({
isIdle: ctx.isIdle(),
hasActiveTelegramTurn: !!activeTelegramTurn,
hasAbortHandler: !!currentAbort,
});
}
function createTelegramModelSwitchContinuationTurn(
turn: ActiveTelegramTurn,
selection: ScopedTelegramModel,
): PendingTelegramTurn {
const statusLabel = truncateTelegramQueueSummary(
`continue on ${selection.model.id}`,
4,
32,
);
return {
chatId: turn.chatId,
replyToMessageId: turn.replyToMessageId,
sourceMessageIds: [],
queueOrder: nextSyntheticTelegramTurnOrder--,
priorityReactionOrder: -1,
queuedAttachments: [],
content: [
{
type: "text",
text: buildTelegramModelSwitchContinuationText(
selection.model,
selection.thinkingLevel,
),
},
],
historyText: `Continue interrupted Telegram request on ${getCanonicalModelId(selection.model)}`,
statusSummary: `${statusLabel || "continue"}`,
};
}
function queueTelegramModelSwitchContinuation(
turn: ActiveTelegramTurn,
selection: ScopedTelegramModel,
ctx: ExtensionContext,
): void {
queuedTelegramTurns.push(
createTelegramModelSwitchContinuationTurn(turn, selection),
);
reorderQueuedTelegramTurns(ctx);
}
function triggerPendingTelegramModelSwitchAbort(
ctx: ExtensionContext,
): boolean {
if (
!pendingTelegramModelSwitch ||
!activeTelegramTurn ||
!currentAbort ||
activeTelegramToolExecutions > 0
) {
return false;
}
const selection = pendingTelegramModelSwitch;
pendingTelegramModelSwitch = undefined;
queueTelegramModelSwitchContinuation(activeTelegramTurn, selection, ctx);
currentAbort();
return true;
}
async function openModelMenu(
chatId: number,
replyToMessageId: number,
ctx: ExtensionContext,
): Promise<void> {
const isIdle = await ensureIdleOrNotify(
ctx,
chatId,
replyToMessageId,
"Cannot switch model while pi is busy. Send /stop first.",
);
if (!isIdle) return;
if (!ctx.isIdle() && !canOfferInFlightTelegramModelSwitch(ctx)) {
await sendTextReply(
chatId,
replyToMessageId,
"Cannot switch model while pi is busy. Send /stop first.",
);
return;
}
const state = await getModelMenuState(chatId, ctx);
if (state.allModels.length === 0) {
await sendTextReply(
@@ -2277,10 +2404,6 @@ export default function (pi: ExtensionAPI) {
);
return true;
}
if (!ctx.isIdle()) {
await answerCallbackQuery(query.id, "Pi is busy. Send /stop first.");
return true;
}
const activeModel = getCurrentTelegramModel(ctx);
if (modelsMatch(selection.model, activeModel)) {
if (
@@ -2293,6 +2416,42 @@ export default function (pi: ExtensionAPI) {
await answerCallbackQuery(query.id, `Model: ${selection.model.id}`);
return true;
}
if (!ctx.isIdle()) {
if (!activeTelegramTurn || !currentAbort) {
await answerCallbackQuery(query.id, "Pi is busy. Send /stop first.");
return true;
}
try {
const changed = await pi.setModel(selection.model);
if (changed === false) {
await answerCallbackQuery(query.id, "Model is not available.");
return true;
}
currentTelegramModel = selection.model;
if (selection.thinkingLevel) {
pi.setThinkingLevel(selection.thinkingLevel);
}
await showStatusMessage(state, ctx);
if (activeTelegramToolExecutions > 0) {
pendingTelegramModelSwitch = selection;
await answerCallbackQuery(
query.id,
`Switched to ${selection.model.id}. Restarting after the current tool finishes…`,
);
return true;
}
queueTelegramModelSwitchContinuation(activeTelegramTurn, selection, ctx);
currentAbort();
await answerCallbackQuery(
query.id,
`Switching to ${selection.model.id} and continuing…`,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await answerCallbackQuery(query.id, message);
}
return true;
}
try {
const changed = await pi.setModel(selection.model);
if (changed === false) {
@@ -2811,6 +2970,7 @@ export default function (pi: ExtensionAPI) {
ctx: ExtensionContext,
): Promise<void> {
if (currentAbort) {
pendingTelegramModelSwitch = undefined;
if (queuedTelegramTurns.length > 0) {
preserveQueuedTurnsAsHistory = true;
}
@@ -2875,7 +3035,8 @@ export default function (pi: ExtensionAPI) {
} catch (error) {
compactionInProgress = false;
updateStatus(ctx);
const errorMessage = error instanceof Error ? error.message : String(error);
const errorMessage =
error instanceof Error ? error.message : String(error);
await sendTextReply(
message.chat.id,
message.message_id,
@@ -3265,6 +3426,9 @@ export default function (pi: ExtensionAPI) {
pi.on("session_start", async (_event, ctx) => {
config = await readConfig();
currentTelegramModel = ctx.model;
activeTelegramToolExecutions = 0;
pendingTelegramModelSwitch = undefined;
nextSyntheticTelegramTurnOrder = -1;
telegramTurnDispatchPending = false;
compactionInProgress = false;
await mkdir(TEMP_DIR, { recursive: true });
@@ -3274,8 +3438,11 @@ export default function (pi: ExtensionAPI) {
pi.on("session_shutdown", async (_event, _ctx) => {
queuedTelegramTurns = [];
nextQueuedTelegramTurnOrder = 0;
nextSyntheticTelegramTurnOrder = -1;
nextPriorityReactionOrder = 0;
currentTelegramModel = undefined;
activeTelegramToolExecutions = 0;
pendingTelegramModelSwitch = undefined;
telegramTurnDispatchPending = false;
compactionInProgress = false;
for (const state of mediaGroups.values()) {
@@ -3307,6 +3474,8 @@ export default function (pi: ExtensionAPI) {
pi.on("agent_start", async (_event, ctx) => {
currentAbort = () => ctx.abort();
activeTelegramToolExecutions = 0;
pendingTelegramModelSwitch = undefined;
if (!activeTelegramTurn && telegramTurnDispatchPending) {
const nextTurn = queuedTelegramTurns.shift();
telegramTurnDispatchPending = false;
@@ -3319,6 +3488,17 @@ export default function (pi: ExtensionAPI) {
updateStatus(ctx);
});
pi.on("tool_execution_start", async (_event, _ctx) => {
if (!activeTelegramTurn) return;
activeTelegramToolExecutions += 1;
});
pi.on("tool_execution_end", async (_event, ctx) => {
if (!activeTelegramTurn) return;
activeTelegramToolExecutions = Math.max(0, activeTelegramToolExecutions - 1);
triggerPendingTelegramModelSwitchAbort(ctx);
});
pi.on("message_start", async (event, _ctx) => {
if (!activeTelegramTurn || !isAssistantMessage(event.message)) return;
if (
@@ -3350,16 +3530,20 @@ export default function (pi: ExtensionAPI) {
currentAbort = undefined;
stopTypingLoop();
activeTelegramTurn = undefined;
activeTelegramToolExecutions = 0;
pendingTelegramModelSwitch = undefined;
telegramTurnDispatchPending = false;
updateStatus(ctx);
if (!turn) {
dispatchNextQueuedTelegramTurn(ctx);
return;
}
const assistant = extractAssistantText(event.messages);
if (assistant.stopReason === "aborted") {
await clearPreview(turn.chatId);
if (!preserveQueuedTurnsAsHistory) {
dispatchNextQueuedTelegramTurn(ctx);
}
return;
}
if (assistant.stopReason === "error") {
@@ -3372,7 +3556,6 @@ export default function (pi: ExtensionAPI) {
);
return;
}
const finalText = assistant.text;
if (previewState) {
previewState.pendingText = finalText ?? previewState.pendingText;
@@ -3393,9 +3576,7 @@ export default function (pi: ExtensionAPI) {
);
}
}
await sendQueuedAttachments(turn);
if (!preserveQueuedTurnsAsHistory) {
dispatchNextQueuedTelegramTurn(ctx);
}
+6 -3
View File
@@ -14,11 +14,14 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/badlogic/pi-telegram.git"
"url": "https://github.com/llblab/pi-telegram.git"
},
"homepage": "https://github.com/badlogic/pi-telegram",
"homepage": "https://github.com/llblab/pi-telegram",
"bugs": {
"url": "https://github.com/badlogic/pi-telegram/issues"
"url": "https://github.com/llblab/pi-telegram/issues"
},
"scripts": {
"test": "node --experimental-strip-types --test tests/*.test.ts"
},
"pi": {
"extensions": [
+122
View File
@@ -0,0 +1,122 @@
import test from "node:test";
import assert from "node:assert/strict";
import { __telegramTestUtils } from "../index.ts";
test("Dispatch is allowed only when every guard is clear", () => {
assert.equal(
__telegramTestUtils.canDispatchTelegramTurnState({
compactionInProgress: false,
hasActiveTelegramTurn: false,
hasPendingTelegramDispatch: false,
isIdle: true,
hasPendingMessages: false,
}),
true,
);
});
test("Dispatch is blocked during compaction", () => {
assert.equal(
__telegramTestUtils.canDispatchTelegramTurnState({
compactionInProgress: true,
hasActiveTelegramTurn: false,
hasPendingTelegramDispatch: false,
isIdle: true,
hasPendingMessages: false,
}),
false,
);
});
test("Dispatch is blocked while a Telegram turn is active or pending", () => {
assert.equal(
__telegramTestUtils.canDispatchTelegramTurnState({
compactionInProgress: false,
hasActiveTelegramTurn: true,
hasPendingTelegramDispatch: false,
isIdle: true,
hasPendingMessages: false,
}),
false,
);
assert.equal(
__telegramTestUtils.canDispatchTelegramTurnState({
compactionInProgress: false,
hasActiveTelegramTurn: false,
hasPendingTelegramDispatch: true,
isIdle: true,
hasPendingMessages: false,
}),
false,
);
});
test("Dispatch is blocked when pi is busy or has pending messages", () => {
assert.equal(
__telegramTestUtils.canDispatchTelegramTurnState({
compactionInProgress: false,
hasActiveTelegramTurn: false,
hasPendingTelegramDispatch: false,
isIdle: false,
hasPendingMessages: false,
}),
false,
);
assert.equal(
__telegramTestUtils.canDispatchTelegramTurnState({
compactionInProgress: false,
hasActiveTelegramTurn: false,
hasPendingTelegramDispatch: false,
isIdle: true,
hasPendingMessages: true,
}),
false,
);
});
test("In-flight model switch is allowed only for active Telegram turns with abort support", () => {
assert.equal(
__telegramTestUtils.canRestartTelegramTurnForModelSwitch({
isIdle: false,
hasActiveTelegramTurn: true,
hasAbortHandler: true,
}),
true,
);
assert.equal(
__telegramTestUtils.canRestartTelegramTurnForModelSwitch({
isIdle: true,
hasActiveTelegramTurn: true,
hasAbortHandler: true,
}),
false,
);
assert.equal(
__telegramTestUtils.canRestartTelegramTurnForModelSwitch({
isIdle: false,
hasActiveTelegramTurn: false,
hasAbortHandler: true,
}),
false,
);
assert.equal(
__telegramTestUtils.canRestartTelegramTurnForModelSwitch({
isIdle: false,
hasActiveTelegramTurn: true,
hasAbortHandler: false,
}),
false,
);
});
test("Continuation prompt stays Telegram-scoped and resume-oriented", () => {
const text = __telegramTestUtils.buildTelegramModelSwitchContinuationText(
{ provider: "openai", id: "gpt-5", name: "GPT-5" },
"high",
);
assert.match(text, /^\[telegram\]/);
assert.match(text, /Continue the interrupted previous Telegram request/);
assert.match(text, /openai\/gpt-5/);
assert.match(text, /thinking level \(high\)/);
});
+60
View File
@@ -0,0 +1,60 @@
import test from "node:test";
import assert from "node:assert/strict";
import { __telegramTestUtils } from "../index.ts";
test("Nested lists stay out of code blocks", () => {
const chunks = __telegramTestUtils.renderTelegramMessage(
"- Level 1\n - Level 2\n - Level 3 with **bold** text",
{ mode: "markdown" },
);
assert.ok(chunks.length > 0);
assert.equal(
chunks.some((chunk) => chunk.text.includes("<pre><code>")),
false,
);
assert.equal(
chunks.some((chunk) =>
chunk.text.includes("• Level 3 with <b>bold</b> text"),
),
true,
);
});
test("Fenced code blocks preserve literal markdown", () => {
const chunks = __telegramTestUtils.renderTelegramMessage(
'~~~ts\nconst value = "**raw**";\n~~~',
{ mode: "markdown" },
);
assert.equal(chunks.length, 1);
assert.match(chunks[0]?.text ?? "", /<pre><code class="language-ts">/);
assert.match(chunks[0]?.text ?? "", /\*\*raw\*\*/);
});
test("Underscores inside words do not become italic", () => {
const chunks = __telegramTestUtils.renderTelegramMessage(
"Path: foo_bar_baz.txt and **bold**",
{ mode: "markdown" },
);
assert.equal(chunks.length, 1);
assert.equal((chunks[0]?.text ?? "").includes("<i>bar</i>"), false);
assert.match(chunks[0]?.text ?? "", /<b>bold<\/b>/);
});
test("Long markdown replies stay chunked below Telegram limits", () => {
const markdown = Array.from(
{ length: 600 },
(_, index) => `- item **${index}**`,
).join("\n");
const chunks = __telegramTestUtils.renderTelegramMessage(markdown, {
mode: "markdown",
});
assert.ok(chunks.length > 1);
for (const chunk of chunks) {
assert.ok(chunk.text.length <= __telegramTestUtils.MAX_MESSAGE_LENGTH);
assert.equal(
(chunk.text.match(/<b>/g) ?? []).length,
(chunk.text.match(/<\/b>/g) ?? []).length,
);
}
});