mirror of
https://github.com/wassname/pi-telegram.git
synced 2026-06-27 15:16:19 +08:00
agents & in-flight model switching
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
# Project Backlog
|
||||
|
||||
## Open Backlog
|
||||
|
||||
- No open backlog items right now
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
if (!ctx.isIdle() && !canOfferInFlightTelegramModelSwitch(ctx)) {
|
||||
await sendTextReply(
|
||||
chatId,
|
||||
replyToMessageId,
|
||||
"Cannot switch model while pi is busy. Send /stop first.",
|
||||
);
|
||||
if (!isIdle) return;
|
||||
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
@@ -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": [
|
||||
|
||||
@@ -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\)/);
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user