diff --git a/README.md b/README.md index 8023d6e..ec10357 100644 --- a/README.md +++ b/README.md @@ -274,7 +274,7 @@ Then invite the app to the channel and pair in that channel/thread with `relay p | create delegation task (opt-in shared rooms) | `/delegate ` | `relay delegate ` | `/relay delegate ` | | control delegation task | `/task@ [task-id]` (or `/task [task-id]` in private/other clients) | `relay task [task-id]` | `/relay task [task-id]` | -`quiet`, `normal`, `verbose`, and `completion-only` are valid progress modes. Progress mode controls non-terminal progress noise: quiet and completion-only suppress progress updates, normal sends standard progress, and verbose sends more frequent progress. Terminal notifications still deliver the final assistant answer when it fits safe platform limits, splitting by paragraphs within platform limits and falling back to a Markdown document when an adapter supports files and the output is too large for a reasonable chat burst. +`quiet`, `normal`, `verbose`, and `completion-only` are valid progress modes. Progress mode controls non-terminal progress noise: quiet suppresses progress updates, completion-only sends final results plus safe compaction notifications, normal sends coalesced milestone progress, and verbose additionally includes safe visible model/tool snapshots at a shorter interval. Progress updates are deduplicated/coalesced, and Telegram live progress is edited in place where possible instead of posting every raw Pi stream event. Terminal notifications still deliver the final assistant answer when it fits safe platform limits, splitting by paragraphs within platform limits and falling back to a Markdown document when an adapter supports files and the output is too large for a reasonable chat burst. Remote `/disconnect` is scoped to the requesting chat/conversation only: it revokes that Telegram, Discord, or Slack binding and suppresses future session output/buttons there, without disconnecting other messengers that remain paired to the same Pi session. Local `/relay disconnect` is broader and disconnects the current session from all paired messenger bindings. diff --git a/extensions/relay/adapters/discord/runtime.ts b/extensions/relay/adapters/discord/runtime.ts index 23a2d1a..c5c8070 100644 --- a/extensions/relay/adapters/discord/runtime.ts +++ b/extensions/relay/adapters/discord/runtime.ts @@ -4,17 +4,17 @@ import { completeDiscordPairing } from "../channel-pairing.js"; import { DiscordChannelAdapter, discordMentionsSharedRoomAddressing, discordPairingCommand, discordRelayPairingCommand, isDiscordIdentityAllowed, type DiscordApiOperations } from "./adapter.js"; import { createDiscordLiveOperations } from "./live-client.js"; import { TunnelStateStore } from "../../state/tunnel-store.js"; -import type { ChannelPersistedBindingRecord, LatestTurnImage, PairingApprovalDecision, ProgressMode, SessionRoute, TelegramTunnelConfig } from "../../core/types.js"; +import type { ChannelPersistedBindingRecord, LatestTurnImage, PairingApprovalDecision, ProgressActivityEntry, ProgressMode, SessionRoute, TelegramTunnelConfig } from "../../core/types.js"; import { commandAllowsWhilePaused, normalizeAliasArg, parseRemoteCommandInvocation, buildHelpText } from "../../commands/remote.js"; import { delegationTaskActionButtons, parseDelegationInvocation, renderDelegationTaskCard } from "../../commands/delegation.js"; import { formatFullOutput, formatLatestImageEmptyMessage, formatRelayRecentActivity, formatRelayStatusForRoute, formatSessionSelectorError, formatSummaryOutput } from "../../formatting/presenters.js"; import { formatSessionList, resolveSessionSelector, resolveSessionTargetArgs, type SessionListEntry } from "../../core/session-selection.js"; -import { displayProgressMode, normalizeProgressMode, progressModeFor } from "../../notifications/progress.js"; +import { displayProgressMode, formatProgressUpdate, normalizeProgressMode, progressIntervalMsFor, progressModeFor, shouldSendProgressActivity } from "../../notifications/progress.js"; import { sendFinalOutputWithFallback } from "../../core/final-output.js"; import { formatRelayLifecycleNotification, type RelayLifecycleEventKind } from "../../notifications/lifecycle.js"; import { abortRouteSafely, compactRouteSafely, deliverRoutePrompt, latestRouteImagesSafely, probeRouteAvailability, routeActionDisplayMessage, routeIdleState, routeImageByPathSafely, routeModelState, routeSkillCommandsSafely, routeWorkspaceRootSafely, unavailableRouteMessage } from "../../core/route-actions.js"; import { statusSnapshotForRoute } from "../../core/relay-core.js"; -import { authorityOutcomeAllowsDelivery, bindingAuthorityDiagnostic, resolveChannelBindingAuthority } from "../../core/binding-authority.js"; +import { authorityOutcomeAllowsDelivery, bindingAuthorityDiagnostic, channelDestinationKey, resolveChannelBindingAuthority } from "../../core/binding-authority.js"; import { redactSecrets } from "../../config/setup.js"; import { buildImagePromptContent, modelSupportsImages, summarizeTextDeterministically } from "../../core/utils.js"; import { prepareInboundImagePromptContent } from "../../media/index.js"; @@ -73,6 +73,7 @@ export class DiscordRuntime { private readonly activeSessionByConversationUser = new Map(); private readonly recentBindingBySessionKey = new Map(); private readonly typingStates = new Map }>(); + private readonly progressStates = new Map }>(); private readonly invalidPairingAttempts = new Map(); private readonly activeDelegationTaskBySessionKey = new Map(); private started = false; @@ -121,6 +122,7 @@ export class DiscordRuntime { async stop(): Promise { this.started = false; this.clearAllTypingActivity(); + this.clearAllProgressStates(); await this.adapter?.stopPolling?.(); } @@ -131,10 +133,12 @@ export class DiscordRuntime { this.ownedBindingSessionKeys.add(route.sessionKey); this.recentBindingBySessionKey.set(route.sessionKey, binding); } + this.syncProgressDelivery(route); } async unregisterRoute(sessionKey: string): Promise { this.stopTypingActivity(sessionKey); + this.clearProgressStateBySessionKey(sessionKey); this.routes.delete(sessionKey); this.ownedBindingSessionKeys.delete(sessionKey); this.recentBindingBySessionKey.delete(sessionKey); @@ -1423,6 +1427,92 @@ export class DiscordRuntime { this.invalidPairingAttempts.set(key, { ...current, count: current.count + 1 }); } + private progressKey(route: SessionRoute): string | undefined { + const binding = this.recentBindingBySessionKey.get(route.sessionKey); + return binding ? channelDestinationKey({ channel: DISCORD_CHANNEL, instanceId: this.instanceId, sessionKey: route.sessionKey, conversationId: binding.conversationId, userId: binding.userId }) : undefined; + } + + private clearAllProgressStates(): void { + for (const state of this.progressStates.values()) { + if (state.timer) clearTimeout(state.timer); + } + this.progressStates.clear(); + } + + private clearProgressStateByKey(key: string): void { + const state = this.progressStates.get(key); + if (state?.timer) clearTimeout(state.timer); + this.progressStates.delete(key); + } + + private clearProgressStateBySessionKey(sessionKey: string): void { + const prefix = `${DISCORD_CHANNEL}:${this.instanceId}:${sessionKey}:`; + for (const [key] of this.progressStates) { + if (key.startsWith(prefix)) this.clearProgressStateByKey(key); + } + } + + private clearProgressState(route: SessionRoute): void { + const key = this.progressKey(route); + if (key) this.clearProgressStateByKey(key); + else this.clearProgressStateBySessionKey(route.sessionKey); + } + + private syncProgressDelivery(route: SessionRoute): void { + const event = route.notification.progressEvent; + const binding = this.recentBindingBySessionKey.get(route.sessionKey); + const key = this.progressKey(route); + const deliverableEvent = event && (route.notification.lastStatus === "running" || event.kind === "compaction"); + if (!key || !event || !deliverableEvent || !binding || binding.paused) { + if (route.notification.lastStatus && isTerminalStatus(route.notification.lastStatus)) this.clearProgressState(route); + return; + } + const mode = progressModeFor({ progressMode: channelProgressMode(binding) }, this.config); + if (!shouldSendProgressActivity(mode, event)) return; + let state = this.progressStates.get(key); + if (!state) { + state = { pending: [] }; + this.progressStates.set(key, state); + } + if (state.lastEventId === event.id) return; + state.lastEventId = event.id; + state.pending.push(event); + if (state.timer) return; + const interval = progressIntervalMsFor(mode, this.config); + const elapsed = state.lastSentAt ? Date.now() - state.lastSentAt : interval; + const delay = Math.max(0, interval - elapsed); + state.timer = setTimeout(() => { + void this.flushProgress(route.sessionKey, binding, key).catch((error: unknown) => { + this.lastError = safeDiscordRuntimeError(error); + }); + }, delay); + unrefTimer(state.timer); + } + + private async flushProgress(sessionKey: string, expectedBinding: ChannelPersistedBindingRecord, key: string): Promise { + const state = this.progressStates.get(key); + if (!state) return; + state.timer = undefined; + state.lastSentAt = Date.now(); + const route = this.routes.get(sessionKey); + const binding = route ? await this.activeBindingForRoute(route, { includePaused: true, address: bindingAddress(expectedBinding) }) : undefined; + if (!route || !binding || binding.conversationId !== expectedBinding.conversationId || binding.userId !== expectedBinding.userId || binding.paused) { + this.clearProgressStateByKey(key); + return; + } + const mode = progressModeFor({ progressMode: channelProgressMode(binding) }, this.config); + const pending = state.pending.splice(0).filter((entry) => (route.notification.lastStatus === "running" || entry.kind === "compaction") && shouldSendProgressActivity(mode, entry)); + if (pending.length === 0) { + this.clearProgressState(route); + return; + } + if (!this.adapter) return; + const text = formatProgressUpdate(pending, this.config, { header: false }); + if (!text) return; + state.lastSentAt = Date.now(); + await this.adapter.sendText(bindingAddress(binding), text); + } + private startTypingActivity(route: SessionRoute, address: ChannelRouteAddress): void { this.stopTypingActivity(route.sessionKey); this.typingStates.set(route.sessionKey, { address }); diff --git a/extensions/relay/adapters/slack/runtime.ts b/extensions/relay/adapters/slack/runtime.ts index 5654053..09199b3 100644 --- a/extensions/relay/adapters/slack/runtime.ts +++ b/extensions/relay/adapters/slack/runtime.ts @@ -9,7 +9,7 @@ import { buildHelpText, commandAllowsWhilePaused, normalizeAliasArg, parseRemote import { delegationTaskActionButtons, parseDelegationInvocation, renderDelegationTaskCard } from "../../commands/delegation.js"; import { formatFullOutput, formatLatestImageEmptyMessage, formatRelayRecentActivity, formatRelayStatusForRoute, formatSessionSelectorError, formatSummaryOutput, sessionEntryForRoute } from "../../formatting/presenters.js"; import { formatSessionList, resolveSessionSelector, resolveSessionTargetArgs, type SessionListEntry } from "../../core/session-selection.js"; -import { displayProgressMode, formatProgressUpdate, normalizeProgressMode, progressIntervalMsFor, progressModeFor, shouldSendNonTerminalProgress } from "../../notifications/progress.js"; +import { displayProgressMode, formatProgressUpdate, normalizeProgressMode, progressIntervalMsFor, progressModeFor, shouldSendProgressActivity } from "../../notifications/progress.js"; import { sendFinalOutputWithFallback } from "../../core/final-output.js"; import { deliverWorkspaceFileToRequester, formatRequesterFileDeliveryResult, parseRemoteSendFileArgs, type RelayFileDeliveryRequester } from "../../core/requester-file-delivery.js"; import { abortRouteSafely, compactRouteSafely, deliverRoutePrompt, latestRouteImagesSafely, probeRouteAvailability, routeActionDisplayMessage, routeIdleState, routeImageByPathSafely, routeModelState, routeSkillCommandsSafely, routeWorkspaceRootSafely, unavailableRouteMessage } from "../../core/route-actions.js"; @@ -1494,15 +1494,13 @@ export class SlackRuntime { const event = route.notification.progressEvent; const binding = this.recentBindingBySessionKey.get(route.sessionKey); const key = this.progressKey(route); - if (!key || !event || !binding || binding.paused || route.notification.lastStatus !== "running") { + const deliverableEvent = event && (route.notification.lastStatus === "running" || event.kind === "compaction"); + if (!key || !event || !deliverableEvent || !binding || binding.paused) { if (route.notification.lastStatus && isTerminalStatus(route.notification.lastStatus)) this.clearProgressState(route); return; } const mode = progressModeFor({ progressMode: channelProgressMode(binding) }, this.config); - if (!shouldSendNonTerminalProgress(mode)) { - this.clearProgressState(route); - return; - } + if (!shouldSendProgressActivity(mode, event)) return; let state = this.progressStates.get(key); if (!state) { state = { pending: [] }; @@ -1527,19 +1525,20 @@ export class SlackRuntime { const state = this.progressStates.get(key); if (!state) return; state.timer = undefined; + state.lastSentAt = Date.now(); const route = this.routes.get(sessionKey); const binding = route ? await this.activeBindingForRoute(route, { includePaused: true, address: bindingAddress(expectedBinding) }) : undefined; - if (!route || !binding || binding.conversationId !== expectedBinding.conversationId || binding.userId !== expectedBinding.userId || binding.paused || route.notification.lastStatus !== "running") { + if (!route || !binding || binding.conversationId !== expectedBinding.conversationId || binding.userId !== expectedBinding.userId || binding.paused) { this.clearProgressStateByKey(key); return; } const mode = progressModeFor({ progressMode: channelProgressMode(binding) }, this.config); - if (!shouldSendNonTerminalProgress(mode)) { + const pending = state.pending.splice(0).filter((entry) => (route.notification.lastStatus === "running" || entry.kind === "compaction") && shouldSendProgressActivity(mode, entry)); + if (pending.length === 0) { this.clearProgressState(route); return; } - const pending = state.pending.splice(0); - const text = formatProgressUpdate(pending, this.config); + const text = formatProgressUpdate(pending, this.config, { header: false }); if (!text || !this.adapter) return; state.lastSentAt = Date.now(); await this.adapter.sendText(bindingAddress(binding), text); diff --git a/extensions/relay/adapters/telegram/adapter.ts b/extensions/relay/adapters/telegram/adapter.ts index 8aa24f9..f011785 100644 --- a/extensions/relay/adapters/telegram/adapter.ts +++ b/extensions/relay/adapters/telegram/adapter.ts @@ -9,6 +9,7 @@ import type { ChannelInboundFile, ChannelInboundHandler, ChannelInboundMessage, + ChannelLiveProgressRef, ChannelOutboundFile, ChannelOutboundPayload, ChannelRouteAddress, @@ -31,6 +32,8 @@ const TELEGRAM_CHANNEL: ChannelAdapterKind = "telegram"; export interface TelegramApiOperations { getUpdates(offset: number | undefined): Promise>; sendPlainTextWithKeyboard(chatId: number, text: string, keyboard?: TelegramInlineKeyboard): Promise; + sendEditablePlainText?(chatId: number, text: string): Promise; + editPlainText?(chatId: number, messageId: number, text: string): Promise; sendDocumentData(chatId: number, filename: string, data: Uint8Array, caption?: string): Promise; answerCallbackQuery(callbackQueryId: string, text?: string, alert?: boolean): Promise; sendChatAction(chatId: number, action?: "typing" | "upload_document" | "record_video"): Promise; @@ -93,6 +96,37 @@ export class TelegramChannelAdapter implements ChannelAdapter { await this.api.sendPlainTextWithKeyboard(telegramChatId(address), text, options?.buttons ? toTelegramKeyboard(options.buttons) : undefined); } + async sendLiveProgress(address: ChannelRouteAddress, text: string): Promise { + if (!this.api.sendEditablePlainText) { + await this.sendText(address, text); + return undefined; + } + try { + const messageId = await this.api.sendEditablePlainText(telegramChatId(address), text); + return { messageId: String(messageId) }; + } catch { + await this.sendText(address, text); + return undefined; + } + } + + async updateLiveProgress(address: ChannelRouteAddress, ref: ChannelLiveProgressRef, text: string): Promise { + if (!this.api.editPlainText) { + await this.sendText(address, text); + return; + } + const messageId = Number(ref.messageId); + if (!Number.isSafeInteger(messageId)) { + await this.sendText(address, text); + return; + } + try { + await this.api.editPlainText(telegramChatId(address), messageId, text); + } catch { + await this.sendText(address, text); + } + } + async sendDocument(address: ChannelRouteAddress, file: ChannelOutboundFile, options?: { caption?: string; buttons?: ChannelButtonLayout }): Promise { await this.api.sendDocumentData(telegramChatId(address), file.fileName, outboundFileBytes(file), options?.caption); if (options?.buttons) { diff --git a/extensions/relay/adapters/telegram/api.ts b/extensions/relay/adapters/telegram/api.ts index 45376c1..f5b5253 100644 --- a/extensions/relay/adapters/telegram/api.ts +++ b/extensions/relay/adapters/telegram/api.ts @@ -132,6 +132,23 @@ export class TelegramApiClient { await this.sendPreparedChatTextWithKeyboard(chatId, formatted, keyboard); } + async sendEditablePlainText(chatId: number, text: string): Promise { + const redacted = redactSecret(text, this.config.redactionPatterns); + const formatted = formatTelegramChatText(redacted); + const chunk = chunkTelegramText(formatted.replace(/\r\n/g, "\n"), this.config.maxTelegramMessageChars)[0]; + const rendered = this.renderPreparedChunk(chunk?.text ?? ""); + const message = await this.withRetry(() => this.api.sendMessage(chatId, rendered.text, rendered.parseMode ? { parse_mode: rendered.parseMode } : undefined)); + return message.message_id; + } + + async editPlainText(chatId: number, messageId: number, text: string): Promise { + const redacted = redactSecret(text, this.config.redactionPatterns); + const formatted = formatTelegramChatText(redacted); + const chunk = chunkTelegramText(formatted.replace(/\r\n/g, "\n"), this.config.maxTelegramMessageChars)[0]; + const rendered = this.renderPreparedChunk(chunk?.text ?? ""); + await this.withRetry(() => this.api.editMessageText(chatId, messageId, rendered.text, rendered.parseMode ? { parse_mode: rendered.parseMode } : undefined)); + } + /** * Sends chat text that is safe to render as Telegram HTML where supported. * The method applies configured secret redaction before chunking so callers diff --git a/extensions/relay/adapters/telegram/runtime.ts b/extensions/relay/adapters/telegram/runtime.ts index 4d73bd1..432b6e2 100644 --- a/extensions/relay/adapters/telegram/runtime.ts +++ b/extensions/relay/adapters/telegram/runtime.ts @@ -87,7 +87,7 @@ import { progressIntervalMsFor, progressModeFor, recentActivityLimit, - shouldSendNonTerminalProgress, + shouldSendProgressActivity, } from "../../notifications/progress.js"; import { buildImagePromptContent, @@ -106,6 +106,15 @@ const TELEGRAM_ACTIVITY_ACTION = "typing" as const; const TELEGRAM_ACTIVITY_INITIAL_REFRESH_MS = 1_200; const TELEGRAM_ACTIVITY_REFRESH_MS = 4_000; const CUSTOM_ANSWER_EXPIRY_MS = 10 * 60_000; + +type TelegramProgressDeliveryState = { + lastEventId?: string; + pending: NonNullable; + timer?: ReturnType; + lastSentAt?: number; + liveMessageId?: number; + lastText?: string; +}; const ANSWER_AMBIGUITY_EXPIRY_MS = 5 * 60_000; interface TelegramGroupCommandTarget { @@ -174,7 +183,7 @@ export class InProcessTunnelRuntime implements TunnelRuntime { private readonly pendingSkillInputs = new Map(); private readonly pendingAnswerAmbiguities = new Map(); private readonly activityIndicators = new Map>(); - private readonly progressStates = new Map; timer?: ReturnType; lastSentAt?: number }>(); + private readonly progressStates = new Map(); private readonly activeSessionByChatUser = new Map(); private readonly sharedRoomOutputDestinations = new Map(); private readonly activeDelegationTaskBySessionKey = new Map(); @@ -671,12 +680,13 @@ export class InProcessTunnelRuntime implements TunnelRuntime { const event = route.notification.progressEvent; const binding = this.outputBindingForRoute(route); const key = this.progressKey(route); - if (!key || !event || !binding || binding.paused || route.notification.lastStatus !== "running") { + const deliverableEvent = event && (route.notification.lastStatus === "running" || event.kind === "compaction"); + if (!key || !event || !deliverableEvent || !binding || binding.paused) { if (route.notification.lastStatus && isTerminalStatus(route.notification.lastStatus)) this.clearProgressState(route); return; } const mode = progressModeFor(binding, this.config); - if (!shouldSendNonTerminalProgress(mode)) return; + if (!shouldSendProgressActivity(mode, event)) return; let state = this.progressStates.get(key); if (!state) { state = { pending: [] }; @@ -698,18 +708,58 @@ export class InProcessTunnelRuntime implements TunnelRuntime { const state = this.progressStates.get(key); if (!state) return; state.timer = undefined; + state.lastSentAt = Date.now(); const route = this.routes.get(sessionKey); const binding = route ? await this.activeOutputBindingForRoute(route) : undefined; - if (!route || !binding || binding.chatId !== chatId || (userId !== undefined && binding.userId !== userId) || binding.paused || route.notification.lastStatus !== "running") { + if (!route || !binding || binding.chatId !== chatId || (userId !== undefined && binding.userId !== userId) || binding.paused) { if (route) this.clearProgressState(route); else this.progressStates.delete(key); return; } - const pending = state.pending.splice(0); - const text = formatProgressUpdate(pending, this.config); - if (!text) return; + const mode = progressModeFor(binding, this.config); + const pending = state.pending.splice(0).filter((entry) => (route.notification.lastStatus === "running" || entry.kind === "compaction") && shouldSendProgressActivity(mode, entry)); + if (pending.length === 0) { + this.clearProgressState(route); + return; + } + const text = formatProgressUpdate(pending, this.config, { header: false }); + if (!text) { + this.clearProgressState(route); + return; + } state.lastSentAt = Date.now(); - await this.api.sendPlainText(chatId, `${this.sourcePrefixForRoute(route)}${text}`); + const messageText = `${this.sourcePrefixForRoute(route)}${text}`; + await this.deliverProgressSnapshot(chatId, state, messageText); + } + + private async deliverProgressSnapshot(chatId: number, state: TelegramProgressDeliveryState, messageText: string): Promise { + // Live progress is best-effort: prefer edit-in-place, then editable send, then a plain snapshot. + if (state.lastText === messageText) return; + const editableApi = this.api as TelegramApiClient & { sendEditablePlainText?: (chatId: number, text: string) => Promise; editPlainText?: (chatId: number, messageId: number, text: string) => Promise }; + if (state.liveMessageId && editableApi.editPlainText) { + try { + await editableApi.editPlainText(chatId, state.liveMessageId, messageText); + state.lastText = messageText; + return; + } catch { + state.liveMessageId = undefined; + } + } + if (editableApi.sendEditablePlainText) { + try { + state.liveMessageId = await editableApi.sendEditablePlainText(chatId, messageText); + state.lastText = messageText; + return; + } catch { + state.liveMessageId = undefined; + } + } + try { + await this.api.sendPlainText(chatId, messageText); + state.lastText = messageText; + } catch { + state.liveMessageId = undefined; + } } private async acquireLock(): Promise { diff --git a/extensions/relay/broker/process.js b/extensions/relay/broker/process.js index 67bcdb5..0313d25 100644 --- a/extensions/relay/broker/process.js +++ b/extensions/relay/broker/process.js @@ -119,7 +119,7 @@ const normalizeProgressMode = requiredFunction(progressModule, './progress.ts', const progressIntervalMsFor = requiredFunction(progressModule, './progress.ts', 'progressIntervalMsFor'); const progressModeFor = requiredFunction(progressModule, './progress.ts', 'progressModeFor'); const recentActivityLimit = requiredFunction(progressModule, './progress.ts', 'recentActivityLimit'); -const shouldSendNonTerminalProgress = requiredFunction(progressModule, './progress.ts', 'shouldSendNonTerminalProgress'); +const shouldSendProgressActivity = requiredFunction(progressModule, './progress.ts', 'shouldSendProgressActivity'); const HELP_TEXT = requiredString(commandsModule, './commands.ts', 'BROKER_HELP_TEXT'); const commandAllowsWhilePaused = requiredFunction(commandsModule, './commands.ts', 'commandAllowsWhilePaused'); const normalizeAliasArg = requiredFunction(commandsModule, './commands.ts', 'normalizeAliasArg'); @@ -149,6 +149,7 @@ const diagnosticsConfig = JSON.parse(process.env.PI_RELAY_COMMUNICATION_DIAGNOST const skipPolling = process.env.TELEGRAM_TUNNEL_BROKER_SKIP_POLLING === '1'; const testTelegramOutboxPath = testTelegramOutboxPathFromEnv(process.env); const testIngressSecret = process.env.PI_RELAY_BROKER_TEST_INGRESS_SECRET; +let testFailNextEditableProgressSend = process.env.PI_RELAY_BROKER_TEST_FAIL_EDITABLE_PROGRESS_SEND_ONCE === '1'; const diagnosticsLogger = createCommunicationDiagnosticsLogger(diagnosticsConfig); function recordDiagnostic(event) { if (!diagnosticsLogger.config?.enabled) return; @@ -317,9 +318,16 @@ async function appendBrokerTestTelegramOutbox(event) { return appendTestTelegramOutbox(event, { outboxPath: testTelegramOutboxPath, recordDiagnostic }); } +let brokerTestTelegramMessageId = 10_000; + async function sendTelegramMessage(chatId, text, options) { - if (await appendBrokerTestTelegramOutbox({ method: 'sendMessage', chatId, text, options })) return; - await api.sendMessage(chatId, text, options); + if (await appendBrokerTestTelegramOutbox({ method: 'sendMessage', chatId, text, options })) return { message_id: brokerTestTelegramMessageId++ }; + return api.sendMessage(chatId, text, options); +} + +async function editTelegramMessage(chatId, messageId, text, options) { + if (await appendBrokerTestTelegramOutbox({ method: 'editMessageText', chatId, messageId, text, options })) return; + return api.editMessageText(chatId, messageId, text, options); } async function sendTelegramDocument(chatId, document, options, testDocument) { @@ -341,6 +349,25 @@ async function sendPreparedPlainText(chatId, text, keyboard) { } } +async function sendEditablePlainText(chatId, text) { + if (testFailNextEditableProgressSend) { + testFailNextEditableProgressSend = false; + throw new Error('Simulated editable progress send failure.'); + } + const chunk = chunkText(text)[0] || ''; + const prepared = prepareTelegramChunkForSend(chunk); + const options = prepared.parseMode ? { parse_mode: prepared.parseMode } : undefined; + const message = await withRetry(() => sendTelegramMessage(chatId, prepared.text, options)); + return typeof message?.message_id === 'number' ? message.message_id : undefined; +} + +async function editPlainText(chatId, messageId, text) { + const chunk = chunkText(text)[0] || ''; + const prepared = prepareTelegramChunkForSend(chunk); + const options = prepared.parseMode ? { parse_mode: prepared.parseMode } : undefined; + await withRetry(() => editTelegramMessage(chatId, messageId, prepared.text, options)); +} + async function sendPlainText(chatId, text, keyboard) { const chunks = chunkText(text); recordDiagnostic({ component: 'broker', event: 'notification.send', messenger: 'telegram', outcome: 'attempt', conversationId: String(chatId), details: { kind: 'text', chunks: chunks.length, hasKeyboard: Boolean(keyboard), textLength: String(text || '').length } }); @@ -1194,12 +1221,13 @@ function clearProgressState(route) { function syncProgressDelivery(route) { const event = route?.notification?.progressEvent; const key = getProgressKey(route); - if (!key || !event || !route?.binding || route.binding.paused || route.notification?.lastStatus !== 'running') { + const deliverableEvent = event && (route?.notification?.lastStatus === 'running' || event.kind === 'compaction'); + if (!key || !event || !deliverableEvent || !route?.binding || route.binding.paused) { if (route?.notification?.lastStatus && isTerminalStatus(route.notification.lastStatus)) clearProgressState(route); return; } const mode = progressModeFor(route.binding, config); - if (!shouldSendNonTerminalProgress(mode)) return; + if (!shouldSendProgressActivity(mode, event)) return; let state = progressStates.get(key); if (!state) { state = { pending: [] }; @@ -1223,17 +1251,55 @@ async function flushProgress(sessionKey, chatId, userId, key) { const state = progressStates.get(key); if (!state) return; state.timer = undefined; + state.lastSentAt = Date.now(); const route = routes.get(sessionKey); const binding = await activeBindingForRoute(route, { includePaused: true }); - if (!route || !binding || binding.chatId !== chatId || (userId !== undefined && binding.userId !== userId) || binding.paused || route.notification?.lastStatus !== 'running') { + if (!route || !binding || binding.chatId !== chatId || (userId !== undefined && binding.userId !== userId) || binding.paused) { clearProgressStateByKey(key); return; } route.binding = binding; - const text = formatProgressUpdate(state.pending.splice(0), config); - if (!text) return; + const mode = progressModeFor(binding, config); + const pending = state.pending.splice(0).filter((entry) => (route.notification?.lastStatus === 'running' || entry.kind === 'compaction') && shouldSendProgressActivity(mode, entry)); + if (pending.length === 0) { + clearProgressStateByKey(key); + return; + } + const text = formatProgressUpdate(pending, config, { header: false }); + if (!text) { + clearProgressStateByKey(key); + return; + } state.lastSentAt = Date.now(); - await sendPlainText(chatId, `${sourcePrefixForRoute(route)}${text}`); + const messageText = `${sourcePrefixForRoute(route)}${text}`; + await deliverProgressSnapshot(chatId, state, messageText); +} + +async function deliverProgressSnapshot(chatId, state, messageText) { + // Live progress is best-effort: prefer edit-in-place, then editable send, then a plain snapshot. + if (state.lastText === messageText) return; + if (state.liveMessageId) { + try { + await editPlainText(chatId, state.liveMessageId, messageText); + state.lastText = messageText; + return; + } catch { + state.liveMessageId = undefined; + } + } + try { + state.liveMessageId = await sendEditablePlainText(chatId, messageText); + state.lastText = messageText; + return; + } catch { + state.liveMessageId = undefined; + } + try { + await sendPlainText(chatId, messageText); + state.lastText = messageText; + } catch { + state.liveMessageId = undefined; + } } async function pairingHashForCode(nonce) { diff --git a/extensions/relay/core/channel-adapter.ts b/extensions/relay/core/channel-adapter.ts index c140311..59a95d3 100644 --- a/extensions/relay/core/channel-adapter.ts +++ b/extensions/relay/core/channel-adapter.ts @@ -197,12 +197,18 @@ export interface ChannelAdapter extends ChannelAdapterMetadata { handleWebhook?(payload: unknown, headers: Record, handler: ChannelInboundHandler): Promise; send(payload: ChannelOutboundPayload): Promise; sendText(address: ChannelRouteAddress, text: string, options?: { buttons?: ChannelButtonLayout }): Promise; + sendLiveProgress?(address: ChannelRouteAddress, text: string): Promise; + updateLiveProgress?(address: ChannelRouteAddress, ref: ChannelLiveProgressRef, text: string): Promise; sendDocument(address: ChannelRouteAddress, file: ChannelOutboundFile, options?: { caption?: string; buttons?: ChannelButtonLayout }): Promise; sendImage(address: ChannelRouteAddress, file: ChannelOutboundFile, options?: { caption?: string; buttons?: ChannelButtonLayout }): Promise; sendActivity(address: ChannelRouteAddress, activity: ChannelOutboundActivity["activity"]): Promise; answerAction(actionId: string, options?: { text?: string; alert?: boolean }): Promise; } +export interface ChannelLiveProgressRef { + messageId: string; +} + export type RelayPromptContent = string | (TextContent | ImageContent)[]; export interface RelayPromptDeliveryRequest { diff --git a/extensions/relay/core/types.ts b/extensions/relay/core/types.ts index 8dc9d05..05761ba 100644 --- a/extensions/relay/core/types.ts +++ b/extensions/relay/core/types.ts @@ -15,10 +15,12 @@ export type ProgressMode = "quiet" | "normal" | "verbose" | "completionOnly"; export interface ProgressActivityEntry { id: string; - kind: "lifecycle" | "tool" | "assistant" | "status"; + kind: "lifecycle" | "tool" | "assistant" | "status" | "compaction"; text: string; detail?: string; at: number; + delivery?: "milestone" | "volatile"; + semanticKey?: string; } export interface SharedRoomRelayConfig { diff --git a/extensions/relay/notifications/progress.ts b/extensions/relay/notifications/progress.ts index 1cbc564..407a773 100644 --- a/extensions/relay/notifications/progress.ts +++ b/extensions/relay/notifications/progress.ts @@ -5,6 +5,9 @@ export const DEFAULT_PROGRESS_INTERVAL_MS = 30_000; export const DEFAULT_VERBOSE_PROGRESS_INTERVAL_MS = 10_000; export const DEFAULT_RECENT_ACTIVITY_LIMIT = 10; export const DEFAULT_MAX_PROGRESS_MESSAGE_CHARS = 700; +export const COMPACTION_PROGRESS_STARTED_TEXT = "Context compaction started"; +export const COMPACTION_PROGRESS_COMPLETED_TEXT = "Context compaction completed"; +export const DEFAULT_LIVE_PROGRESS_MARKER = "●"; export const PROGRESS_MODES: ProgressMode[] = ["quiet", "normal", "verbose", "completionOnly"]; @@ -31,6 +34,31 @@ export function shouldSendNonTerminalProgress(mode: ProgressMode): boolean { return mode === "normal" || mode === "verbose"; } +export function shouldSendCompactionProgress(mode: ProgressMode): boolean { + return mode !== "quiet"; +} + +export function shouldSendProgressActivity(mode: ProgressMode, entry: Pick & Partial>): boolean { + if (entry.kind === "compaction") return shouldSendCompactionProgress(mode); + if (progressActivityDelivery(entry) === "volatile") return mode === "verbose"; + return shouldSendNonTerminalProgress(mode); +} + +export function progressActivityDelivery(entry: Pick & Partial>): "milestone" | "volatile" { + if (entry.delivery) return entry.delivery; + if (entry.kind === "assistant") return "volatile"; + const text = entry.text?.trim() ?? ""; + if (entry.kind === "status" && /^model update$/i.test(text)) return "volatile"; + if (entry.kind === "tool" && /^processed tool result$/i.test(text)) return "volatile"; + return "milestone"; +} + +export function progressSemanticKey(entry: Pick): string { + if (entry.semanticKey?.trim()) return normalizeProgressKey(entry.semanticKey); + const delivery = progressActivityDelivery(entry); + return normalizeProgressKey(`${delivery}:${entry.kind}:${entry.text}:${entry.detail ?? ""}`); +} + export function progressIntervalMsFor(mode: ProgressMode, config: Pick): number { if (mode === "verbose") return positiveNumber(config.verboseProgressIntervalMs, DEFAULT_VERBOSE_PROGRESS_INTERVAL_MS); return positiveNumber(config.progressIntervalMs, DEFAULT_PROGRESS_INTERVAL_MS); @@ -67,9 +95,12 @@ export function createProgressActivity(input: { text: string; detail?: string; at?: number; + delivery?: ProgressActivityEntry["delivery"]; + semanticKey?: string; }, config: Pick): ProgressActivityEntry | undefined { const text = sanitizeProgressText(input.text, config); const detail = sanitizeProgressText(input.detail, config); + const semanticKey = sanitizeProgressText(input.semanticKey, config); if (!text) return undefined; return { id: input.id, @@ -77,6 +108,8 @@ export function createProgressActivity(input: { text, detail: detail || undefined, at: input.at ?? Date.now(), + delivery: input.delivery, + semanticKey: semanticKey ? normalizeProgressKey(semanticKey) : undefined, }; } @@ -92,11 +125,42 @@ export function appendRecentActivity( return next; } -export function formatProgressUpdate(entries: ProgressActivityEntry[], config: Pick): string | undefined { - const latest = coalesceProgressEntries(entries); +export function coalesceLiveProgressEntries(entries: ProgressActivityEntry[]): ProgressActivityEntry[] { + type CountedProgressActivityEntry = ProgressActivityEntry & { count?: number }; + const milestones = new Map(); + const volatileByKind = new Map(); + + for (const entry of entries) { + const delivery = progressActivityDelivery(entry); + if (delivery === "volatile") { + const key = `${entry.kind}:${normalizeProgressKey(entry.text)}`; + const existing = volatileByKind.get(key); + if (!existing || entry.at >= existing.at) volatileByKind.set(key, { ...entry }); + continue; + } + const key = progressSemanticKey(entry); + const existing = milestones.get(key); + if (existing) { + existing.count = (existing.count ?? 1) + 1; + existing.at = Math.max(existing.at, entry.at); + continue; + } + milestones.set(key, { ...entry }); + } + + const latestVolatile = [...volatileByKind.values()]; + return ([...milestones.values(), ...latestVolatile] as CountedProgressActivityEntry[]) + .sort((left, right) => left.at - right.at) + .slice(-5) + .map((entry) => entry.count && entry.count > 1 ? { ...entry, text: `${entry.text} (${entry.count}×)` } : entry); +} + +export function formatProgressUpdate(entries: ProgressActivityEntry[], config: Pick, options: { header?: boolean; marker?: string } = {}): string | undefined { + const latest = coalesceLiveProgressEntries(entries); if (latest.length === 0) return undefined; - const body = latest.map((entry) => `• ${entry.text}${entry.detail ? ` — ${entry.detail}` : ""}`).join("\n"); - const output = `Pi progress\n${body}`; + const marker = options.marker ?? DEFAULT_LIVE_PROGRESS_MARKER; + const body = latest.map((entry) => `${marker} ${entry.text}${entry.detail ? ` — ${entry.detail}` : ""}`).join("\n"); + const output = (options.header ?? true) ? `Pi progress\n${body}` : body; const maxChars = maxProgressMessageChars(config); return output.length > maxChars ? `${output.slice(0, maxChars - 1).trimEnd()}…` : output; } @@ -116,24 +180,6 @@ export function sessionDisplayName(entry: { sessionLabel: string; alias?: string return entry.alias?.trim() || entry.sessionLabel; } -function coalesceProgressEntries(entries: ProgressActivityEntry[]): ProgressActivityEntry[] { - const byText = new Map(); - for (const entry of entries) { - const key = `${entry.kind}:${entry.text}:${entry.detail ?? ""}`; - const existing = byText.get(key); - if (existing) { - existing.count = (existing.count ?? 1) + 1; - existing.at = Math.max(existing.at, entry.at); - continue; - } - byText.set(key, { ...entry }); - } - return [...byText.values()] - .sort((left, right) => left.at - right.at) - .slice(-5) - .map((entry) => entry.count && entry.count > 1 ? { ...entry, text: `${entry.text} (${entry.count}×)` } : entry); -} - function relativeTime(at: number, now: number): string { const seconds = Math.max(0, Math.round((now - at) / 1000)); if (seconds < 60) return `${seconds}s ago`; @@ -143,6 +189,10 @@ function relativeTime(at: number, now: number): string { return `${hours}h ago`; } +function normalizeProgressKey(value: string): string { + return value.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim().toLowerCase(); +} + function positiveNumber(value: number | undefined, fallback: number): number { return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback; } diff --git a/extensions/relay/runtime/extension-runtime.ts b/extensions/relay/runtime/extension-runtime.ts index 12979e6..3a1a18b 100644 --- a/extensions/relay/runtime/extension-runtime.ts +++ b/extensions/relay/runtime/extension-runtime.ts @@ -13,7 +13,7 @@ import type { BindingEntryData, ChannelPersistedBindingRecord, DiscordRelayConfi import { extractStructuredAnswerMetadata } from "../core/guided-answer.js"; import type { DiscordRuntime } from "../adapters/discord/runtime.js"; import type { SlackRuntime } from "../adapters/slack/runtime.js"; -import { appendRecentActivity, createProgressActivity, recentActivityLimit } from "../notifications/progress.js"; +import { appendRecentActivity, COMPACTION_PROGRESS_COMPLETED_TEXT, COMPACTION_PROGRESS_STARTED_TEXT, createProgressActivity, recentActivityLimit } from "../notifications/progress.js"; import { authorityOutcomeAllowsDelivery, resolveChannelBindingAuthority, resolveTelegramBindingAuthority } from "../core/binding-authority.js"; import { formatRelayLifecycleNotification, type RelayLifecycleEventKind } from "../notifications/lifecycle.js"; import { formatRelayStatusLine, type RelayStatusLineBindingState, type RelayStatusLineChannel } from "./status-line.js"; @@ -638,7 +638,12 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { return images; } - function recordProgress(kind: "lifecycle" | "tool" | "assistant" | "status", text: string, detail?: string): void { + function toolLifecycleSemanticKey(prefix: string, toolCallId: unknown): string { + const stableId = typeof toolCallId === "string" && toolCallId.trim() ? toolCallId.trim() : `missing-${Date.now()}-${progressSequence + 1}`; + return `${prefix}:${stableId}`; + } + + function recordProgress(kind: "lifecycle" | "tool" | "assistant" | "status" | "compaction", text: string, detail?: string, options: { delivery?: "milestone" | "volatile"; semanticKey?: string } = {}): void { if (!currentRoute) return; const config = configCache; const entry = createProgressActivity({ @@ -646,6 +651,8 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { kind, text, detail, + delivery: options.delivery, + semanticKey: options.semanticKey, }, config ?? { redactionPatterns: [], maxProgressMessageChars: undefined }); if (!entry) return; currentRoute.notification.progressEvent = entry; @@ -2021,6 +2028,24 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { } }); + pi.on("session_before_compact", async (_event, ctx) => { + latestContext = ctx; + if (!currentRoute) return; + currentRoute.actions.context = ctx; + recordProgress("compaction", COMPACTION_PROGRESS_STARTED_TEXT); + recordDiagnostic({ component: "runtime", event: "session_before_compact", outcome: "started", ...diagnosticRouteFields() }); + publishRouteStateSoon(); + }); + + pi.on("session_compact", async (_event, ctx) => { + latestContext = ctx; + if (!currentRoute) return; + currentRoute.actions.context = ctx; + recordProgress("compaction", COMPACTION_PROGRESS_COMPLETED_TEXT); + recordDiagnostic({ component: "runtime", event: "session_compact", outcome: "completed", ...diagnosticRouteFields() }); + publishRouteStateSoon(); + }); + pi.on("agent_start", async (_event, ctx) => { latestContext = ctx; if (!currentRoute) return; @@ -2054,7 +2079,10 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { currentRoute.notification.lastAssistantText = assistantText; currentRoute.lastActivityAt = Date.now(); if (currentRoute.notification.lastStatus === "running") { - recordProgress("assistant", "Drafting response"); + const streamEvent = event.assistantMessageEvent; + if (streamEvent?.type === "text_end" && typeof streamEvent.content === "string" && streamEvent.content.trim()) { + recordProgress("assistant", "Model update", summarizeTextDeterministically(streamEvent.content, 280), { delivery: "volatile", semanticKey: `assistant:${streamEvent.content}` }); + } publishRouteStateSoon(); } } @@ -2079,7 +2107,7 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { const toolText = extractTextContent(event.message.content as never); if (toolText) activeTurnImagePathTexts.push(toolText); if (currentRoute.notification.lastStatus === "running") { - recordProgress("tool", "Processed tool result"); + recordProgress("tool", "Processed tool result", undefined, { delivery: "volatile", semanticKey: `tool-result:${event.message.toolCallId ?? "unknown"}` }); publishRouteStateSoon(); } } @@ -2144,11 +2172,11 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { recordDiagnostic({ component: "runtime", event: "tool_execution_end", outcome: event.isError ? "error" : "ok", ...diagnosticRouteFields(), details: { toolName: String(event.toolName ?? ""), isError: Boolean(event.isError) } }); if (event.isError) { currentRoute.notification.lastFailure = `Tool ${event.toolName} failed.`; - recordProgress("tool", "Tool failed", event.toolName); + recordProgress("tool", "Tool failed", event.toolName, { delivery: "milestone", semanticKey: toolLifecycleSemanticKey("tool-failed", event.toolCallId) }); publishRouteStateSoon(); return; } - recordProgress("tool", "Tool completed", event.toolName); + recordProgress("tool", "Tool completed", event.toolName, { delivery: "milestone", semanticKey: toolLifecycleSemanticKey("tool-completed", event.toolCallId) }); publishRouteStateSoon(); }); diff --git a/openspec/changes/add-remote-skill-invocation/.openspec.yaml b/openspec/changes/archive/2026-06-14-add-remote-skill-invocation/.openspec.yaml similarity index 100% rename from openspec/changes/add-remote-skill-invocation/.openspec.yaml rename to openspec/changes/archive/2026-06-14-add-remote-skill-invocation/.openspec.yaml diff --git a/openspec/changes/add-remote-skill-invocation/design.md b/openspec/changes/archive/2026-06-14-add-remote-skill-invocation/design.md similarity index 100% rename from openspec/changes/add-remote-skill-invocation/design.md rename to openspec/changes/archive/2026-06-14-add-remote-skill-invocation/design.md diff --git a/openspec/changes/add-remote-skill-invocation/proposal.md b/openspec/changes/archive/2026-06-14-add-remote-skill-invocation/proposal.md similarity index 100% rename from openspec/changes/add-remote-skill-invocation/proposal.md rename to openspec/changes/archive/2026-06-14-add-remote-skill-invocation/proposal.md diff --git a/openspec/changes/add-remote-skill-invocation/specs/messenger-command-surfaces/spec.md b/openspec/changes/archive/2026-06-14-add-remote-skill-invocation/specs/messenger-command-surfaces/spec.md similarity index 100% rename from openspec/changes/add-remote-skill-invocation/specs/messenger-command-surfaces/spec.md rename to openspec/changes/archive/2026-06-14-add-remote-skill-invocation/specs/messenger-command-surfaces/spec.md diff --git a/openspec/changes/add-remote-skill-invocation/specs/messenger-relay-sessions/spec.md b/openspec/changes/archive/2026-06-14-add-remote-skill-invocation/specs/messenger-relay-sessions/spec.md similarity index 100% rename from openspec/changes/add-remote-skill-invocation/specs/messenger-relay-sessions/spec.md rename to openspec/changes/archive/2026-06-14-add-remote-skill-invocation/specs/messenger-relay-sessions/spec.md diff --git a/openspec/changes/add-remote-skill-invocation/specs/relay-interaction-middleware/spec.md b/openspec/changes/archive/2026-06-14-add-remote-skill-invocation/specs/relay-interaction-middleware/spec.md similarity index 100% rename from openspec/changes/add-remote-skill-invocation/specs/relay-interaction-middleware/spec.md rename to openspec/changes/archive/2026-06-14-add-remote-skill-invocation/specs/relay-interaction-middleware/spec.md diff --git a/openspec/changes/add-remote-skill-invocation/specs/relay-skill-invocation/spec.md b/openspec/changes/archive/2026-06-14-add-remote-skill-invocation/specs/relay-skill-invocation/spec.md similarity index 100% rename from openspec/changes/add-remote-skill-invocation/specs/relay-skill-invocation/spec.md rename to openspec/changes/archive/2026-06-14-add-remote-skill-invocation/specs/relay-skill-invocation/spec.md diff --git a/openspec/changes/add-remote-skill-invocation/tasks.md b/openspec/changes/archive/2026-06-14-add-remote-skill-invocation/tasks.md similarity index 100% rename from openspec/changes/add-remote-skill-invocation/tasks.md rename to openspec/changes/archive/2026-06-14-add-remote-skill-invocation/tasks.md diff --git a/openspec/changes/archive/2026-06-14-coalesce-live-progress-updates/.openspec.yaml b/openspec/changes/archive/2026-06-14-coalesce-live-progress-updates/.openspec.yaml new file mode 100644 index 0000000..64b47c4 --- /dev/null +++ b/openspec/changes/archive/2026-06-14-coalesce-live-progress-updates/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-13 diff --git a/openspec/changes/archive/2026-06-14-coalesce-live-progress-updates/design.md b/openspec/changes/archive/2026-06-14-coalesce-live-progress-updates/design.md new file mode 100644 index 0000000..9121670 --- /dev/null +++ b/openspec/changes/archive/2026-06-14-coalesce-live-progress-updates/design.md @@ -0,0 +1,82 @@ +## Context + +Pi emits a live session event stream with message start/update/end, tool lifecycle, queue, compaction, and terminal events. The local Pi terminal renders much of this as mutable UI state: one assistant message component is updated in place, and tool components are updated as execution progresses. + +PiRelay currently flattens selected events into messenger chat messages. This works for stable milestones and final output, but it is a poor fit for volatile stream snapshots. Recent experiments with relaying safe assistant text showed the problem clearly: identical or superseded model updates can become many Telegram messages because each update is a distinct relay progress event. Tool lifecycle handling can also report overlapping internal events such as `Processed tool result` and `Tool completed — bash`. + +The relay should treat Pi progress as session state first and messenger messages second. The delivery strategy should depend on messenger capability: edit a single live status message when supported, or send a coalesced snapshot at a controlled cadence when not. + +## Goals / Non-Goals + +**Goals:** +- Prevent duplicate progress messages caused by repeated stream snapshots or overlapping lifecycle events. +- Make normal progress mode useful and low-noise by delivering stable milestones and coalesced live status only. +- Preserve verbose progress for users who want more detail, while still deduplicating and rate-limiting it. +- Use edit-in-place for live progress when the messenger adapter supports it, starting with Telegram as the primary target. +- Fall back to sending coalesced snapshots where editing is unsupported or fails. +- Keep final completion/failure/abort output as separate terminal notifications. +- Preserve existing authorization, paused/revoked binding, progress-mode, and secret-safety boundaries. + +**Non-Goals:** +- Relaying hidden thinking, chain-of-thought, raw transcript content, hidden prompts, tool internals, or full streaming deltas. +- Replacing Pi's local terminal rendering. +- Removing existing progress modes in this change. +- Implementing rich threaded dashboards for every messenger platform in the first iteration. + +## Decisions + +1. **Represent progress as coalesced session state, not a list of raw events.** + - Introduce a progress accumulator that accepts safe progress activities and produces a current live status snapshot plus optional stable milestones. + - Rationale: Pi's terminal is stateful; messenger delivery should mirror that concept instead of posting every intermediate event. + - Alternative considered: Deduplicate individual messages by text hash only. This helps immediate spam but does not solve superseded updates or tool lifecycle overlap. + +2. **Use progress classes to distinguish milestones from volatile live status.** + - Stable milestones include start, compaction start/end, explicit failures, and meaningful tool boundaries. + - Volatile live status includes assistant/model stream snapshots and rapidly changing tool-output/progress details. + - Rationale: normal mode should not depend on low-level event timing. + - Alternative considered: Keep the existing `kind` enum as-is. It is too coarse: `status` can mean either stable or volatile status. + +3. **Normal mode sends low-noise milestones and coalesced snapshots; verbose can include more detail.** + - Normal SHALL suppress generic stream snapshots unless they are folded into a live status update that replaces or coalesces earlier state. + - Verbose MAY include additional technical details but MUST still deduplicate repeated content. + - Rationale: a remote chat should remain readable during long tasks. + +4. **Prefer edit-in-place where available.** + - Adapter contract should expose optional live-progress update capability, for example `sendOrUpdateProgress(address, state)` or equivalent methods that return/update a platform message reference. + - Telegram can use `sendMessage` then `editMessageText` for the live status message. + - Slack and Discord can use edit APIs if wired later; until then they can use snapshot fallback. + - Rationale: edit-in-place best matches Pi's terminal live component. + - Alternative considered: Always send snapshots. This is simpler but still creates chat history noise during long runs. + +5. **Keep terminal notifications separate from live progress.** + - Completion/failure/abort final output remains a distinct message and should clear or finalize live progress state. + - Rationale: final output is durable user content; live status is ephemeral operational feedback. + +6. **Persist only minimal non-sensitive live message references if needed.** + - Editable message ids may be stored per binding/session if needed for broker/runtime restarts. + - Stored state MUST NOT include raw transcript text or hidden content. + - Rationale: edit-in-place may need a platform message id, but progress text can be recomputed from safe current state or safely dropped. + +## Risks / Trade-offs + +- **Risk: Editable message state becomes stale after messenger deletion or restart.** → Treat edit failures as non-fatal, clear the stored reference, and fall back to sending a new coalesced snapshot. +- **Risk: Coalescing hides useful detail.** → Keep verbose mode for additional technical progress and preserve `/recent` for bounded recent activity. +- **Risk: Platform parity differs because edit support varies.** → Define messenger-neutral behavior as coalesced delivery; edit-in-place is an optimization, not a correctness requirement. +- **Risk: Progress accumulator accidentally retains sensitive text.** → Accept only sanitized safe progress activities and add tests proving hidden thinking, raw identifiers, pairing codes, and summaries are omitted. +- **Risk: Implementation touches multiple adapters and broker path.** → Implement via shared helpers and adapter capability methods to avoid divergent policy. + +## Migration Plan + +1. Add shared accumulator/formatter helpers and tests without changing delivery behavior. +2. Route runtime progress events through the accumulator and preserve current progress modes. +3. Update Telegram direct and broker delivery to edit or coalesce live progress messages. +4. Update Slack and Discord to use coalesced snapshot fallback, with optional edit support left for a later platform-specific enhancement. +5. Validate with typecheck, full tests, and OpenSpec validation. +6. Rollback strategy: disable edit-in-place and use snapshot fallback while retaining dedupe/coalescing helpers. + +## Open Questions + +- What exact visual format should the compact live status use: session color marker, session label, both, or platform-specific prefix? +- Should successful short-lived tool completions be omitted entirely in normal mode, or shown only when they take longer than a threshold? +- Should `/recent` show coalesced live status history, raw bounded milestones, or both? +- Should Telegram live progress messages be deleted, finalized, or left as-is after final output is delivered? diff --git a/openspec/changes/archive/2026-06-14-coalesce-live-progress-updates/proposal.md b/openspec/changes/archive/2026-06-14-coalesce-live-progress-updates/proposal.md new file mode 100644 index 0000000..348be91 --- /dev/null +++ b/openspec/changes/archive/2026-06-14-coalesce-live-progress-updates/proposal.md @@ -0,0 +1,42 @@ +## Why + +PiRelay currently maps Pi's live session event stream too directly into messenger chat messages. Streaming assistant updates and overlapping tool lifecycle events can produce duplicated or low-value messages, while Pi's own terminal renders the same events as mutable live state. + +Remote users need timely awareness without chat spam. PiRelay should coalesce volatile progress into stable messenger updates, using edit-in-place when a messenger supports it and sending only the final coalesced progress snapshot when it does not. + +## What Changes + +- Introduce a messenger-neutral live progress delivery model that separates volatile Pi stream state from stable milestone notifications. +- Coalesce repeated or superseded progress updates before messenger delivery. +- Prefer editing a single live progress/status message for messengers that support message updates, starting with Telegram where practical. +- For messengers without edit-in-place support or where updating fails, send only coalesced snapshots at a controlled cadence rather than every raw event. +- Keep terminal completion/failure/abort notifications and full final output delivery as separate messages. +- Preserve compaction start/end notifications in every progress mode except quiet. +- Clarify normal vs verbose progress behavior: + - normal: stable milestones and coalesced live status only + - verbose: may include more detailed technical progress, still deduplicated and rate-limited + - completion-only: final results plus explicitly allowed lifecycle notices such as compaction + - quiet: suppress progress notifications +- Do not relay hidden thinking, raw transcripts, tool internals, pairing codes, destination identifiers, or secrets. + +## Capabilities + +### New Capabilities + +### Modified Capabilities +- `messenger-relay-sessions`: Progress delivery SHALL coalesce live Pi session state and use edit-in-place or final coalesced snapshots instead of emitting duplicate raw stream-event messages. + +## Impact + +- Affected runtime code: + - `extensions/relay/runtime/extension-runtime.ts` + - `extensions/relay/notifications/progress.ts` + - Telegram, Discord, Slack direct runtimes + - Telegram broker progress delivery path +- Affected tests: + - progress helper tests + - Telegram runtime/broker progress tests + - Slack and Discord progress parity tests + - integration tests for safe assistant/tool progress handling +- No new runtime dependencies are expected. +- State changes, if needed for editable message ids, must be backward-compatible and must not persist sensitive transcript content. diff --git a/openspec/changes/archive/2026-06-14-coalesce-live-progress-updates/specs/messenger-relay-sessions/spec.md b/openspec/changes/archive/2026-06-14-coalesce-live-progress-updates/specs/messenger-relay-sessions/spec.md new file mode 100644 index 0000000..4624edb --- /dev/null +++ b/openspec/changes/archive/2026-06-14-coalesce-live-progress-updates/specs/messenger-relay-sessions/spec.md @@ -0,0 +1,58 @@ +## ADDED Requirements + +### Requirement: Coalesced live progress delivery +The system SHALL deliver Pi session progress to messengers as coalesced live state rather than as a direct stream of raw Pi events. + +#### Scenario: Repeated live status is not duplicated +- **WHEN** Pi emits repeated assistant stream updates, repeated safe model status text, or otherwise equivalent progress activities for the same running turn +- **THEN** the messenger receives at most one current live-progress representation for that equivalent status within the configured delivery window +- **AND** the system does not post a new chat message for every repeated raw Pi event + +#### Scenario: Superseded live status is coalesced +- **WHEN** multiple volatile progress updates occur before the messenger delivery window elapses +- **THEN** the system delivers the latest coalesced safe status, plus any stable milestones that remain relevant +- **AND** superseded volatile snapshots are not delivered as separate messenger messages + +#### Scenario: Editable messengers update live status in place +- **WHEN** a messenger adapter supports updating a previously sent message and a paired binding is eligible to receive progress +- **THEN** the system uses a single live progress message for the active turn where practical +- **AND** later live progress updates edit that message instead of appending duplicate chat messages +- **AND** final completion, failure, abort, and full-output messages remain separate terminal notifications + +#### Scenario: Non-editable messengers receive coalesced snapshots +- **WHEN** a messenger adapter does not support updating a previously sent progress message or an update attempt fails +- **THEN** the system falls back to sending coalesced progress snapshots at the configured cadence +- **AND** it still avoids sending duplicate raw stream-event messages + +#### Scenario: Normal progress mode is low-noise +- **WHEN** a binding uses normal progress mode during a running Pi turn +- **THEN** the messenger receives stable milestones and coalesced live status only +- **AND** generic assistant streaming snapshots, repeated drafting text, and overlapping tool-result bookkeeping messages are not delivered as standalone progress messages + +#### Scenario: Verbose progress mode remains bounded +- **WHEN** a binding uses verbose progress mode during a running Pi turn +- **THEN** the messenger MAY receive more detailed progress than normal mode +- **BUT** repeated equivalent updates MUST still be deduplicated or coalesced +- **AND** delivery MUST respect the configured verbose progress interval and platform message limits + +#### Scenario: Completion-only and quiet progress modes remain respected +- **WHEN** a binding uses completion-only progress mode +- **THEN** the messenger receives terminal final output and explicitly allowed lifecycle notices such as compaction progress +- **AND** it does not receive ordinary live progress snapshots +- **WHEN** a binding uses quiet progress mode +- **THEN** the messenger does not receive live progress snapshots or compaction progress notifications + +#### Scenario: Tool lifecycle progress is human-level in normal mode +- **WHEN** Pi emits overlapping tool lifecycle events such as tool execution completion and tool-result message completion for the same tool call +- **THEN** normal progress mode does not deliver both as separate technical messages +- **AND** the system either collapses them into one safe human-readable milestone or omits successful short-lived tool chatter + +#### Scenario: Live progress remains secret-safe +- **WHEN** the system formats or updates live progress for any messenger +- **THEN** it excludes hidden thinking content, chain-of-thought, hidden prompts, raw transcripts, pairing codes, bot tokens, raw chat or channel identifiers, and full compaction summaries +- **AND** only sanitized safe progress text may be stored or delivered + +#### Scenario: Authorization still gates progress delivery +- **WHEN** a binding is paused, revoked, stale, unauthorized, or no longer authoritative for a route +- **THEN** the system does not send, edit, or finalize live progress messages for that binding +- **AND** any pending live progress state for that destination is cleared or ignored safely diff --git a/openspec/changes/archive/2026-06-14-coalesce-live-progress-updates/tasks.md b/openspec/changes/archive/2026-06-14-coalesce-live-progress-updates/tasks.md new file mode 100644 index 0000000..8ea1d49 --- /dev/null +++ b/openspec/changes/archive/2026-06-14-coalesce-live-progress-updates/tasks.md @@ -0,0 +1,41 @@ +## 1. Progress Model and Formatting + +- [x] 1.1 Add shared types/helpers for live progress state, stable milestones, volatile status snapshots, destination keys, and progress-mode eligibility. +- [x] 1.2 Implement semantic deduplication for equivalent progress activities using normalized kind/text/detail or a stable progress key rather than event id alone. +- [x] 1.3 Implement coalescing rules that keep stable milestones, keep only the latest volatile status per category, and drop superseded stream snapshots. +- [x] 1.4 Update progress formatting to support compact one-line and bounded multi-line output without the repetitive `Pi progress` header where the adapter can provide source context. +- [x] 1.5 Add safety filtering tests proving live progress omits hidden thinking, raw transcripts, pairing codes, destination identifiers, tokens, and compaction summaries. + +## 2. Runtime Event Classification + +- [x] 2.1 Classify assistant/model stream updates as volatile live status rather than stable normal-mode milestones. +- [x] 2.2 Classify compaction start/end as stable lifecycle milestones that remain eligible in every progress mode except quiet. +- [x] 2.3 Collapse overlapping tool lifecycle events so normal mode does not emit both `Processed tool result` and `Tool completed — ` for the same tool call. +- [x] 2.4 Keep verbose mode capable of exposing additional technical progress while still using dedupe and coalescing. +- [x] 2.5 Add integration tests for repeated assistant updates, overlapping tool events, compaction events, and final-output separation. + +## 3. Adapter and Broker Delivery + +- [x] 3.1 Extend the messenger adapter/runtime contract with optional live-progress edit capability and a fallback snapshot path. +- [x] 3.2 Implement Telegram direct live progress delivery using send-then-edit where possible, with safe fallback to coalesced snapshots when edit fails. +- [x] 3.3 Implement equivalent Telegram broker delivery behavior, including minimal non-secret message-reference handling if required. +- [x] 3.4 Update Slack and Discord delivery paths to use coalesced snapshot fallback while preserving authorization, paused/revoked, and binding authority checks. +- [x] 3.5 Ensure terminal completion, failure, abort, and full-output delivery finalize or clear live progress state without merging final output into live status. + +## 4. Progress Mode UX + +- [x] 4.1 Verify normal mode delivers only stable milestones and coalesced live status, never duplicate stream snapshots or generic tool-result bookkeeping. +- [x] 4.2 Verify verbose mode can deliver more detailed progress but remains deduplicated, coalesced, rate-limited, and bounded by platform message limits. +- [x] 4.3 Verify completion-only receives final output and allowed compaction notices but no ordinary live progress snapshots. +- [x] 4.4 Verify quiet receives no live progress or compaction progress. +- [x] 4.5 Update README/help text to explain the revised normal, verbose, completion-only, and quiet behavior. + +## 5. Tests and Validation + +- [x] 5.1 Add unit tests for accumulator/coalescing/deduplication helpers. +- [x] 5.2 Add Telegram runtime tests for edit-in-place delivery, edit failure fallback, terminal finalization, and progress-mode filtering. +- [x] 5.3 Add broker tests for coalesced progress delivery and preservation of authorization/binding authority checks. +- [x] 5.4 Add Slack and Discord parity tests for coalesced snapshot fallback and queued-progress preservation. +- [x] 5.5 Run `npm run typecheck`. +- [x] 5.6 Run `npm test`. +- [x] 5.7 Run `openspec validate coalesce-live-progress-updates --strict`. diff --git a/openspec/changes/fallback-final-assistant-output/proposal.md b/openspec/changes/archive/2026-06-14-fallback-final-assistant-output/proposal.md similarity index 100% rename from openspec/changes/fallback-final-assistant-output/proposal.md rename to openspec/changes/archive/2026-06-14-fallback-final-assistant-output/proposal.md diff --git a/openspec/changes/fallback-final-assistant-output/specs/messenger-relay-sessions/spec.md b/openspec/changes/archive/2026-06-14-fallback-final-assistant-output/specs/messenger-relay-sessions/spec.md similarity index 100% rename from openspec/changes/fallback-final-assistant-output/specs/messenger-relay-sessions/spec.md rename to openspec/changes/archive/2026-06-14-fallback-final-assistant-output/specs/messenger-relay-sessions/spec.md diff --git a/openspec/changes/fallback-final-assistant-output/tasks.md b/openspec/changes/archive/2026-06-14-fallback-final-assistant-output/tasks.md similarity index 100% rename from openspec/changes/fallback-final-assistant-output/tasks.md rename to openspec/changes/archive/2026-06-14-fallback-final-assistant-output/tasks.md diff --git a/openspec/changes/improve-telegram-output-readability/.openspec.yaml b/openspec/changes/archive/2026-06-14-improve-telegram-output-readability/.openspec.yaml similarity index 100% rename from openspec/changes/improve-telegram-output-readability/.openspec.yaml rename to openspec/changes/archive/2026-06-14-improve-telegram-output-readability/.openspec.yaml diff --git a/openspec/changes/improve-telegram-output-readability/design.md b/openspec/changes/archive/2026-06-14-improve-telegram-output-readability/design.md similarity index 100% rename from openspec/changes/improve-telegram-output-readability/design.md rename to openspec/changes/archive/2026-06-14-improve-telegram-output-readability/design.md diff --git a/openspec/changes/improve-telegram-output-readability/proposal.md b/openspec/changes/archive/2026-06-14-improve-telegram-output-readability/proposal.md similarity index 100% rename from openspec/changes/improve-telegram-output-readability/proposal.md rename to openspec/changes/archive/2026-06-14-improve-telegram-output-readability/proposal.md diff --git a/openspec/changes/improve-telegram-output-readability/specs/messenger-relay-sessions/spec.md b/openspec/changes/archive/2026-06-14-improve-telegram-output-readability/specs/messenger-relay-sessions/spec.md similarity index 100% rename from openspec/changes/improve-telegram-output-readability/specs/messenger-relay-sessions/spec.md rename to openspec/changes/archive/2026-06-14-improve-telegram-output-readability/specs/messenger-relay-sessions/spec.md diff --git a/openspec/changes/improve-telegram-output-readability/specs/relay-file-delivery/spec.md b/openspec/changes/archive/2026-06-14-improve-telegram-output-readability/specs/relay-file-delivery/spec.md similarity index 100% rename from openspec/changes/improve-telegram-output-readability/specs/relay-file-delivery/spec.md rename to openspec/changes/archive/2026-06-14-improve-telegram-output-readability/specs/relay-file-delivery/spec.md diff --git a/openspec/changes/improve-telegram-output-readability/tasks.md b/openspec/changes/archive/2026-06-14-improve-telegram-output-readability/tasks.md similarity index 100% rename from openspec/changes/improve-telegram-output-readability/tasks.md rename to openspec/changes/archive/2026-06-14-improve-telegram-output-readability/tasks.md diff --git a/openspec/changes/archive/2026-06-14-notify-session-compaction-progress/.openspec.yaml b/openspec/changes/archive/2026-06-14-notify-session-compaction-progress/.openspec.yaml new file mode 100644 index 0000000..64b47c4 --- /dev/null +++ b/openspec/changes/archive/2026-06-14-notify-session-compaction-progress/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-13 diff --git a/openspec/changes/archive/2026-06-14-notify-session-compaction-progress/design.md b/openspec/changes/archive/2026-06-14-notify-session-compaction-progress/design.md new file mode 100644 index 0000000..ebd114d --- /dev/null +++ b/openspec/changes/archive/2026-06-14-notify-session-compaction-progress/design.md @@ -0,0 +1,50 @@ +## Context + +Pi exposes compaction to extensions through `session_before_compact` and `session_compact`. `session_before_compact` fires for manual compaction and auto-compaction, including threshold and overflow paths, but it does not expose the compaction reason. `session_compact` fires after a compaction entry is successfully appended. + +PiRelay already tracks per-route progress through `SessionRoute.notification.progressEvent` and adapter/broker delivery loops. Progress modes are binding-specific: `quiet`, `normal`, `verbose`, and `completion-only`. Existing non-terminal activity is generally delivered only for normal and verbose, while terminal output is still delivered for completion-only. This change needs a small exception: compaction lifecycle notifications should be visible in every progress mode except quiet. + +## Goals / Non-Goals + +**Goals:** + +- Notify active paired messenger bindings when Pi begins compaction. +- Notify active paired messenger bindings when Pi successfully completes compaction. +- Respect binding-specific progress mode: send in normal, verbose, and completion-only; suppress in quiet. +- Keep notifications messenger-neutral and safe across Telegram, Discord, Slack, and broker delivery. +- Avoid disrupting compaction if remote notification delivery fails. + +**Non-Goals:** + +- Expose or send the generated compaction summary to messengers. +- Distinguish manual, threshold, and overflow compactions; Pi's extension hook does not currently expose the reason. +- Guarantee an end/failure notification for auto-compaction failures that occur after `session_before_compact` but before `session_compact`; Pi extensions do not currently receive `compaction_end`. +- Change Pi's extension API. + +## Decisions + +1. **Use existing Pi extension hooks rather than SDK-only events.** + - Decision: Use `session_before_compact` for start and `session_compact` for successful completion. + - Rationale: These hooks are available to PiRelay extensions today and cover auto-compaction. + - Alternative considered: Wait for `compaction_start`/`compaction_end` extension events. That would provide richer status but blocks useful notifications on upstream Pi changes. + +2. **Represent compaction as a dedicated progress lifecycle event.** + - Decision: Add route progress activity such as `Context compaction started` and `Context compaction completed` using existing sanitized progress formatting and recent-activity storage. + - Rationale: It reuses existing broker and adapter progress transport, redaction, and recent activity behavior. + - Alternative considered: Add a separate notification channel. That would duplicate rate limiting, binding authority checks, and platform delivery logic. + +3. **Use a compaction-specific progress-mode predicate.** + - Decision: Introduce or apply logic equivalent to `mode !== "quiet"` for compaction notifications instead of `shouldSendNonTerminalProgress()`. + - Rationale: The requested behavior intentionally includes completion-only mode, while existing non-terminal progress excludes it. + - Alternative considered: Treat compaction start as ordinary non-terminal progress. That would incorrectly suppress it for completion-only bindings. + +4. **Keep notification content minimal.** + - Decision: Messages mention only the session display label/context and lifecycle state; they must not include compaction summary contents, transcript excerpts, hidden prompts, internal ids, or tokens. + - Rationale: Compaction summaries can contain sensitive conversation details and should remain in session context, not messenger notifications. + +## Risks / Trade-offs + +- **No failure end hook for auto-compaction** → Mitigation: document and test the behavior around start and successful completion; continue reporting remote `/compact` action failures through the existing route-action outcome path. +- **Duplicate notifications from direct adapter and broker paths** → Mitigation: publish one route progress event per hook and let the existing owner/broker route delivery rules decide the active outbound path. +- **Completion-only semantics become less literal** → Mitigation: scope the exception narrowly to compaction lifecycle notifications and keep quiet as the only fully suppressed mode. +- **Messenger send failures during compaction** → Mitigation: use best-effort asynchronous delivery and preserve existing nonfatal progress-delivery behavior. diff --git a/openspec/changes/archive/2026-06-14-notify-session-compaction-progress/proposal.md b/openspec/changes/archive/2026-06-14-notify-session-compaction-progress/proposal.md new file mode 100644 index 0000000..bdfa3f2 --- /dev/null +++ b/openspec/changes/archive/2026-06-14-notify-session-compaction-progress/proposal.md @@ -0,0 +1,28 @@ +## Why + +Paired remote users currently see task progress and completion, but context compaction can happen during long sessions without a clear remote signal. Notifying remote users when compaction starts and ends reduces confusion during pauses, especially for auto-compaction and remote `/compact` requests. + +## What Changes + +- Send safe messenger notifications when a paired Pi session begins compaction and when compaction completes. +- Deliver compaction start/end notifications according to each binding's progress mode, enabled for normal, verbose, and completion-only modes, and suppressed for quiet mode. +- Apply the behavior consistently across Telegram, Discord, Slack, and broker-mediated delivery paths. +- Keep notification content safe: no transcripts, hidden prompts, pairing codes, raw chat/channel ids, secrets, or compaction summary body. +- Treat delivery as best-effort and nonfatal; compaction must not fail because a messenger notification cannot be delivered. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `messenger-relay-sessions`: Extend shared progress semantics to include compaction start and compaction end notifications across messenger bindings. + +## Impact + +- Affected runtime hooks: Pi extension `session_before_compact` and `session_compact` handlers in the relay runtime. +- Affected delivery paths: route notification state, broker propagation, Telegram/Discord/Slack progress delivery. +- Affected tests: progress-mode behavior, compaction hook handling, broker parity, and secret-safe notification formatting. +- No new runtime dependencies are expected. diff --git a/openspec/changes/archive/2026-06-14-notify-session-compaction-progress/specs/messenger-relay-sessions/spec.md b/openspec/changes/archive/2026-06-14-notify-session-compaction-progress/specs/messenger-relay-sessions/spec.md new file mode 100644 index 0000000..e049b95 --- /dev/null +++ b/openspec/changes/archive/2026-06-14-notify-session-compaction-progress/specs/messenger-relay-sessions/spec.md @@ -0,0 +1,39 @@ +## ADDED Requirements + +### Requirement: Compaction progress notifications follow binding progress mode +The system SHALL notify eligible paired messenger bindings when a Pi session compaction starts and when it successfully completes, and SHALL suppress those notifications only for bindings whose progress mode is quiet. + +#### Scenario: Compaction start is delivered in non-quiet modes +- **WHEN** a paired Pi session emits `session_before_compact` +- **AND** a Telegram, Discord, Slack, or future messenger binding for that session has progress mode normal, verbose, or completion-only +- **THEN** PiRelay sends or schedules a safe compaction-start progress notification for that binding + +#### Scenario: Compaction start is suppressed in quiet mode +- **WHEN** a paired Pi session emits `session_before_compact` +- **AND** a messenger binding for that session has progress mode quiet +- **THEN** PiRelay does not send a compaction-start progress notification to that binding + +#### Scenario: Compaction completion is delivered in non-quiet modes +- **WHEN** a paired Pi session emits `session_compact` after successfully appending a compaction entry +- **AND** a Telegram, Discord, Slack, or future messenger binding for that session has progress mode normal, verbose, or completion-only +- **THEN** PiRelay sends or schedules a safe compaction-completed progress notification for that binding + +#### Scenario: Compaction completion is suppressed in quiet mode +- **WHEN** a paired Pi session emits `session_compact` after successfully appending a compaction entry +- **AND** a messenger binding for that session has progress mode quiet +- **THEN** PiRelay does not send a compaction-completed progress notification to that binding + +#### Scenario: Compaction notifications are safe +- **WHEN** PiRelay formats a compaction start or completion notification +- **THEN** the notification omits bot tokens, pairing codes, hidden prompts, tool internals, raw chat ids, raw channel ids, workspace ids, full transcripts, and compaction summary contents +- **AND** it does not expose whether compaction was triggered manually, by threshold, or by overflow unless Pi has provided that information through a safe extension event + +#### Scenario: Compaction notification failures are nonfatal +- **WHEN** PiRelay cannot deliver a compaction start or completion notification to an eligible binding +- **THEN** compaction handling continues without failing, cancelling, or corrupting the Pi session +- **AND** PiRelay records or reports only secret-safe diagnostics + +#### Scenario: Revoked or unauthorized bindings receive no compaction notifications +- **WHEN** a compaction start or completion notification is about to be delivered +- **THEN** PiRelay verifies the destination remains an active authorized binding according to existing binding authority and adapter delivery rules +- **AND** it does not send the notification to revoked, paused, unauthorized, missing, or stale destinations diff --git a/openspec/changes/archive/2026-06-14-notify-session-compaction-progress/tasks.md b/openspec/changes/archive/2026-06-14-notify-session-compaction-progress/tasks.md new file mode 100644 index 0000000..a3f7dd4 --- /dev/null +++ b/openspec/changes/archive/2026-06-14-notify-session-compaction-progress/tasks.md @@ -0,0 +1,24 @@ +## 1. Progress Semantics + +- [x] 1.1 Add a compaction-progress eligibility helper that returns true for normal, verbose, and completion-only progress modes and false for quiet. +- [x] 1.2 Add safe formatting or progress activity labels for compaction start and compaction completion without including summary text or internal identifiers. + +## 2. Runtime Integration + +- [x] 2.1 Handle Pi `session_before_compact` in the relay extension runtime by recording/publishing a compaction-start progress event for the current route. +- [x] 2.2 Handle Pi `session_compact` in the relay extension runtime by recording/publishing a compaction-completed progress event for the current route. +- [x] 2.3 Ensure broker-mediated route updates and direct Telegram, Discord, and Slack delivery paths all apply the compaction-specific progress-mode policy. +- [x] 2.4 Ensure revoked, paused, stale, or unauthorized bindings do not receive compaction notifications through existing binding authority checks. + +## 3. Tests + +- [x] 3.1 Add unit tests for the compaction progress-mode helper, including quiet, normal, verbose, and completion-only. +- [x] 3.2 Add runtime tests that `session_before_compact` records/publishes a compaction-start notification and `session_compact` records/publishes a compaction-completed notification. +- [x] 3.3 Add adapter or broker parity tests proving compaction notifications are delivered in normal, verbose, and completion-only modes but suppressed in quiet mode. +- [x] 3.4 Add safety tests proving compaction notifications omit compaction summaries, raw destination identifiers, and other sensitive content. + +## 4. Validation + +- [x] 4.1 Run `npm run typecheck`. +- [x] 4.2 Run `npm test`. +- [x] 4.3 Run `openspec validate notify-session-compaction-progress --strict`. diff --git a/openspec/changes/improve-tool-call-progress-reporting/.openspec.yaml b/openspec/changes/improve-tool-call-progress-reporting/.openspec.yaml new file mode 100644 index 0000000..64b47c4 --- /dev/null +++ b/openspec/changes/improve-tool-call-progress-reporting/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-13 diff --git a/openspec/changes/improve-tool-call-progress-reporting/design.md b/openspec/changes/improve-tool-call-progress-reporting/design.md new file mode 100644 index 0000000..8a67cdd --- /dev/null +++ b/openspec/changes/improve-tool-call-progress-reporting/design.md @@ -0,0 +1,98 @@ +## Context + +Pi emits rich tool lifecycle events: `tool_call`, `tool_execution_start`, `tool_execution_update`, `tool_execution_end`, and tool-result messages. PiRelay currently uses only coarse progress messages such as `Tool completed — bash`, which are safe but not useful. The live-progress coalescing change gives Telegram a mutable live message and Slack/Discord bounded snapshots, but the content being coalesced still lacks semantic value. + +Tool reporting must remain safe because tool arguments can include shell commands, paths, search queries, file contents, user text, or secrets. Normal-mode progress should explain what Pi is doing without relaying tool output, raw transcripts, hidden prompts, pairing codes, chat IDs, or unbounded arguments. + +## Goals / Non-Goals + +**Goals:** + +- Make normal-mode tool progress human-readable and compact. +- Aggregate repeated tool calls into one live status card per binding instead of many low-signal milestones. +- Summarize built-in tool intent using allowlisted fields only. +- Preserve existing progress-mode semantics: normal is low-noise, verbose is more detailed, completion-only excludes ordinary tool progress, and quiet suppresses all progress. +- Keep Telegram direct and broker edit-in-place behavior, with Slack/Discord using the same shared formatted snapshot. +- Add safety tests for redaction, argument bounding, and no tool-output leakage. + +**Non-Goals:** + +- Do not expose full tool output or raw command output in normal-mode progress. +- Do not build a full remote terminal transcript or per-tool log viewer. +- Do not change final assistant output delivery or `/full` behavior. +- Do not add new runtime dependencies. +- Do not rely on private Pi internals beyond documented extension events currently available. + +## Decisions + +### 1. Track tool progress by `toolCallId` + +Maintain a small in-memory tool progress accumulator for the current turn, keyed by `toolCallId`. `tool_call` and `tool_execution_start` can create or update an active record, and `tool_execution_end` can mark it completed or failed. + +Rationale: event ids are too granular and produce repeated messages; `toolCallId` is the stable lifecycle identity. + +Alternative considered: continue emitting independent progress activities and rely only on text coalescing. This still produces poor summaries and cannot represent active vs completed tool state. + +### 2. Summarize only allowlisted tool arguments + +Create pure helpers that accept `toolName`, `toolCallId`, and tool input/args and return a safe bounded summary. Examples: + +- `bash`: first command line, redacted and truncated. +- `read`: file path and optional range if present. +- `edit`/`write`: target path only, not replacement text or file content. +- `grep`/`rg`: pattern and search path, redacted and truncated. +- `find`/`ls`: target path or search root. +- unknown/custom tools: tool name only, or a conservative sanitized label in verbose mode. + +Rationale: allowlists prevent accidental leakage from arbitrary tool args. + +Alternative considered: summarize all JSON args generically. Rejected because it risks leaking secrets, file contents, prompt fragments, or raw destination IDs. + +### 3. Format one compact tool status card + +Represent tool progress as a bounded set of rows plus counts, for example: + +```text +● Working + ▶ bash: npm test + 📖 read: extensions/relay/runtime/extension-runtime.ts + ✏️ edit: tests/integration.test.ts + 🔧 tools: bash×2 read×4 edit×1 +``` + +The card should fit the configured progress limit and prefer the most recent active/failed rows plus aggregate counts. + +Rationale: humans need to know current intent and rough progress, not every raw event. + +Alternative considered: one message per tool completion. The screenshot demonstrates this is not useful. + +### 4. Keep normal and verbose distinct + +Normal mode shows safe operation summaries and aggregate counts. Verbose mode may include additional safe lifecycle markers such as active/completed/failed status per recent tool, but it still uses the same redaction, bounding, and coalescing helpers. + +Rationale: verbose should help debugging without reintroducing unbounded noise or sensitive output leakage. + +### 5. Clear accumulator on turn boundaries + +The accumulator is current-turn state. It resets on `agent_start`, terminal `agent_end`, abort/failure handling, route unregister, and runtime restart. It is not persisted. + +Rationale: persisted state must avoid storing tool internals or transcripts, and stale tool rows would confuse later turns. + +## Risks / Trade-offs + +- **Risk: Useful command/path context can contain secrets** → Apply configured redaction before storing summaries, bound lengths, and avoid unallowlisted fields. +- **Risk: Tool event coverage differs across Pi versions** → Use `tool_call` and `tool_execution_end` as primary sources, and degrade to generic tool-name summaries when start/update details are unavailable. +- **Risk: The live card can still churn too often** → Reuse existing per-binding rate limiting and semantic coalescing; update Telegram in place where possible. +- **Risk: Multiple same-name tools become ambiguous** → Show aggregate counts and recent summaries rather than pretending every call is unique. +- **Risk: Broker parity diverges** → Put summarization and formatting in shared helpers used by direct runtimes and broker-facing route state. + +## Migration Plan + +- No persisted schema migration is required. +- Existing progress preferences continue to work. +- Rollback is safe: removing the accumulator returns progress to existing generic tool milestones. + +## Open Questions + +- Should normal mode show active tool rows (`▶ bash: npm test`) or only completed summaries? The recommended default is to show active rows because it answers “what is Pi doing now?”. +- Should verbose mode include sanitized elapsed durations per tool when available? This can be deferred unless Pi event timing proves reliable enough. diff --git a/openspec/changes/improve-tool-call-progress-reporting/proposal.md b/openspec/changes/improve-tool-call-progress-reporting/proposal.md new file mode 100644 index 0000000..36d13a3 --- /dev/null +++ b/openspec/changes/improve-tool-call-progress-reporting/proposal.md @@ -0,0 +1,30 @@ +## Why + +PiRelay's current normal-mode tool progress is too noisy and too low-signal: users see repeated messages such as `Tool completed — bash` without knowing what is actually happening. The recent live-progress coalescing work gives us the delivery foundation; now tool reporting needs human-readable, safe summaries and aggregation. + +## What Changes + +- Replace generic normal-mode tool milestones with compact tool-call progress summaries that describe the operation safely, such as `bash: npm test`, `read: extensions/relay/runtime/extension-runtime.ts`, or `edit: tests/integration.test.ts`. +- Track tool-call lifecycle by `toolCallId` so starts, completions, failures, and repeated calls collapse into a bounded live status card instead of separate repeated messages. +- Add allowlisted summarizers for built-in tools (`bash`, `read`, `edit`, `write`, `grep`/`rg`, `find`, `ls`) that expose useful intent without relaying tool output, hidden prompts, raw transcripts, secrets, or unbounded arguments. +- Keep normal mode focused on stable, human-readable tool progress while verbose mode can include more technical tool details under the same redaction and bounding rules. +- Preserve completion-only and quiet semantics: completion-only does not receive ordinary tool progress; quiet receives no progress. +- Maintain Telegram edit-in-place behavior and Slack/Discord coalesced snapshot fallback for the improved tool summaries. + +## Capabilities + +### New Capabilities + + + +### Modified Capabilities + +- `messenger-relay-sessions`: Progress reporting SHALL summarize and aggregate tool-call activity in a safe, bounded, human-readable form instead of emitting repeated generic tool-completed milestones. + +## Impact + +- Affected runtime code: `extensions/relay/runtime/extension-runtime.ts` tool event handling and progress-state helpers. +- Affected notification code: `extensions/relay/notifications/progress.ts` formatting/coalescing and new tool-summary helpers. +- Affected adapters: Telegram direct/broker progress delivery, Slack runtime, and Discord runtime should continue using the shared coalesced output. +- Affected tests: progress helper tests, runtime integration tests for tool-call lifecycle, Telegram edit/fallback tests, broker tests, and Slack/Discord parity tests. +- No new runtime dependencies are expected. diff --git a/openspec/changes/improve-tool-call-progress-reporting/specs/messenger-relay-sessions/spec.md b/openspec/changes/improve-tool-call-progress-reporting/specs/messenger-relay-sessions/spec.md new file mode 100644 index 0000000..7ac3aa3 --- /dev/null +++ b/openspec/changes/improve-tool-call-progress-reporting/specs/messenger-relay-sessions/spec.md @@ -0,0 +1,87 @@ +## ADDED Requirements + +### Requirement: Tool progress is summarized safely +The system SHALL summarize tool-call progress using safe, bounded, human-readable operation labels instead of exposing raw tool arguments, tool output, transcripts, hidden prompts, pairing codes, raw messenger destination identifiers, or secrets. + +#### Scenario: Bash progress shows command intent safely +- **WHEN** a running paired session emits a bash tool call and the receiving binding is eligible for normal progress +- **THEN** the progress update includes a bounded redacted summary of the command intent, such as the first command line +- **AND** the update does not include command output, hidden prompts, full transcripts, bot tokens, or unredacted secret-pattern matches + +#### Scenario: File tools show target paths without content +- **WHEN** a running paired session emits read, edit, or write tool calls and the receiving binding is eligible for progress +- **THEN** the progress update identifies the relevant target file path or safe basename when available +- **AND** the update does not include file contents, replacement text, patches, or unbounded arguments + +#### Scenario: Search/list tools show query or path intent safely +- **WHEN** a running paired session emits grep, rg, find, or ls style tool calls and the receiving binding is eligible for progress +- **THEN** the progress update includes a bounded redacted search pattern, query, or target path when available +- **AND** unknown or unsafe fields are omitted rather than serialized generically + +#### Scenario: Unknown tools remain conservative +- **WHEN** a running paired session emits an unknown or custom tool call +- **THEN** normal-mode progress identifies only the sanitized tool name or a conservative generic label +- **AND** it does not serialize arbitrary tool arguments + +### Requirement: Tool progress is aggregated by turn +The system SHALL aggregate current-turn tool progress by stable tool-call identity and tool kind so repeated tool events produce a compact live status rather than many generic completion messages. + +#### Scenario: Repeated tool calls collapse into counts +- **WHEN** a running paired session completes multiple tool calls of the same kind within the progress window +- **THEN** the progress update includes a bounded aggregate count such as `bash×2` or `read×4` +- **AND** it does not send a separate messenger message for every individual completion when the adapter can coalesce or edit live progress + +#### Scenario: Active and recent tools are shown compactly +- **WHEN** one or more tool calls are active or recently completed during a running paired turn +- **THEN** the progress update prioritizes the current active tool summaries and the most recent completed or failed summaries within the configured progress length limit +- **AND** older repeated activity is represented by aggregate counts rather than unbounded rows + +#### Scenario: Failed tools are visible without leaking output +- **WHEN** a tool call fails during a running paired turn and the receiving binding is eligible for progress +- **THEN** the progress update marks that tool as failed using the safe tool label +- **AND** it does not include raw stack traces, command output, or unbounded error payloads in normal mode + +#### Scenario: Tool progress resets on turn boundaries +- **WHEN** a Pi turn starts, completes, fails, aborts, unregisters, or the runtime restarts +- **THEN** current-turn tool progress state is reset or discarded +- **AND** later turns do not display stale tool rows or counts from previous turns + +### Requirement: Progress modes govern tool reporting +The system SHALL apply existing progress-mode semantics to improved tool reporting for every messenger binding independently. + +#### Scenario: Normal mode receives low-noise tool summaries +- **WHEN** a binding is configured for normal progress and a running paired session emits tool activity +- **THEN** the binding receives coalesced safe tool summaries and aggregate counts +- **AND** it does not receive generic duplicate `Processed tool result` or repeated `Tool completed — ` messages for the same activity + +#### Scenario: Verbose mode remains bounded and safe +- **WHEN** a binding is configured for verbose progress and a running paired session emits tool activity +- **THEN** the binding may receive additional safe technical tool lifecycle detail +- **AND** the output remains redacted, bounded, coalesced, and free of raw tool output or arbitrary argument serialization + +#### Scenario: Completion-only excludes ordinary tool progress +- **WHEN** a binding is configured for completion-only progress and a running paired session emits ordinary tool activity +- **THEN** the binding does not receive the ordinary tool progress update +- **AND** terminal completion output and allowed compaction notifications continue to follow their existing policies + +#### Scenario: Quiet suppresses all tool progress +- **WHEN** a binding is configured for quiet progress and a running paired session emits tool activity +- **THEN** the binding receives no tool progress update + +### Requirement: Tool progress delivery preserves adapter parity +The system SHALL deliver improved tool progress through the same shared progress pipeline for Telegram direct runtime, Telegram broker runtime, Slack runtime, Discord runtime, and future messenger adapters. + +#### Scenario: Telegram updates live tool progress in place when possible +- **WHEN** Telegram can edit the live progress message and the tool-progress card changes +- **THEN** PiRelay updates the existing live progress message instead of posting a new message for every tool call +- **AND** if editing fails, PiRelay falls back to bounded coalesced snapshots without exposing unsafe data + +#### Scenario: Slack and Discord use coalesced snapshots +- **WHEN** Slack or Discord receives improved tool progress +- **THEN** PiRelay sends bounded coalesced snapshots using the same safe summaries and progress-mode filtering as Telegram +- **AND** authorization, paused/revoked binding checks, and destination scoping remain enforced before delivery + +#### Scenario: Broker and in-process runtimes match +- **WHEN** equivalent tool progress is emitted through the in-process runtime and broker-owned Telegram runtime +- **THEN** both paths produce equivalent safe tool summary content and progress-mode behavior +- **AND** neither path persists raw tool args, tool outputs, or secret-bearing semantic keys diff --git a/openspec/changes/improve-tool-call-progress-reporting/tasks.md b/openspec/changes/improve-tool-call-progress-reporting/tasks.md new file mode 100644 index 0000000..a8e623c --- /dev/null +++ b/openspec/changes/improve-tool-call-progress-reporting/tasks.md @@ -0,0 +1,45 @@ +## 1. Tool Summary Model + +- [ ] 1.1 Add shared tool-progress types for current-turn tool records, lifecycle state, safe labels, aggregate counts, and formatted rows. +- [ ] 1.2 Implement pure allowlisted summarizers for built-in tools: bash, read, edit, write, grep/rg, find, and ls. +- [ ] 1.3 Ensure unknown/custom tools fall back to conservative sanitized tool-name labels without serializing arbitrary args. +- [ ] 1.4 Apply existing redaction, normalization, and progress length bounds before storing tool labels or semantic keys. +- [ ] 1.5 Add unit tests proving tool labels omit outputs, file contents, replacement text, raw transcripts, pairing codes, destination ids, and configured secret patterns. + +## 2. Turn-Scoped Accumulator and Formatting + +- [ ] 2.1 Implement a current-turn tool progress accumulator keyed by `toolCallId` with active, completed, failed, and count state. +- [ ] 2.2 Reset accumulator state on agent start, terminal end/failure/abort, route unregister, runtime stop/restart, and session changes. +- [ ] 2.3 Implement compact tool-progress card formatting that prioritizes active tools, recent failed/completed tools, and aggregate counts within `maxProgressMessageChars`. +- [ ] 2.4 Preserve existing live-progress coalescing and rate limiting while replacing generic repeated tool milestones with aggregated tool cards. +- [ ] 2.5 Add unit tests for repeated calls, active/recent prioritization, failed tool rows, count formatting, and truncation behavior. + +## 3. Runtime Event Integration + +- [ ] 3.1 Wire `tool_call` and/or `tool_execution_start` into the accumulator with safe summarized intent. +- [ ] 3.2 Wire `tool_execution_end` into completion/failure state without including raw result payloads. +- [ ] 3.3 Keep `message_end` tool-result bookkeeping volatile/verbose-only or suppress it when a matching tool lifecycle record exists. +- [ ] 3.4 Preserve approval-gate behavior and authorization boundaries in `tool_call` handlers before adding progress side effects. +- [ ] 3.5 Add integration tests for bash, read, edit/write, search/list, failed tools, duplicate tool events, and missing lifecycle fields. + +## 4. Adapter and Broker Parity + +- [ ] 4.1 Verify Telegram direct runtime edits the improved live tool card in place and falls back safely when edit fails. +- [ ] 4.2 Verify Telegram broker runtime emits equivalent improved tool progress content and does not persist unsafe tool args or outputs. +- [ ] 4.3 Verify Slack and Discord receive the same bounded coalesced tool summaries through snapshot fallback. +- [ ] 4.4 Verify paused, revoked, moved, state-unavailable, and destination-mismatch binding checks still suppress protected progress delivery. +- [ ] 4.5 Add parity tests for Telegram direct, broker, Slack, and Discord progress-mode behavior. + +## 5. Progress Mode UX and Documentation + +- [ ] 5.1 Verify normal mode receives safe low-noise tool summaries and no repeated generic `Tool completed — ` stream. +- [ ] 5.2 Verify verbose mode can include additional safe tool lifecycle detail while remaining redacted, bounded, and coalesced. +- [ ] 5.3 Verify completion-only suppresses ordinary tool progress while preserving terminal output and allowed compaction notices. +- [ ] 5.4 Verify quiet suppresses all tool progress. +- [ ] 5.5 Update README/help text with examples of improved tool-progress reporting and progress-mode expectations. + +## 6. Validation + +- [ ] 6.1 Run `npm run typecheck`. +- [ ] 6.2 Run `npm test`. +- [ ] 6.3 Run `openspec validate improve-tool-call-progress-reporting --strict`. diff --git a/openspec/changes/remote-new-session-handoff/.openspec.yaml b/openspec/changes/remote-new-session-handoff/.openspec.yaml new file mode 100644 index 0000000..c86b1d7 --- /dev/null +++ b/openspec/changes/remote-new-session-handoff/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-14 diff --git a/openspec/changes/remote-new-session-handoff/design.md b/openspec/changes/remote-new-session-handoff/design.md new file mode 100644 index 0000000..b28c46b --- /dev/null +++ b/openspec/changes/remote-new-session-handoff/design.md @@ -0,0 +1,96 @@ +## Context + +Pi's local `/new` command replaces the active session with a new session id and session file. PiRelay currently keys bindings and broker routes by `sessionKey = sessionId + sessionFile`, so the old route unregisters and paired messengers see an offline session even though the user often intends to continue working in the same workspace. Pi's extension API exposes `ctx.newSession()` only on command-capable contexts, while many runtime event callbacks expose only base `ExtensionContext`, so remote `/new` must be implemented through a carefully captured route action rather than by assuming every context can start sessions. + +The desired UX has two related parts: authorized messenger users can request a new session remotely, and local or remote session replacement can safely carry relay control to the replacement route when it is clearly the same local workspace continuation. + +## Goals / Non-Goals + +**Goals:** + +- Provide canonical remote `/new` semantics for authorized messenger users. +- Use typed route-action outcomes so adapters report unavailable, busy, unsupported, cancelled, and success states consistently. +- Handoff eligible bindings and active selections from old session key to new session key after local or remote `/new`. +- Avoid misleading offline notifications when a shutdown is immediately followed by a safe handoff. +- Preserve authorization, stale-action, binding-authority, and requester-scoping invariants. +- Support Telegram direct and broker paths, plus Slack/Discord command parity or explicit capability fallback. + +**Non-Goals:** + +- Do not create a new Pi process from a messenger when the selected Pi route is offline. +- Do not migrate bindings across unrelated workspaces, machines, users, or ambiguous sessions. +- Do not expose raw session keys, file paths, chat ids, bot tokens, prompts, or transcripts in messenger output. +- Do not make `/new` bypass busy/approval/custom-answer safeguards. +- Do not implement generic session switching, forking, or tree navigation in this change. + +## Decisions + +### 1. Implement remote `/new` as a route action + +Add a narrow `newSession` route action to `SessionRouteActions` and shared route-action helpers. Adapters invoke this action after authorization and route resolution rather than touching Pi context directly. + +Rationale: route actions centralize stale/offline/busy checks and keep adapter behavior consistent. + +Alternative considered: directly call `route.actions.context.newSession()` from adapters. Rejected because route context is typed as base `ExtensionContext`, can become stale, and may not expose command-only session controls. + +### 2. Capture command-capable context only where Pi provides it + +The extension runtime should store a short-lived command-capable context when local command handlers run or otherwise expose a safe command execution wrapper if Pi provides one. `newSession` returns `unsupported` when no current command-capable context is available for the selected live route. + +Rationale: Pi distinguishes command contexts from event contexts for safety. PiRelay should respect that boundary and fail explicitly when unsupported. + +Alternative considered: type assertion from base context to command context. Rejected because it would be brittle and can throw at runtime. + +### 3. Use a pending handoff window for local `/new` + +On `session_shutdown`, if the route has active bindings, create an in-memory pending handoff record with old route identity, safe workspace identity, active binding summaries, active selections, and a short TTL. Delay offline lifecycle notification during that TTL. On subsequent `session_start`, if strict matching succeeds, migrate bindings and selections to the new route and send a moved notification; otherwise expire the record and send the normal offline notification. + +Rationale: local `/new` emits an old shutdown before the replacement route registers. A short handoff window prevents false offline notifications without hiding real shutdowns indefinitely. + +Alternative considered: always migrate by label. Rejected because labels are user-controlled and not unique enough for authorization-sensitive binding movement. + +### 4. Require strict migration criteria + +A handoff may occur only when all applicable criteria match: + +- same local process/machine/runtime instance, +- same workspace root/cwd, +- old binding is active and not revoked, +- new route has no explicit conflicting binding, +- replacement starts within the TTL, +- old shutdown was not an explicit disconnect/revoke, +- no multiple pending candidates match the new route. + +Rationale: migration mutates authorization state and must fail closed. + +### 5. Mark old session state as moved/superseded + +When migration succeeds, create/update bindings for the new session key and mark old bindings as moved/superseded or revoked with safe migration metadata. Active selections for the same messenger conversation/user move to the new session key. Old route buttons/actions become stale because their turn/session identifiers no longer match. + +Rationale: the UI should not show duplicate old offline entries as active targets, and old actions must not affect the replacement route accidentally. + +### 6. Remote `/new` owns the handoff transaction + +Remote `/new` should prepare a handoff record, call `ctx.newSession()` with a `withSession` callback when possible, then sync/register the replacement route and migrate bindings in the same controlled flow. If Pi cancels the new-session operation, PiRelay reports cancellation and leaves old bindings unchanged. + +Rationale: remote users need deterministic feedback and should not lose control if Pi refuses or cancels session replacement. + +## Risks / Trade-offs + +- **Risk: Binding hijack across sessions** → Require same workspace/runtime/machine and unambiguous pending handoff; fail closed to manual reconnect when unsure. +- **Risk: Command context unavailable** → Return explicit unsupported/capability guidance rather than throwing or pretending success. +- **Risk: Busy session replacement loses work** → Refuse while busy by default or require an explicit confirmation path; preserve existing abort/compact controls. +- **Risk: Offline notifications are delayed** → Keep TTL short and flush offline notification on expiry, runtime stop, or process shutdown. +- **Risk: Broker and direct runtime diverge** → Keep migration/state helpers shared under `extensions/relay/` and cover direct and broker parity tests. +- **Risk: Old buttons/actions remain visible in chats** → Existing turn/session action validation must reject stale actions and tests must cover post-handoff old callbacks. + +## Migration Plan + +- Persisted state changes must be backward-compatible: old states without moved/superseded fields remain valid. +- Existing bindings continue to work without migration until `/new` or remote new-session action is used. +- Rollback is safe: migrated bindings are ordinary active bindings on the new session key; old moved metadata can be ignored by older code except for duplicate display behavior. + +## Open Questions + +- Should remote `/new` require `/new confirm` when the route is busy, or should it always refuse while busy? The safer initial behavior is refuse while busy and suggest `/abort` first. +- Should migration move all active bindings for the route or only the requester binding? The recommended default is all active bindings for local `/new`, but only the requester binding for remote `/new` unless a later option explicitly requests all. diff --git a/openspec/changes/remote-new-session-handoff/proposal.md b/openspec/changes/remote-new-session-handoff/proposal.md new file mode 100644 index 0000000..e0d213e --- /dev/null +++ b/openspec/changes/remote-new-session-handoff/proposal.md @@ -0,0 +1,33 @@ +## Why + +When a local Pi user runs `/new`, PiRelay currently marks the old paired session offline because the session id/file changes, even though the user usually expects relay control to continue for the same workspace. Messenger users also cannot request a new Pi session remotely, which makes long-running remote workflows harder to reset safely. + +## What Changes + +- Add a canonical remote `/new` command, and equivalent adapter forms, for authorized messenger users to request a new Pi session for the selected live route. +- Add a route-action outcome for new-session requests so adapters report idle, busy, offline, ambiguous, unsupported, cancelled, and success states consistently. +- Handoff eligible messenger bindings and active selections from an old route to the replacement route when a new session is started locally or remotely and strict safety conditions are met. +- Delay or suppress misleading offline lifecycle notifications during a short local `/new` handoff window, replacing them with a clear moved-to-new-session notification when migration succeeds. +- Preserve authorization and stale-action safety: old route actions/buttons become stale, revoked/paused/moved bindings are not used, and no prompt is injected into an offline or wrong session. +- Support Telegram direct and broker paths first, with Slack/Discord command parity and explicit capability fallbacks where session-control context is unavailable. + +## Capabilities + +### New Capabilities + + + +### Modified Capabilities + +- `messenger-relay-sessions`: Remote session renewal and binding handoff semantics for local or remote `/new`. +- `messenger-command-surfaces`: Canonical command metadata and help/menu surfaces include the new-session command where supported. +- `relay-route-action-safety`: Route-action outcomes include safe new-session execution and unavailable/busy/cancelled reporting. +- `relay-channel-adapters`: Live adapters route new-session commands through shared route-action safety and expose explicit capability fallbacks. + +## Impact + +- Affected runtime code: `extensions/relay/runtime/extension-runtime.ts` for command-capable context capture, new-session route action, handoff state, and lifecycle notification handling. +- Affected broker/adapter code: Telegram direct/broker command routing plus Discord/Slack parity surfaces and fallback messages. +- Affected state code: binding migration helpers, active selection updates, old-binding moved/revoked handling, and stale action checks. +- Affected tests: route-action safety tests, runtime integration tests, broker process tests, Telegram/Slack/Discord command parity tests, and lifecycle notification tests. +- No new runtime dependencies are expected. diff --git a/openspec/changes/remote-new-session-handoff/specs/messenger-command-surfaces/spec.md b/openspec/changes/remote-new-session-handoff/specs/messenger-command-surfaces/spec.md new file mode 100644 index 0000000..183b206 --- /dev/null +++ b/openspec/changes/remote-new-session-handoff/specs/messenger-command-surfaces/spec.md @@ -0,0 +1,24 @@ +## ADDED Requirements + +### Requirement: New-session command is part of canonical messenger surfaces +The system SHALL expose a canonical new-session command through messenger command metadata, help text, and platform-specific command surfaces when the adapter can route the command to shared session-control semantics. + +#### Scenario: Help advertises remote new command where supported +- **WHEN** an authorized user requests help through Telegram, Discord, Slack, or a future live messenger adapter that supports remote session-control commands +- **THEN** the help text includes the new-session command such as `/new` or the platform's equivalent invocation form +- **AND** it explains that the command starts a replacement Pi session for the selected live route + +#### Scenario: Command menu includes new session command +- **WHEN** PiRelay derives Telegram bot commands, Discord native command metadata, Slack command metadata, or another platform command menu for an adapter that supports remote new-session routing +- **THEN** the metadata includes the new-session command or equivalent namespaced subcommand +- **AND** unsupported adapters document the limitation rather than silently omitting an implemented command + +#### Scenario: Unsupported new command is explicit +- **WHEN** a messenger adapter receives `/new` or an equivalent new-session command but the selected route or adapter cannot execute session replacement +- **THEN** the response is a clear capability-specific limitation or route-state message +- **AND** it does not fall through to generic unknown-command help + +#### Scenario: Reliable Discord invocation remains namespaced +- **WHEN** Discord exposes the new-session command +- **THEN** PiRelay documents a reliable invocation such as `relay new` or `/relay new` +- **AND** any bare `/new` alias is treated as best-effort only diff --git a/openspec/changes/remote-new-session-handoff/specs/messenger-relay-sessions/spec.md b/openspec/changes/remote-new-session-handoff/specs/messenger-relay-sessions/spec.md new file mode 100644 index 0000000..89762e2 --- /dev/null +++ b/openspec/changes/remote-new-session-handoff/specs/messenger-relay-sessions/spec.md @@ -0,0 +1,67 @@ +## ADDED Requirements + +### Requirement: Session renewal handoff preserves authorized relay control +The system SHALL safely move eligible messenger bindings and active selections from an old live session route to a replacement session route when a local or remote new-session operation clearly represents the same workspace continuation. + +#### Scenario: Local new session migrates eligible binding +- **WHEN** a paired local Pi session shuts down because the user starts a new session and a replacement route starts in the same workspace within the handoff window +- **THEN** PiRelay migrates the eligible active messenger binding to the replacement session key +- **AND** updates the active selection for that messenger conversation/user to the replacement session +- **AND** does not require a fresh pairing code + +#### Scenario: Handoff requires strict matching +- **WHEN** a replacement route starts after an old paired route shuts down +- **THEN** PiRelay migrates bindings only when the old and replacement routes match the same local machine/runtime, workspace root, and unambiguous pending handoff record +- **AND** the old binding is active, not revoked, not explicitly disconnected, and not conflicting with an existing replacement binding + +#### Scenario: Ambiguous handoff fails closed +- **WHEN** more than one pending handoff could match a replacement route or the replacement route cannot be proven to be the same workspace continuation +- **THEN** PiRelay does not migrate any binding automatically +- **AND** it reports safe reconnect or `/sessions` guidance without exposing raw session keys, file paths, chat ids, or hidden data + +#### Scenario: Old route becomes stale after handoff +- **WHEN** a binding is migrated from an old session route to a replacement session route +- **THEN** protected output, callbacks, guided actions, full-output actions, latest-image actions, abort, compact, and prompt delivery for the old route are rejected as stale or moved +- **AND** the replacement route receives future authorized prompts and controls according to active selection rules + +### Requirement: Handoff-aware lifecycle notifications +The system SHALL avoid misleading offline lifecycle notifications during a short new-session handoff window and SHALL send clear moved or offline notifications after the handoff outcome is known. + +#### Scenario: Offline notification is delayed during handoff window +- **WHEN** a paired session shuts down with active bindings and a handoff candidate is possible +- **THEN** PiRelay delays the offline lifecycle notification for a bounded short interval +- **AND** still unregisters the old live route immediately so new prompts are not injected into the stale session + +#### Scenario: Successful handoff sends moved notification +- **WHEN** a replacement route starts and the handoff succeeds +- **THEN** eligible messenger conversations receive a safe notification that relay control moved to the new Pi session +- **AND** they do not receive the misleading old-session offline notification for that handoff + +#### Scenario: Handoff expiry sends offline notification +- **WHEN** no safe replacement route appears before the handoff window expires +- **THEN** PiRelay sends the normal offline notification for the old paired session +- **AND** leaves the persisted binding in its safe offline/restorable state unless it was explicitly disconnected + +### Requirement: Remote new-session command renews selected live route +The system SHALL allow authorized messenger users to request a new Pi session for an online selected route when the runtime can safely execute command-capable session controls. + +#### Scenario: Authorized remote new starts replacement session +- **WHEN** an authorized messenger user sends `/new` or an equivalent command for exactly one selected online idle session +- **THEN** PiRelay requests a new Pi session through the route-action boundary +- **AND** on success migrates the requester conversation's eligible binding and active selection to the replacement route +- **AND** replies that the new session started and relay control moved + +#### Scenario: Remote new refuses offline or ambiguous route +- **WHEN** an authorized messenger user sends `/new` but no online selected route exists or multiple routes are ambiguous +- **THEN** PiRelay does not request a new Pi session +- **AND** returns safe `/sessions` or `/use` guidance consistent with other remote controls + +#### Scenario: Remote new refuses busy route by default +- **WHEN** an authorized messenger user sends `/new` while the selected route is running a turn, awaiting approval, or capturing a custom answer +- **THEN** PiRelay refuses or requires an explicit confirmation policy before replacing the session +- **AND** it does not silently abandon the active turn, approval, or answer state + +#### Scenario: Remote new unsupported reports capability limitation +- **WHEN** an authorized messenger user sends `/new` for a route whose runtime has no current command-capable session-control context +- **THEN** PiRelay returns an explicit unsupported-capability message +- **AND** it does not mark the route offline, mutate bindings, or pretend a new session was created diff --git a/openspec/changes/remote-new-session-handoff/specs/relay-channel-adapters/spec.md b/openspec/changes/remote-new-session-handoff/specs/relay-channel-adapters/spec.md new file mode 100644 index 0000000..98ea346 --- /dev/null +++ b/openspec/changes/remote-new-session-handoff/specs/relay-channel-adapters/spec.md @@ -0,0 +1,29 @@ +## ADDED Requirements + +### Requirement: Adapters delegate new-session commands safely +Telegram, Discord, Slack, and future live messenger adapters SHALL route remote new-session commands through shared authorization, route resolution, and route-action safety helpers instead of implementing independent session replacement logic. + +#### Scenario: Adapter authorizes before new-session side effects +- **WHEN** a messenger user sends `/new`, `relay new`, `/relay new`, or an equivalent new-session command +- **THEN** the adapter authorizes the user and resolves the selected route before invoking any Pi session-control action +- **AND** unauthorized users cannot trigger session replacement, binding migration, media download, prompt injection, or protected output + +#### Scenario: Adapter renders typed new-session outcomes +- **WHEN** the shared route-action helper returns success, unavailable, busy, unsupported, cancelled, or failure for a new-session command +- **THEN** the adapter renders the corresponding safe platform-appropriate response +- **AND** does not mark the messenger runtime unhealthy for route-unavailable or unsupported-capability outcomes + +#### Scenario: Broker and direct Telegram remain equivalent +- **WHEN** an authorized Telegram user requests a new session through direct runtime or broker-owned runtime for equivalent route state +- **THEN** both paths produce equivalent route-action behavior, binding handoff behavior, and user-facing response class +- **AND** both preserve binding authority and active selection invariants + +#### Scenario: Slack and Discord parity or fallback is explicit +- **WHEN** Slack or Discord receives a new-session command +- **THEN** PiRelay either executes the shared new-session route action with the same semantics as Telegram or returns an explicit capability limitation +- **AND** tests document the chosen behavior instead of allowing a generic unknown-command response + +#### Scenario: Deferred handoff work preserves destination identity +- **WHEN** an adapter schedules delayed offline notification, moved notification, or other handoff-related work for a messenger destination +- **THEN** the work remains scoped to the destination and binding state for which it was scheduled +- **AND** current binding authority is rechecked before protected session feedback is sent diff --git a/openspec/changes/remote-new-session-handoff/specs/relay-route-action-safety/spec.md b/openspec/changes/remote-new-session-handoff/specs/relay-route-action-safety/spec.md new file mode 100644 index 0000000..e9b64c6 --- /dev/null +++ b/openspec/changes/remote-new-session-handoff/specs/relay-route-action-safety/spec.md @@ -0,0 +1,29 @@ +## ADDED Requirements + +### Requirement: New-session route action uses typed outcomes +The system SHALL execute remote new-session requests through shared route-action safety helpers that return typed outcomes for success, unavailable route, busy route, unsupported capability, cancellation, authorization failure, and execution failure. + +#### Scenario: New-session action succeeds +- **WHEN** a route-action caller requests a new session for an authorized selected idle route with command-capable session-control support +- **THEN** the helper executes the new-session action and returns a success outcome containing safe replacement-route information needed for handoff +- **AND** it does not expose raw session file paths, raw session keys, messenger destination ids, or secrets in user-facing text + +#### Scenario: New-session action reports unavailable route +- **WHEN** the selected route becomes offline, stale, revoked, moved, or unavailable before the new-session action executes +- **THEN** the helper returns an unavailable outcome +- **AND** no binding migration, prompt injection, or session-control operation is attempted + +#### Scenario: New-session action reports busy route +- **WHEN** the selected route is running a turn, waiting for approval, capturing a custom answer, or otherwise not safe to replace under the configured policy +- **THEN** the helper returns a busy or confirmation-required outcome +- **AND** it does not cancel or abandon the active operation unless a later explicit confirmation policy allows it + +#### Scenario: New-session action reports unsupported capability +- **WHEN** the route is online but no command-capable context or session-control action is available +- **THEN** the helper returns an unsupported-capability outcome +- **AND** adapters render that outcome as a safe limitation message instead of a generic failure + +#### Scenario: Cancelled new-session leaves state unchanged +- **WHEN** Pi cancels or refuses the requested new-session operation +- **THEN** the helper returns a cancelled outcome +- **AND** old bindings, active selections, pending approvals, and custom-answer state are not migrated or cleared except for safe audit/status updates diff --git a/openspec/changes/remote-new-session-handoff/tasks.md b/openspec/changes/remote-new-session-handoff/tasks.md new file mode 100644 index 0000000..1596b9a --- /dev/null +++ b/openspec/changes/remote-new-session-handoff/tasks.md @@ -0,0 +1,52 @@ +## 1. Route Action and Command Context + +- [ ] 1.1 Add typed new-session route-action outcome types for success, unavailable, busy, unsupported, cancelled, and failure. +- [ ] 1.2 Extend `SessionRouteActions` with a narrow `newSession` action that does not expose raw Pi context to adapters. +- [ ] 1.3 Capture or derive command-capable session-control context only where Pi provides `ExtensionCommandContext` support. +- [ ] 1.4 Return an explicit unsupported-capability outcome when no safe command-capable context is available. +- [ ] 1.5 Add route-action unit tests for success, unavailable, busy, unsupported, cancelled, and stale-route outcomes. + +## 2. Handoff State and Binding Migration + +- [ ] 2.1 Add a shared pending-handoff model with old route identity, safe workspace identity, binding summaries, active selections, reason, and TTL. +- [ ] 2.2 Implement strict matching helpers for replacement routes: same runtime/machine, same workspace root, active non-revoked binding, no explicit disconnect, no conflicting new binding, unambiguous candidate. +- [ ] 2.3 Implement binding migration helpers that create new-session bindings, move active selections, and mark old bindings moved/superseded without breaking older state files. +- [ ] 2.4 Ensure old route callbacks/actions/output retrieval become stale after successful handoff. +- [ ] 2.5 Add state-store tests for migration, ambiguity, revoked/paused bindings, moved old bindings, and active selection updates. + +## 3. Local Session Renewal Lifecycle + +- [ ] 3.1 On `session_shutdown`, unregister the old route immediately but create a bounded pending handoff when active bindings make renewal possible. +- [ ] 3.2 Delay offline lifecycle notification during the handoff window and flush it when the handoff expires or fails. +- [ ] 3.3 On `session_start`, migrate eligible bindings to the replacement route and send a safe moved-to-new-session notification. +- [ ] 3.4 Preserve normal offline notification behavior for true shutdowns, explicit disconnects, and unsafe/ambiguous handoffs. +- [ ] 3.5 Add runtime lifecycle tests for local `/new` success, expiry, ambiguous candidate, explicit disconnect, and stale old actions. + +## 4. Remote New-Session Command + +- [ ] 4.1 Add canonical `/new` command parsing and help text for Telegram, with shared command definition metadata where applicable. +- [ ] 4.2 Route remote `/new` through authorization, selected-route resolution, idle/busy checks, and the new-session route action. +- [ ] 4.3 Implement safe success, offline, ambiguous, busy, unsupported, cancelled, and failure response text. +- [ ] 4.4 Decide and implement initial busy policy, defaulting to refusal with guidance rather than replacing an active turn. +- [ ] 4.5 Add Telegram direct runtime tests for authorized, unauthorized, offline, ambiguous, busy, unsupported, cancelled, and successful remote `/new`. + +## 5. Broker and Adapter Parity + +- [ ] 5.1 Extend broker client protocol to request new-session route actions and report typed outcomes without leaking secrets. +- [ ] 5.2 Implement Telegram broker `/new` handling with equivalent direct-runtime behavior and active selection migration. +- [ ] 5.3 Update Slack and Discord command surfaces to execute shared new-session behavior or return explicit capability fallback. +- [ ] 5.4 Ensure paused, revoked, moved, state-unavailable, and destination-mismatch binding checks suppress new-session side effects. +- [ ] 5.5 Add broker, Slack, and Discord parity tests for command routing, outcomes, and fallback behavior. + +## 6. Documentation and UX + +- [ ] 6.1 Update README/help text to document `/new`, busy/offline limitations, and handoff behavior. +- [ ] 6.2 Add safe lifecycle notification copy for successful session handoff and failed/expired handoff guidance. +- [ ] 6.3 Document that remote `/new` starts a replacement session for an online selected route and does not start Pi when the route is offline. +- [ ] 6.4 Add smoke-test guidance for local `/new` handoff and remote `/new` through Telegram. + +## 7. Validation + +- [ ] 7.1 Run `npm run typecheck`. +- [ ] 7.2 Run `npm test`. +- [ ] 7.3 Run `openspec validate remote-new-session-handoff --strict`. diff --git a/openspec/specs/messenger-command-surfaces/spec.md b/openspec/specs/messenger-command-surfaces/spec.md index 9a2963e..f68183b 100644 --- a/openspec/specs/messenger-command-surfaces/spec.md +++ b/openspec/specs/messenger-command-surfaces/spec.md @@ -113,3 +113,31 @@ The system SHALL test and diagnose command-surface parity so registered commands - **WHEN** a platform native command surface cannot be registered or delivered because setup is incomplete - **THEN** PiRelay setup or doctor output explains the missing non-secret readiness category and the reliable text fallback without printing secrets +### Requirement: Remote skill commands are represented in command surfaces +The system SHALL expose remote skill discovery and invocation through stable PiRelay command-surface metadata without dynamically registering every local skill as a platform command. + +#### Scenario: Canonical metadata includes skill commands +- **WHEN** command-surface metadata is generated for Telegram, Discord, or Slack +- **THEN** it includes the canonical `skills` and `skill` commands when remote skill invocation is implemented or explicitly documents their disabled/unsupported state +- **AND** it does not generate a separate native command for each local skill by default + +#### Scenario: Telegram skill commands use safe names +- **WHEN** Telegram bot command metadata is generated and remote skill invocation is enabled or documented +- **THEN** the Telegram command menu includes safe entries such as `/skills` and `/skill` +- **AND** inbound Telegram skill commands route through the same authorization and policy checks as other protected relay commands + +#### Scenario: Discord skill commands use namespaced surface +- **WHEN** Discord native `/relay` command metadata is generated +- **THEN** the metadata includes `skills` and `skill` subcommands or an equivalent namespaced option group +- **AND** reliable text fallback forms such as `relay skills` and `relay skill [input]` remain documented + +#### Scenario: Slack skill commands use relay namespace +- **WHEN** Slack slash-command setup metadata is generated +- **THEN** the `/relay` usage hint includes skill discovery/invocation where platform length limits allow +- **AND** `relay skills` and `relay skill [input]` text forms remain supported as reliable fallbacks + +#### Scenario: Skill menu entries are bounded +- **WHEN** PiRelay renders a skill list as buttons, menus, or command help +- **THEN** labels and descriptions are truncated or paginated according to platform limits +- **AND** they include only safe skill name, description, and source category metadata permitted by skill exposure policy + diff --git a/openspec/specs/messenger-relay-sessions/spec.md b/openspec/specs/messenger-relay-sessions/spec.md index 4fe8cb5..0e908ec 100644 --- a/openspec/specs/messenger-relay-sessions/spec.md +++ b/openspec/specs/messenger-relay-sessions/spec.md @@ -152,7 +152,7 @@ The system SHALL expose common PiRelay controls through every messenger adapter The system SHALL expose the canonical PiRelay remote command set through every first-class messenger adapter with equivalent behavior, using text commands, slash commands, buttons, menus, or documented fallbacks according to platform capabilities. #### Scenario: Canonical commands are supported on every live adapter -- **WHEN** a live Telegram, Discord, Slack, or future messenger adapter receives `/help`, `/status`, `/sessions`, `/use`, `/to`, `/alias`, `/forget`, `/progress`, `/recent`, `/summary`, `/full`, `/images`, `/send-image`, `/steer`, `/followup`, `/abort`, `/compact`, `/pause`, `/resume`, or `/disconnect` from an authorized user +- **WHEN** a live Telegram, Discord, Slack, or future messenger adapter receives `/help`, `/status`, `/sessions`, `/use`, `/to`, `/alias`, `/forget`, `/progress`, `/recent`, `/summary`, `/full`, `/skills`, `/skill`, `/images`, `/send-image`, `/steer`, `/followup`, `/abort`, `/compact`, `/pause`, `/resume`, or `/disconnect` from an authorized user - **THEN** the adapter routes the command through shared relay semantics and returns the same success, usage, ambiguity, offline, unauthorized, unsupported-capability, or error response class as the other live adapters #### Scenario: Unsupported-command help does not catch implemented commands @@ -172,7 +172,7 @@ The system SHALL expose the canonical PiRelay remote command set through every f - **THEN** every registered command is implemented by the runtime and every implemented canonical command has a registered slash command, interaction equivalent, or documented text fallback #### Scenario: Discord text-prefix commands avoid slash interception -- **WHEN** an authorized Discord DM user sends `relay status`, `relay sessions`, `relay full`, `relay abort`, or another canonical command using the `relay ` text-prefix form +- **WHEN** an authorized Discord DM user sends `relay status`, `relay sessions`, `relay full`, `relay skills`, `relay skill`, `relay abort`, or another canonical command using the `relay ` text-prefix form - **THEN** PiRelay handles the command as an ordinary Discord message without depending on Discord application-command routing or top-level slash-command selection #### Scenario: Discord bare slash aliases are best-effort @@ -200,6 +200,13 @@ The system SHALL deliver safe progress, terminal notifications, latest output re - **WHEN** an authorized Telegram, Discord, Slack, or future messenger user sends a prompt that is accepted and the Pi turn completes with a final assistant message - **THEN** the originating messenger conversation receives the assistant completion summary or excerpt without requiring a separate local command or Telegram-only notification path +#### Scenario: Completion uses completed assistant text when final event omits it +- **WHEN** a paired Pi turn emits non-empty assistant text through a completed assistant `message_end` event +- **AND** the subsequent `agent_end` payload does not contain non-empty assistant text +- **THEN** PiRelay treats the turn as completed using the completed assistant text from the same active turn +- **AND** it does not send “finished without a final assistant response” for that turn +- **AND** it does not use stream-only drafts, user messages, tool results, hidden prompts, or transcript content as fallback final output + #### Scenario: Failure notification is sent - **WHEN** a paired Pi turn fails or finishes without a final assistant response - **THEN** every eligible bound messenger receives a safe failure notification that does not claim successful completion @@ -473,14 +480,19 @@ The system SHALL expose latest-image retrieval and explicit safe image delivery ### Requirement: Messenger final output follows shared mode-aware policy The system SHALL apply the same terminal assistant-output delivery policy across Telegram, Discord, Slack, and future live messengers, with only platform-specific rendering and capability fallbacks differing. -#### Scenario: Quiet binding receives concise completion +#### Scenario: Quiet binding receives terminal output without progress noise - **WHEN** a Pi turn completes for a messenger binding whose progress mode is quiet -- **THEN** PiRelay sends a concise completion message or summary -- **AND** it offers `/full`, an equivalent command, or a downloadable Markdown action where supported for retrieving the full output +- **THEN** PiRelay suppresses non-terminal progress updates for that binding +- **AND** it delivers the terminal assistant output using the same safe full-output chunk-or-document policy as other terminal-notification modes +- **AND** quiet mode does not by itself cause short final assistant output to be summarized, excerpted, or whitespace-collapsed #### Scenario: Normal binding receives full final output - **WHEN** a Pi turn completes for a messenger binding whose progress mode is normal - **THEN** PiRelay sends the latest assistant output as paragraph-aware message chunks when it fits safe platform limits +- **AND** it preserves user-visible paragraph breaks, bullets, code-ish lines, and validation-result blocks in the delivered assistant output +- **AND** for Telegram, it renders supported Markdown constructs with Telegram-safe chat formatting when the rendered message fits the configured safe chunk limit +- **AND** Telegram falls back to plain text when no Markdown formatting is needed or the rendered markup would exceed safe chunk limits +- **AND** Telegram offers a Markdown download action when the source output contains Markdown tables that are rendered with chat-safe fallbacks - **AND** it uses a document fallback when chunking would be excessive and the adapter supports documents #### Scenario: Verbose binding receives progress and full final output @@ -493,6 +505,20 @@ The system SHALL apply the same terminal assistant-output delivery policy across - **THEN** PiRelay suppresses non-terminal progress updates - **AND** sends the latest assistant output using the same chunk-or-document rules as normal mode +#### Scenario: Progress mode does not determine final-output length +- **WHEN** a completed assistant output would fit within the messenger's configured safe text chunk policy after redaction and formatting +- **THEN** PiRelay sends that output losslessly as chat text for every progress mode that emits terminal notifications +- **AND** it does not replace the output with a whitespace-collapsed deterministic summary only because the binding uses quiet mode or only to reduce a comparable-size message + +#### Scenario: Shortened output offers full retrieval +- **WHEN** PiRelay sends a terminal notification whose visible assistant text is summarized, excerpted, truncated, reformatted, or otherwise not equal to the latest full assistant output +- **THEN** the notification includes a supported `/full` hint, button, equivalent command, or document/download action for retrieving the full output + +#### Scenario: Broker and in-process terminal output are equivalent +- **WHEN** the same Telegram completion is delivered through the in-process runtime and the broker-owned runtime +- **THEN** both paths apply the same progress-mode, chunking, formatting-preservation, summary/excerpt, and full-output retrieval policy +- **AND** neither path silently downgrades a small readable output to a collapsed summary while the other sends it in full + #### Scenario: Full output is never silently truncated - **WHEN** a final assistant output exceeds platform text limits and document delivery is unavailable - **THEN** PiRelay reports an explicit capability limitation or retrieval fallback @@ -806,3 +832,98 @@ PiRelay SHALL associate approval requester context only with accepted remote mes - **THEN** PiRelay fails closed for that remote operation - **AND** it does not downgrade the operation to local approval bypass +### Requirement: Coalesced live progress delivery +The system SHALL deliver Pi session progress to messengers as coalesced live state rather than as a direct stream of raw Pi events. + +#### Scenario: Repeated live status is not duplicated +- **WHEN** Pi emits repeated assistant stream updates, repeated safe model status text, or otherwise equivalent progress activities for the same running turn +- **THEN** the messenger receives at most one current live-progress representation for that equivalent status within the configured delivery window +- **AND** the system does not post a new chat message for every repeated raw Pi event + +#### Scenario: Superseded live status is coalesced +- **WHEN** multiple volatile progress updates occur before the messenger delivery window elapses +- **THEN** the system delivers the latest coalesced safe status, plus any stable milestones that remain relevant +- **AND** superseded volatile snapshots are not delivered as separate messenger messages + +#### Scenario: Editable messengers update live status in place +- **WHEN** a messenger adapter supports updating a previously sent message and a paired binding is eligible to receive progress +- **THEN** the system uses a single live progress message for the active turn where practical +- **AND** later live progress updates edit that message instead of appending duplicate chat messages +- **AND** final completion, failure, abort, and full-output messages remain separate terminal notifications + +#### Scenario: Non-editable messengers receive coalesced snapshots +- **WHEN** a messenger adapter does not support updating a previously sent progress message or an update attempt fails +- **THEN** the system falls back to sending coalesced progress snapshots at the configured cadence +- **AND** it still avoids sending duplicate raw stream-event messages + +#### Scenario: Normal progress mode is low-noise +- **WHEN** a binding uses normal progress mode during a running Pi turn +- **THEN** the messenger receives stable milestones and coalesced live status only +- **AND** generic assistant streaming snapshots, repeated drafting text, and overlapping tool-result bookkeeping messages are not delivered as standalone progress messages + +#### Scenario: Verbose progress mode remains bounded +- **WHEN** a binding uses verbose progress mode during a running Pi turn +- **THEN** the messenger MAY receive more detailed progress than normal mode +- **BUT** repeated equivalent updates MUST still be deduplicated or coalesced +- **AND** delivery MUST respect the configured verbose progress interval and platform message limits + +#### Scenario: Completion-only and quiet progress modes remain respected +- **WHEN** a binding uses completion-only progress mode +- **THEN** the messenger receives terminal final output and explicitly allowed lifecycle notices such as compaction progress +- **AND** it does not receive ordinary live progress snapshots +- **WHEN** a binding uses quiet progress mode +- **THEN** the messenger does not receive live progress snapshots or compaction progress notifications + +#### Scenario: Tool lifecycle progress is human-level in normal mode +- **WHEN** Pi emits overlapping tool lifecycle events such as tool execution completion and tool-result message completion for the same tool call +- **THEN** normal progress mode does not deliver both as separate technical messages +- **AND** the system either collapses them into one safe human-readable milestone or omits successful short-lived tool chatter + +#### Scenario: Live progress remains secret-safe +- **WHEN** the system formats or updates live progress for any messenger +- **THEN** it excludes hidden thinking content, chain-of-thought, hidden prompts, raw transcripts, pairing codes, bot tokens, raw chat or channel identifiers, and full compaction summaries +- **AND** only sanitized safe progress text may be stored or delivered + +#### Scenario: Authorization still gates progress delivery +- **WHEN** a binding is paused, revoked, stale, unauthorized, or no longer authoritative for a route +- **THEN** the system does not send, edit, or finalize live progress messages for that binding +- **AND** any pending live progress state for that destination is cleared or ignored safely + +### Requirement: Compaction progress notifications follow binding progress mode +The system SHALL notify eligible paired messenger bindings when a Pi session compaction starts and when it successfully completes, and SHALL suppress those notifications only for bindings whose progress mode is quiet. + +#### Scenario: Compaction start is delivered in non-quiet modes +- **WHEN** a paired Pi session emits `session_before_compact` +- **AND** a Telegram, Discord, Slack, or future messenger binding for that session has progress mode normal, verbose, or completion-only +- **THEN** PiRelay sends or schedules a safe compaction-start progress notification for that binding + +#### Scenario: Compaction start is suppressed in quiet mode +- **WHEN** a paired Pi session emits `session_before_compact` +- **AND** a messenger binding for that session has progress mode quiet +- **THEN** PiRelay does not send a compaction-start progress notification to that binding + +#### Scenario: Compaction completion is delivered in non-quiet modes +- **WHEN** a paired Pi session emits `session_compact` after successfully appending a compaction entry +- **AND** a Telegram, Discord, Slack, or future messenger binding for that session has progress mode normal, verbose, or completion-only +- **THEN** PiRelay sends or schedules a safe compaction-completed progress notification for that binding + +#### Scenario: Compaction completion is suppressed in quiet mode +- **WHEN** a paired Pi session emits `session_compact` after successfully appending a compaction entry +- **AND** a messenger binding for that session has progress mode quiet +- **THEN** PiRelay does not send a compaction-completed progress notification to that binding + +#### Scenario: Compaction notifications are safe +- **WHEN** PiRelay formats a compaction start or completion notification +- **THEN** the notification omits bot tokens, pairing codes, hidden prompts, tool internals, raw chat ids, raw channel ids, workspace ids, full transcripts, and compaction summary contents +- **AND** it does not expose whether compaction was triggered manually, by threshold, or by overflow unless Pi has provided that information through a safe extension event + +#### Scenario: Compaction notification failures are nonfatal +- **WHEN** PiRelay cannot deliver a compaction start or completion notification to an eligible binding +- **THEN** compaction handling continues without failing, cancelling, or corrupting the Pi session +- **AND** PiRelay records or reports only secret-safe diagnostics + +#### Scenario: Revoked or unauthorized bindings receive no compaction notifications +- **WHEN** a compaction start or completion notification is about to be delivered +- **THEN** PiRelay verifies the destination remains an active authorized binding according to existing binding authority and adapter delivery rules +- **AND** it does not send the notification to revoked, paused, unauthorized, missing, or stale destinations + diff --git a/openspec/specs/relay-file-delivery/spec.md b/openspec/specs/relay-file-delivery/spec.md index 66c086f..b142d2b 100644 --- a/openspec/specs/relay-file-delivery/spec.md +++ b/openspec/specs/relay-file-delivery/spec.md @@ -78,14 +78,16 @@ PiRelay SHALL allow authorized paired messenger users to request bounded safe wo ### Requirement: Full-output document fallback is messenger-neutral PiRelay SHALL expose full assistant output as message chunks or a downloadable Markdown document according to shared final-output policy and adapter capabilities. -#### Scenario: Quiet mode offers full output instead of spamming chat +#### Scenario: Quiet mode suppresses progress but preserves final-output policy - **WHEN** a paired session completes while a messenger binding is in quiet progress mode -- **THEN** PiRelay sends only a short completion/summary notification -- **AND** provides a command or action to retrieve the full output as chat text or a Markdown file where the adapter supports document delivery +- **THEN** PiRelay suppresses non-terminal progress updates for that binding +- **AND** sends the final assistant output according to the same bounded chunk or Markdown document fallback policy used for terminal output in other progress modes +- **AND** does not summarize or collapse formatting for output that already fits the bounded chunk policy solely because the binding is quiet #### Scenario: Normal mode sends full final output - **WHEN** a paired session completes while a messenger binding is in normal progress mode - **THEN** PiRelay sends the final assistant output to the messenger conversation as paragraph-aware message chunks when it fits within bounded chunk limits +- **AND** it does not summarize or collapse formatting for outputs that already fit the bounded chunk policy - **AND** falls back to a Markdown document when chunking would exceed the configured safe threshold and the adapter supports document delivery #### Scenario: Verbose mode sends progress and full final output @@ -96,6 +98,11 @@ PiRelay SHALL expose full assistant output as message chunks or a downloadable M - **WHEN** a paired session completes while a messenger binding is in completion-only mode - **THEN** PiRelay suppresses non-terminal progress updates and sends the final assistant output according to the same full-output chunk/file rules as normal mode +#### Scenario: Retrieval actions appear when chat output is shortened +- **WHEN** PiRelay sends a terminal chat notification containing only a summary or excerpt of the latest assistant output +- **THEN** it exposes a full-output chat or Markdown document retrieval path supported by the target adapter +- **AND** the availability of that retrieval path does not depend solely on the full output exceeding a fixed long-output character threshold + #### Scenario: Adapter lacks document delivery - **WHEN** full output is too large for safe message chunks and the target adapter cannot send documents - **THEN** PiRelay returns an explicit capability limitation instead of silently truncating the output diff --git a/openspec/specs/relay-interaction-middleware/spec.md b/openspec/specs/relay-interaction-middleware/spec.md index 5f0fdcf..6f38cfd 100644 --- a/openspec/specs/relay-interaction-middleware/spec.md +++ b/openspec/specs/relay-interaction-middleware/spec.md @@ -170,3 +170,36 @@ Approval decisions SHALL use the same stale-state and authorization protections - **WHEN** a valid approval decision targets a session that is offline or whose owning client no longer has the pending operation - **THEN** PiRelay reports a safe stale/offline response and does not approve the operation +### Requirement: Skill interactions do not fall through as prompts +The relay interaction pipeline and adapter runtimes SHALL ensure remote skill discovery, selection, pending input, cancellation, and invocation are handled as relay control flows rather than ordinary prompt text before any skill invocation is delivered to Pi. + +#### Scenario: Skill list command is handled before prompt delivery +- **WHEN** an authorized inbound messenger event is parsed as `skills` or an equivalent skill-list command +- **THEN** PiRelay routes it to skill discovery for the selected route +- **AND** it does not inject the command text into Pi as an ordinary prompt + +#### Scenario: Skill invocation command is handled before prompt delivery +- **WHEN** an authorized inbound messenger event is parsed as `skill [input]` or an equivalent skill invocation command +- **THEN** PiRelay routes it to a skill invocation or pending-input flow after skill policy validation +- **AND** it does not inject the raw command text into Pi separately + +#### Scenario: Pending skill input captures next text +- **WHEN** a requester-scoped pending skill-input state exists and the same authorized requester sends non-command text before expiry +- **THEN** PiRelay classifies that text as skill input for the pending invocation +- **AND** it bypasses ordinary prompt routing for that message + +#### Scenario: Skill cancellation is internal +- **WHEN** a requester with pending skill input sends `/cancel`, `skill cancel`, or an equivalent platform action +- **THEN** PiRelay clears only that requester's pending skill state +- **AND** it does not inject the cancellation text into Pi + +#### Scenario: Skill action targets offline session +- **WHEN** PiRelay resolves a skill action that requires an online session but the selected session or remote owning machine is offline +- **THEN** the system reports the offline state to the originating messenger +- **AND** it does not silently drop the action or inject a fallback prompt + +#### Scenario: Skill action is stale +- **WHEN** a delayed button, retried event, duplicate ingress, expired pending state, or superseded route refers to a skill action that is no longer current +- **THEN** middleware rejects the action as stale +- **AND** it does not invoke a skill or deliver pending input to Pi + diff --git a/openspec/specs/relay-skill-invocation/spec.md b/openspec/specs/relay-skill-invocation/spec.md new file mode 100644 index 0000000..92fe97e --- /dev/null +++ b/openspec/specs/relay-skill-invocation/spec.md @@ -0,0 +1,126 @@ +# relay-skill-invocation Specification + +## Purpose +Defines safe remote skill discovery and invocation semantics for authorized messenger users, including allow-list configuration, command parsing, prompt delivery, audit trails, broker parity, and secret-safe output boundaries. +## Requirements +### Requirement: Remote skill discovery +PiRelay SHALL expose a safe, configurable list of local Pi skills to authorized messenger users. + +#### Scenario: Authorized user lists skills +- **WHEN** an authorized bound messenger user invokes `/skills`, `relay skills`, or an equivalent platform command +- **THEN** PiRelay reads skill command metadata from the selected live Pi session +- **AND** returns a bounded list of skill names and safe descriptions filtered by relay skill policy +- **AND** it does not include raw skill file contents, absolute filesystem paths, hidden prompts, tool internals, transcripts, tokens, or raw/unbounded callback payload contents + +#### Scenario: Skill discovery is unavailable +- **WHEN** the selected route is offline, stale, or cannot provide command metadata +- **THEN** PiRelay returns a safe unavailable or unsupported response +- **AND** it does not fall back to scanning arbitrary filesystem paths from the messenger request + +#### Scenario: No skills pass policy +- **WHEN** skill discovery succeeds but no skill matches the configured allowlist, source filters, or visibility policy +- **THEN** PiRelay reports that no remote-invokable skills are available +- **AND** it does not reveal filtered skill names unless configuration explicitly allows safe filtered-count diagnostics + +### Requirement: Skill exposure policy +PiRelay SHALL gate remote skill listing and invocation with explicit configuration and authorization policy. + +#### Scenario: Remote skills are disabled +- **WHEN** a messenger user invokes `/skills` or `/skill` while remote skill invocation is disabled +- **THEN** PiRelay returns a clear disabled-capability response +- **AND** it does not list or invoke any skills + +#### Scenario: Allowlist filters skills +- **WHEN** remote skill invocation is enabled with an allowlist +- **THEN** PiRelay lists and invokes only skills whose canonical names match the allowlist +- **AND** it rejects all other skill names with a safe not-available response + +#### Scenario: Source policy filters skills +- **WHEN** remote skill invocation is configured to include or exclude skill sources such as project, user, package, or temporary sources +- **THEN** PiRelay applies that source policy before rendering skill menus or accepting invocation +- **AND** source information shown to the messenger is limited to a safe category label rather than full paths + +#### Scenario: Risky skill requires confirmation +- **WHEN** policy marks a skill name as requiring confirmation +- **THEN** PiRelay refuses remote invocation with a confirmation-required response +- **AND** it does not invoke the skill until an explicit approval flow is implemented by a future change + +### Requirement: Remote skill invocation +PiRelay SHALL let authorized messenger users invoke allowed local skills with explicit input while preserving route-action safety. + +#### Scenario: Invoke skill with inline input +- **WHEN** an authorized bound user sends `/skill ` or an equivalent platform command for an allowed skill +- **THEN** PiRelay validates the skill name, selected route, paused state, online state, authorization, and policy before invoking the skill +- **AND** it delivers an invocation equivalent to local `/skill: ` to the selected Pi session +- **AND** it acknowledges the accepted invocation through the originating messenger + +#### Scenario: Skill name is unknown or filtered +- **WHEN** an authorized user requests a skill that is not available, filtered, disabled, or ambiguous +- **THEN** PiRelay returns a safe not-available or ambiguity response with allowed next actions +- **AND** it does not inject the raw `/skill` command as an ordinary Pi prompt + +#### Scenario: Invocation target is paused or offline +- **WHEN** an authorized user requests an allowed skill but the selected route is paused, offline, stale, or unavailable +- **THEN** PiRelay returns the same safe paused/offline/unavailable response class used by other remote prompt actions +- **AND** it does not claim the skill was invoked + +#### Scenario: Busy session uses explicit delivery mode +- **WHEN** an authorized user invokes a skill while the selected Pi session is busy +- **THEN** PiRelay applies existing busy delivery semantics for prompt-like actions +- **AND** the acknowledgement states whether the skill invocation was queued as follow-up, steering, or refused by policy + +### Requirement: Pending skill input +PiRelay SHALL support requester-scoped pending input for skills selected without inline input. + +#### Scenario: User selects skill without input +- **WHEN** an authorized user invokes `/skill ` or taps a skill button without providing input +- **THEN** PiRelay records a pending skill-input state scoped to the channel, instance, conversation or thread, user, route, and skill name +- **AND** it asks that same requester to send the input or cancel before a configured expiry + +#### Scenario: Next message completes pending input +- **WHEN** the same authorized requester sends non-command text before the pending skill-input state expires +- **THEN** PiRelay treats that text as the input for the pending skill invocation +- **AND** it clears the pending state before delivering the invocation to Pi +- **AND** it does not route that text as an ordinary prompt separately + +#### Scenario: Different requester sends text +- **WHEN** another user, conversation, thread, channel instance, or route sends text while a pending skill-input state exists +- **THEN** PiRelay does not use that text to complete the pending skill invocation +- **AND** normal routing rules apply for that other interaction + +#### Scenario: Pending input expires or is cancelled +- **WHEN** pending skill input expires or the requester sends `/cancel`, `/skill cancel`, or an equivalent platform action +- **THEN** PiRelay clears the pending state +- **AND** it does not invoke the skill + +### Requirement: Skill action buttons and stale-state safety +PiRelay SHALL render skill actions through adapter capabilities without weakening authorization or stale-state protections. + +#### Scenario: Skill list renders buttons +- **WHEN** the active messenger adapter supports buttons or menus and skills are available +- **THEN** PiRelay may render each visible skill as an action button or menu item +- **AND** each action contains only a bounded opaque reference or safe skill name, not raw input, file paths, skill instructions, or hidden metadata + +#### Scenario: Adapter lacks buttons +- **WHEN** the active messenger adapter cannot render skill buttons or menus +- **THEN** PiRelay returns text instructions using `/skill [input]` or the platform-equivalent command +- **AND** the same authorization, filtering, and pending-input semantics apply + +#### Scenario: Stale skill action is invoked +- **WHEN** a button or callback references a skill list, pending state, route, or turn that is expired, superseded, paused, disconnected, or no longer authorized +- **THEN** PiRelay rejects the action as stale or unavailable +- **AND** it does not invoke any skill or inject fallback prompt text + +### Requirement: Skill output and audit safety +PiRelay SHALL handle skill invocation acknowledgements, resulting assistant output, and audits using existing safe relay output rules. + +#### Scenario: Skill invocation produces assistant output +- **WHEN** a remote skill invocation causes Pi to complete a turn with assistant output +- **THEN** PiRelay delivers terminal output through the existing messenger completion, chunking, document, and full-output retrieval policy +- **AND** it does not expose additional skill internals beyond normal assistant output + +#### Scenario: Skill invocation is audited +- **WHEN** a remote skill invocation is accepted, refused, cancelled, expires, or fails +- **THEN** PiRelay records a safe local audit/diagnostic event with skill name, requester channel category, route, and result class +- **AND** it does not record raw skill input unless existing prompt audit policy explicitly allows equivalent prompt text + diff --git a/tests/broker-process.test.ts b/tests/broker-process.test.ts index 67b483b..190dd87 100644 --- a/tests/broker-process.test.ts +++ b/tests/broker-process.test.ts @@ -921,6 +921,243 @@ describe("telegram broker process", () => { expect(texts).toEqual(["Remote skill invocation is disabled.", "Remote skill invocation is disabled."]); }); + it("delivers compact broker progress and suppresses volatile normal-mode bookkeeping", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "pirelay-broker-progress-")); + tempDirs.push(stateDir); + const outboxPath = join(stateDir, "telegram-outbox.jsonl"); + const binding = { + sessionKey: "broker-progress:memory", + sessionId: "broker-progress", + sessionLabel: "Broker Progress", + chatId: 123, + userId: 456, + boundAt: new Date(0).toISOString(), + lastSeenAt: new Date(0).toISOString(), + progressMode: "normal", + status: "active", + }; + await writeFile(join(stateDir, "state.json"), JSON.stringify({ + setup: { botId: 1, botUsername: "dummy_bot", botDisplayName: "Dummy", validatedAt: new Date(0).toISOString() }, + pendingPairings: {}, + bindings: { [binding.sessionKey]: binding }, + channelBindings: {}, + })); + + const socketPath = join(stateDir, "broker.sock"); + const brokerPath = fileURLToPath(new URL("../extensions/relay/broker/entry.js", import.meta.url)); + const child = spawn(process.execPath, [brokerPath], { + env: { + ...process.env, + TELEGRAM_TUNNEL_BROKER_SOCKET_PATH: socketPath, + TELEGRAM_TUNNEL_BROKER_CONFIG_JSON: JSON.stringify({ botToken: "123456:ABCDEFGHIJKLMNOPQRSTUVWXYZ123456", stateDir, pollingTimeoutSeconds: 1, progressIntervalMs: 1 }), + TELEGRAM_TUNNEL_BROKER_SKIP_POLLING: "1", + PI_RELAY_BROKER_TEST_TELEGRAM_OUTBOX_PATH: outboxPath, + }, + }); + children.push(child); + await waitForSocket(socketPath, child); + const client = await openBrokerClient(socketPath); + try { + await client.request({ + type: "request", + action: "registerRoute", + clientId: "progress-client", + route: { + sessionKey: binding.sessionKey, + sessionId: binding.sessionId, + sessionLabel: binding.sessionLabel, + online: true, + busy: true, + notification: { lastStatus: "running", progressEvent: { id: "volatile", kind: "tool", text: "Processed tool result", delivery: "volatile", at: Date.now() } }, + binding, + }, + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(await readFile(outboxPath, "utf8").catch(() => "")).toBe(""); + + await client.request({ + type: "request", + action: "registerRoute", + clientId: "progress-client", + route: { + sessionKey: binding.sessionKey, + sessionId: binding.sessionId, + sessionLabel: binding.sessionLabel, + online: true, + busy: true, + notification: { lastStatus: "running", progressEvent: { id: "tool", kind: "tool", text: "Tool completed", detail: "bash", at: Date.now() } }, + binding, + }, + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + await client.request({ + type: "request", + action: "registerRoute", + clientId: "progress-client", + route: { + sessionKey: binding.sessionKey, + sessionId: binding.sessionId, + sessionLabel: binding.sessionLabel, + online: true, + busy: true, + notification: { lastStatus: "running", progressEvent: { id: "tool-2", kind: "tool", text: "Tool completed", detail: "read", at: Date.now() + 1 } }, + binding, + }, + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + } finally { + client.close(); + } + + const outbox = parseOutbox(await readFile(outboxPath, "utf8")); + const sends = outbox.filter((entry): entry is TestOutboxMessage => entry.method === "sendMessage"); + const edits = outbox.filter((entry): entry is TestOutboxEditMessage => entry.method === "editMessageText"); + expect(sends).toHaveLength(1); + expect(sends[0]?.text).toContain("Tool completed"); + expect(sends[0]?.text).not.toContain("Pi progress"); + expect(sends[0]?.text).not.toContain("Processed tool result"); + expect(edits).toHaveLength(1); + expect(edits[0]).toMatchObject({ chatId: 123, messageId: 10_000, text: expect.stringContaining("read") }); + }); + + it("falls back to plain broker progress when editable send fails", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "prb-pef-")); + tempDirs.push(stateDir); + const outboxPath = join(stateDir, "telegram-outbox.jsonl"); + const binding = { + sessionKey: "broker-progress-editable-fallback:memory", + sessionId: "broker-progress-editable-fallback", + sessionLabel: "Broker Progress Editable Fallback", + chatId: 123, + userId: 456, + boundAt: new Date(0).toISOString(), + lastSeenAt: new Date(0).toISOString(), + progressMode: "normal", + status: "active", + }; + await writeFile(join(stateDir, "state.json"), JSON.stringify({ + setup: { botId: 1, botUsername: "dummy_bot", botDisplayName: "Dummy", validatedAt: new Date(0).toISOString() }, + pendingPairings: {}, + bindings: { [binding.sessionKey]: binding }, + channelBindings: {}, + })); + + const socketPath = join(stateDir, "broker.sock"); + const brokerPath = fileURLToPath(new URL("../extensions/relay/broker/entry.js", import.meta.url)); + const child = spawn(process.execPath, [brokerPath], { + env: { + ...process.env, + TELEGRAM_TUNNEL_BROKER_SOCKET_PATH: socketPath, + TELEGRAM_TUNNEL_BROKER_CONFIG_JSON: JSON.stringify({ botToken: "123456:ABCDEFGHIJKLMNOPQRSTUVWXYZ123456", stateDir, pollingTimeoutSeconds: 1, progressIntervalMs: 1 }), + TELEGRAM_TUNNEL_BROKER_SKIP_POLLING: "1", + PI_RELAY_BROKER_TEST_TELEGRAM_OUTBOX_PATH: outboxPath, + PI_RELAY_BROKER_TEST_FAIL_EDITABLE_PROGRESS_SEND_ONCE: "1", + }, + }); + children.push(child); + await waitForSocket(socketPath, child); + const client = await openBrokerClient(socketPath); + try { + await client.request({ + type: "request", + action: "registerRoute", + clientId: "progress-editable-fallback-client", + route: { + sessionKey: binding.sessionKey, + sessionId: binding.sessionId, + sessionLabel: binding.sessionLabel, + online: true, + busy: true, + notification: { lastStatus: "running", progressEvent: { id: "tool-1", kind: "tool", text: "Fallback progress", at: Date.now() } }, + binding, + }, + }); + await waitForFileToContain(outboxPath, "Fallback progress"); + } finally { + client.close(); + } + + const outbox = parseOutbox(await readFile(outboxPath, "utf8")); + const sends = outbox.filter((entry): entry is TestOutboxMessage => entry.method === "sendMessage"); + const edits = outbox.filter((entry): entry is TestOutboxEditMessage => entry.method === "editMessageText"); + expect(sends).toHaveLength(1); + expect(sends[0]?.text).toContain("Fallback progress"); + expect(edits).toHaveLength(0); + }); + + it("clears broker progress state when queued progress becomes non-deliverable", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "pirelay-broker-progress-filtered-")); + tempDirs.push(stateDir); + const outboxPath = join(stateDir, "telegram-outbox.jsonl"); + const binding = { + sessionKey: "broker-progress-filtered:memory", + sessionId: "broker-progress-filtered", + sessionLabel: "Broker Progress Filtered", + chatId: 123, + userId: 456, + boundAt: new Date(0).toISOString(), + lastSeenAt: new Date(0).toISOString(), + progressMode: "normal", + status: "active", + }; + const writeState = async (progressMode: "normal" | "quiet") => { + await writeFile(join(stateDir, "state.json"), JSON.stringify({ + setup: { botId: 1, botUsername: "dummy_bot", botDisplayName: "Dummy", validatedAt: new Date(0).toISOString() }, + pendingPairings: {}, + bindings: { [binding.sessionKey]: { ...binding, progressMode } }, + channelBindings: {}, + })); + }; + await writeState("normal"); + + const socketPath = join(stateDir, "broker.sock"); + const brokerPath = fileURLToPath(new URL("../extensions/relay/broker/entry.js", import.meta.url)); + const child = spawn(process.execPath, [brokerPath], { + env: { + ...process.env, + TELEGRAM_TUNNEL_BROKER_SOCKET_PATH: socketPath, + TELEGRAM_TUNNEL_BROKER_CONFIG_JSON: JSON.stringify({ botToken: "123456:ABCDEFGHIJKLMNOPQRSTUVWXYZ123456", stateDir, pollingTimeoutSeconds: 1, progressIntervalMs: 500 }), + TELEGRAM_TUNNEL_BROKER_SKIP_POLLING: "1", + PI_RELAY_BROKER_TEST_TELEGRAM_OUTBOX_PATH: outboxPath, + }, + }); + children.push(child); + await waitForSocket(socketPath, child); + const client = await openBrokerClient(socketPath); + const registerProgress = async (id: string, text: string) => { + await client.request({ + type: "request", + action: "registerRoute", + clientId: "progress-filtered-client", + route: { + sessionKey: binding.sessionKey, + sessionId: binding.sessionId, + sessionLabel: binding.sessionLabel, + online: true, + busy: true, + notification: { lastStatus: "running", progressEvent: { id, kind: "tool", text, at: Date.now() } }, + binding: { ...binding, progressMode: "normal" }, + }, + }); + }; + try { + await registerProgress("tool-1", "Initial progress"); + await waitForFileToContain(outboxPath, "Initial progress"); + await registerProgress("tool-2", "Suppressed progress"); + await writeState("quiet"); + await new Promise((resolve) => setTimeout(resolve, 650)); + const afterSuppressed = parseOutbox(await readFile(outboxPath, "utf8")) + .filter((entry): entry is TestOutboxMessage => entry.method === "sendMessage"); + expect(afterSuppressed.map((entry) => entry.text).join("\n")).not.toContain("Suppressed progress"); + + await writeState("normal"); + await registerProgress("tool-3", "Resumed progress"); + await waitForFileToContain(outboxPath, "Resumed progress", 250); + } finally { + client.close(); + } + }); + it("rejects pending broker client requests when the socket closes cleanly", async () => { const stateDir = await mkdtemp(join(tmpdir(), "pirelay-broker-process-")); tempDirs.push(stateDir); @@ -1047,7 +1284,15 @@ interface TestOutboxDocument { options?: unknown; } -type TestOutboxEntry = TestOutboxMessage | TestOutboxDocument; +interface TestOutboxEditMessage { + method: "editMessageText"; + chatId: number; + messageId: number; + text: string; + options?: unknown; +} + +type TestOutboxEntry = TestOutboxMessage | TestOutboxDocument | TestOutboxEditMessage; function parseOutbox(raw: string): TestOutboxEntry[] { return raw.trim().split("\n").filter(Boolean).map((line) => JSON.parse(line) as TestOutboxEntry); @@ -1105,6 +1350,17 @@ function stopChild(child: ChildProcessWithoutNullStreams): Promise { }); } +async function waitForFileToContain(path: string, expected: string, timeoutMs = 1_000): Promise { + const deadline = Date.now() + timeoutMs; + let latest = ""; + while (Date.now() < deadline) { + latest = await readFile(path, "utf8").catch(() => ""); + if (latest.includes(expected)) return; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + expect(latest).toContain(expected); +} + function isAlreadyExitedError(error: unknown): boolean { return typeof error === "object" && error !== null && "code" in error && error.code === "ESRCH"; } diff --git a/tests/discord-runtime.test.ts b/tests/discord-runtime.test.ts index 7d09d7f..37b66ce 100644 --- a/tests/discord-runtime.test.ts +++ b/tests/discord-runtime.test.ts @@ -188,6 +188,129 @@ describe("DiscordRuntime", () => { expect(betaOperations.messages).toHaveLength(messageCount); }); + it("keeps queued Discord progress when a suppressed assistant update arrives", async () => { + const cfg = await config(); + cfg.progressIntervalMs = 1; + const ops = new FakeDiscordOperations(); + const runtime = new DiscordRuntime(cfg, { operations: ops }); + const session = route().route; + session.notification.lastStatus = "running"; + const store = new TunnelStateStore(cfg.stateDir); + await store.upsertChannelBinding({ + channel: "discord", + instanceId: "default", + conversationId: "dm1", + userId: "u1", + sessionKey: session.sessionKey, + sessionId: session.sessionId, + sessionLabel: session.sessionLabel, + boundAt: new Date().toISOString(), + lastSeenAt: new Date().toISOString(), + metadata: { progressMode: "normal" }, + }); + + await runtime.start(); + + session.notification.progressEvent = { id: "tool-progress", kind: "tool", text: "Running tests", at: Date.now() }; + await runtime.registerRoute(session); + session.notification.progressEvent = { id: "assistant-progress", kind: "assistant", text: "Drafting response", at: Date.now() }; + await runtime.registerRoute(session); + await vi.waitFor(() => expect(ops.messages).toHaveLength(1)); + + expect(ops.messages).toHaveLength(1); + expect(ops.messages[0]?.content).toContain("Running tests"); + expect(ops.messages[0]?.content).not.toContain("Pi progress"); + expect(ops.messages[0]?.content).not.toContain("Drafting response"); + }); + + it("clears Discord progress state when pending progress becomes suppressed", async () => { + vi.useFakeTimers(); + const cfg = await config(); + cfg.progressIntervalMs = 1; + const ops = new FakeDiscordOperations(); + const runtime = new DiscordRuntime(cfg, { operations: ops }); + const session = route().route; + session.notification.lastStatus = "running"; + const store = new TunnelStateStore(cfg.stateDir); + await store.upsertChannelBinding({ + channel: "discord", + instanceId: "default", + conversationId: "dm1", + userId: "u1", + sessionKey: session.sessionKey, + sessionId: session.sessionId, + sessionLabel: session.sessionLabel, + boundAt: new Date().toISOString(), + lastSeenAt: new Date().toISOString(), + metadata: { progressMode: "normal" }, + }); + + await runtime.start(); + session.notification.progressEvent = { id: "tool-progress", kind: "tool", text: "Running tests", at: Date.now() }; + await runtime.registerRoute(session); + await store.upsertChannelBinding({ + channel: "discord", + instanceId: "default", + conversationId: "dm1", + userId: "u1", + sessionKey: session.sessionKey, + sessionId: session.sessionId, + sessionLabel: session.sessionLabel, + boundAt: new Date().toISOString(), + lastSeenAt: new Date().toISOString(), + metadata: { progressMode: "quiet" }, + }); + await vi.runOnlyPendingTimersAsync(); + await vi.waitFor(() => expect((runtime as any).progressStates.size).toBe(0)); + expect(ops.messages).toHaveLength(0); + }); + + it("delivers Discord compaction progress in completion-only mode but not quiet", async () => { + const cfg = await config(); + cfg.progressIntervalMs = 1; + const ops = new FakeDiscordOperations(); + const runtime = new DiscordRuntime(cfg, { operations: ops }); + const session = route().route; + session.notification.lastStatus = "completed"; + session.notification.progressEvent = { id: "compact-start", kind: "compaction", text: "Context compaction started", at: Date.now() }; + const store = new TunnelStateStore(cfg.stateDir); + await store.upsertChannelBinding({ + channel: "discord", + instanceId: "default", + conversationId: "dm1", + userId: "u1", + sessionKey: session.sessionKey, + sessionId: session.sessionId, + sessionLabel: session.sessionLabel, + boundAt: new Date().toISOString(), + lastSeenAt: new Date().toISOString(), + metadata: { progressMode: "completionOnly" }, + }); + + await runtime.registerRoute(session); + await vi.waitFor(() => expect(ops.messages.length).toBeGreaterThan(0)); + + expect(ops.messages.at(-1)?.content).toContain("Context compaction started"); + + ops.messages.length = 0; + await store.upsertChannelBinding({ + channel: "discord", + instanceId: "default", + conversationId: "dm1", + userId: "u1", + sessionKey: session.sessionKey, + sessionId: session.sessionId, + sessionLabel: session.sessionLabel, + boundAt: new Date().toISOString(), + lastSeenAt: new Date().toISOString(), + metadata: { progressMode: "quiet" }, + }); + session.notification.progressEvent = { id: "compact-done", kind: "compaction", text: "Context compaction completed", at: Date.now() }; + await runtime.registerRoute(session); + + expect(ops.messages).toHaveLength(0); + }); + it("reports startup failures without exposing tokens", async () => { const cfg = await config(); const ops = new FakeDiscordOperations(new Error("bad discord-token-supersecret")); diff --git a/tests/integration.test.ts b/tests/integration.test.ts index a10f478..8611dff 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -472,6 +472,56 @@ describe("PiRelay integration behavior", () => { expect(statuses).toContainEqual({ key: "relay-sync", value: "" }); }); + it("publishes safe compaction progress from Pi compaction hooks", async () => { + const config = await createRuntimeConfig("pi-compaction-progress-hooks-"); + vi.stubEnv("TELEGRAM_BOT_TOKEN", config.botToken); + vi.stubEnv("PI_TELEGRAM_TUNNEL_STATE_DIR", config.stateDir); + let registeredRoute: SessionRoute | undefined; + const registerRoute = vi.fn(async (route: SessionRoute) => { registeredRoute = route; }); + const fakeRuntime: TunnelRuntime = { + setup: undefined, + start: vi.fn(async () => undefined), + stop: vi.fn(async () => undefined), + ensureSetup: vi.fn(async () => ({ + botId: 123456, + botUsername: "pi_test_bot", + botDisplayName: "Pi Test Bot", + validatedAt: new Date().toISOString(), + })), + registerRoute, + unregisterRoute: vi.fn(async () => undefined), + getStatus: vi.fn(() => undefined), + sendToBoundChat: vi.fn(async () => undefined), + }; + + vi.doMock("../extensions/relay/adapters/telegram/runtime.js", () => ({ + getOrCreateTunnelRuntime: () => fakeRuntime, + sendSessionNotification: vi.fn(async () => undefined), + })); + + const { default: relayExtension } = await import("../extensions/relay/index.js"); + const pi = createMockPi(); + const { context } = createMockContext("compaction-progress"); + relayExtension(pi.api as any); + + await pi.emit("session_start", {}, context); + await pi.emit("session_before_compact", { customInstructions: "SECRET_PROMPT" }, context); + await waitFor(() => registerRoute.mock.calls.length >= 2); + + expect(registeredRoute?.notification.progressEvent).toMatchObject({ kind: "compaction", text: "Context compaction started" }); + expect(JSON.stringify(registeredRoute?.notification.progressEvent)).not.toContain("SECRET_PROMPT"); + + await pi.emit("session_compact", { compactionEntry: { summary: "SECRET_SUMMARY" } }, context); + await waitFor(() => registerRoute.mock.calls.length >= 3); + + expect(registeredRoute?.notification.progressEvent).toMatchObject({ kind: "compaction", text: "Context compaction completed" }); + expect(JSON.stringify(registeredRoute?.notification)).not.toContain("SECRET_SUMMARY"); + expect(registeredRoute?.notification.recentActivity?.map((entry) => entry.text)).toEqual([ + "Context compaction started", + "Context compaction completed", + ]); + }); + it("bypasses approval gates for local-only tool calls", async () => { const config = await createRuntimeConfig("pi-approval-local-"); await writeFile(config.configPath!, JSON.stringify({ @@ -2772,6 +2822,101 @@ describe("PiRelay integration behavior", () => { expect(sendSessionNotification).toHaveBeenCalledWith(fakeRuntime, route, "completed", expect.anything()); }); + it("records safe visible assistant text updates as verbose-only model progress", async () => { + const config = await createRuntimeConfig("pi-visible-model-progress-"); + vi.stubEnv("TELEGRAM_BOT_TOKEN", config.botToken); + vi.stubEnv("PI_TELEGRAM_TUNNEL_STATE_DIR", config.stateDir); + + const registeredRoutes = new Map(); + const fakeRuntime: TunnelRuntime = { + setup: undefined, + start: vi.fn(async () => undefined), + stop: vi.fn(async () => undefined), + ensureSetup: vi.fn(async () => ({ + botId: 123456, + botUsername: "pi_test_bot", + botDisplayName: "Pi Test Bot", + validatedAt: new Date().toISOString(), + })), + registerRoute: vi.fn(async (route: SessionRoute) => { + registeredRoutes.set(route.sessionKey, route); + }), + unregisterRoute: vi.fn(async () => undefined), + getStatus: vi.fn(() => undefined), + sendToBoundChat: vi.fn(async () => undefined), + }; + + vi.doMock("../extensions/relay/adapters/telegram/runtime.js", () => ({ + getOrCreateTunnelRuntime: () => fakeRuntime, + sendSessionNotification: vi.fn(async () => undefined), + })); + + const { default: relayExtension } = await import("../extensions/relay/index.js"); + const pi = createMockPi(); + const { context } = createMockContext("visible-model-progress"); + relayExtension(pi.api as any); + + await pi.emit("session_start", { reason: "startup" }, context); + const route = [...registeredRoutes.values()][0]!; + await pi.emit("agent_start", {}, context); + await pi.emit("message_update", { + message: { role: "assistant", content: [{ type: "text", text: "Visible draft without stream metadata." }] }, + }, context); + await pi.emit("message_update", { + message: { role: "assistant", content: [{ type: "text", text: "Visible draft with partial stream metadata." }] }, + assistantMessageEvent: {}, + }, context); + expect(route.notification.progressEvent).toMatchObject({ kind: "lifecycle", text: "Pi task started" }); + + await pi.emit("message_update", { + message: { + role: "assistant", + content: [ + { type: "thinking", thinking: "SECRET_REASONING" }, + { type: "text", text: "I'll inspect the failing tests and then patch the runtime." }, + ], + }, + assistantMessageEvent: { type: "text_end", contentIndex: 1, content: "I'll inspect the failing tests and then patch the runtime." }, + }, context); + + expect(route.notification.progressEvent).toMatchObject({ + kind: "assistant", + text: "Model update", + detail: expect.stringContaining("inspect the failing tests"), + delivery: "volatile", + semanticKey: expect.stringContaining("inspect the failing tests"), + }); + expect(JSON.stringify(route.notification.progressEvent)).not.toContain("SECRET_REASONING"); + + await pi.emit("message_end", { + message: { role: "toolResult", toolCallId: "tool-1", toolName: "bash", content: [{ type: "text", text: "SECRET_TOOL_RESULT" }], isError: false }, + }, context); + expect(route.notification.progressEvent).toMatchObject({ + kind: "tool", + text: "Processed tool result", + delivery: "volatile", + semanticKey: "tool-result:tool-1", + }); + expect(JSON.stringify(route.notification.progressEvent)).not.toContain("SECRET_TOOL_RESULT"); + + await pi.emit("tool_execution_end", { toolCallId: "tool-1", toolName: "bash", result: {}, isError: false }, context); + expect(route.notification.progressEvent).toMatchObject({ + kind: "tool", + text: "Tool completed", + detail: "bash", + delivery: "milestone", + semanticKey: "tool-completed:tool-1", + }); + + await pi.emit("tool_execution_end", { toolName: "read", result: {}, isError: false }, context); + const missingIdKey = route.notification.progressEvent?.semanticKey; + expect(missingIdKey).toMatch(/^tool-completed:missing-/); + expect(missingIdKey).not.toContain("undefined"); + await pi.emit("tool_execution_end", { toolName: "read", result: {}, isError: false }, context); + expect(route.notification.progressEvent?.semanticKey).toMatch(/^tool-completed:missing-/); + expect(route.notification.progressEvent?.semanticKey).not.toBe(missingIdKey); + }); + it("does not use stream-only drafts as final-output fallback", async () => { const config = await createRuntimeConfig("pi-final-output-stream-only-"); vi.stubEnv("TELEGRAM_BOT_TOKEN", config.botToken); diff --git a/tests/progress.test.ts b/tests/progress.test.ts index b318bde..d70ad37 100644 --- a/tests/progress.test.ts +++ b/tests/progress.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { appendRecentActivity, + coalesceLiveProgressEntries, createProgressActivity, displayProgressMode, formatProgressUpdate, @@ -8,7 +9,10 @@ import { normalizeProgressMode, progressIntervalMsFor, progressModeFor, + progressSemanticKey, + shouldSendCompactionProgress, shouldSendNonTerminalProgress, + shouldSendProgressActivity, } from "../extensions/relay/notifications/progress.js"; import type { SessionNotificationState, TelegramBindingMetadata, TelegramTunnelConfig } from "../extensions/relay/core/types.js"; @@ -37,6 +41,24 @@ describe("progress helpers", () => { expect(progressIntervalMsFor(mode, config)).toBe(5_000); }); + it("sends compaction progress in every mode except quiet", () => { + expect(shouldSendCompactionProgress("quiet")).toBe(false); + expect(shouldSendCompactionProgress("normal")).toBe(true); + expect(shouldSendCompactionProgress("verbose")).toBe(true); + expect(shouldSendCompactionProgress("completionOnly")).toBe(true); + expect(shouldSendProgressActivity("completionOnly", { kind: "compaction" })).toBe(true); + expect(shouldSendProgressActivity("completionOnly", { kind: "tool" })).toBe(false); + }); + + it("keeps assistant heartbeat progress verbose-only", () => { + expect(shouldSendProgressActivity("quiet", { kind: "assistant" })).toBe(false); + expect(shouldSendProgressActivity("normal", { kind: "assistant" })).toBe(false); + expect(shouldSendProgressActivity("completionOnly", { kind: "assistant" })).toBe(false); + expect(shouldSendProgressActivity("verbose", { kind: "assistant" })).toBe(true); + expect(shouldSendProgressActivity("normal", { kind: "tool" })).toBe(true); + expect(shouldSendProgressActivity("normal", { kind: "tool", text: "Processed tool result" })).toBe(false); + }); + it("redacts and bounds progress text", () => { const entry = createProgressActivity({ id: "p1", kind: "tool", text: "Running SECRET_TOKEN in tool" }, config); expect(entry?.text).toContain("[redacted]"); @@ -51,6 +73,42 @@ describe("progress helpers", () => { expect(update).toContain("Running tests (2×)"); }); + it("formats compact progress without the repeated header", () => { + const entry = createProgressActivity({ id: "p1", kind: "tool", text: "Running tests", at: 1 }, config)!; + const update = formatProgressUpdate([entry], config, { header: false }); + expect(update).toBe("● Running tests"); + }); + + it("deduplicates milestones semantically and keeps latest volatile status", () => { + const first = createProgressActivity({ id: "a", kind: "assistant", text: "Model update", detail: "Draft A", at: 1, delivery: "volatile", semanticKey: "assistant" }, config)!; + const second = createProgressActivity({ id: "b", kind: "assistant", text: "Model update", detail: "Draft B", at: 2, delivery: "volatile", semanticKey: "assistant" }, config)!; + const toolA = createProgressActivity({ id: "c", kind: "tool", text: "Tool completed", detail: "bash", at: 3, semanticKey: "tool:1" }, config)!; + const toolB = createProgressActivity({ id: "d", kind: "tool", text: "Tool completed", detail: "bash", at: 4, semanticKey: "tool:1" }, config)!; + const coalesced = coalesceLiveProgressEntries([first, second, toolA, toolB]); + expect(coalesced.map((entry) => entry.detail)).toContain("Draft B"); + expect(coalesced.map((entry) => entry.detail)).not.toContain("Draft A"); + expect(coalesced.find((entry) => entry.text.startsWith("Tool completed"))?.text).toBe("Tool completed (2×)"); + expect(progressSemanticKey(toolA)).toBe(progressSemanticKey(toolB)); + }); + + it("keeps the newest volatile progress even when entries are out of order", () => { + const newer = createProgressActivity({ id: "newer", kind: "assistant", text: "Model update", detail: "new draft", at: 20, delivery: "volatile" }, config)!; + const older = createProgressActivity({ id: "older", kind: "assistant", text: "Model update", detail: "old draft", at: 10, delivery: "volatile" }, config)!; + const coalesced = coalesceLiveProgressEntries([newer, older]); + + expect(coalesced).toHaveLength(1); + expect(coalesced[0]?.detail).toBe("new draft"); + }); + + it("sanitizes live progress before coalescing or formatting", () => { + const entry = createProgressActivity({ id: "secret", kind: "assistant", text: "Model update", detail: "SECRET_TOKEN chat 12345", semanticKey: "assistant:SECRET_TOKEN chat 12345", delivery: "volatile" }, config)!; + const update = formatProgressUpdate([entry], config, { header: false }); + expect(update).toContain("[redacted]"); + expect(update).not.toContain("SECRET_TOKEN"); + expect(entry.semanticKey).toContain("[redacted]"); + expect(entry.semanticKey).not.toContain("secret_token"); + }); + it("stores bounded recent activity", () => { const notification: SessionNotificationState = {}; const first = createProgressActivity({ id: "p1", kind: "lifecycle", text: "Started", at: 1 }, config)!; diff --git a/tests/runtime.test.ts b/tests/runtime.test.ts index 82c94e2..b23f052 100644 --- a/tests/runtime.test.ts +++ b/tests/runtime.test.ts @@ -2777,7 +2777,7 @@ describe("InProcessTunnelRuntime", () => { it("routes Telegram group /to@bot prompts and outputs through private-pairing authorization", async () => { vi.useFakeTimers(); const config = await createRuntimeConfig(); - config.progressIntervalMs = 1; + config.progressIntervalMs = 1_000; const store = new TunnelStateStore(config.stateDir); const runtime = new InProcessTunnelRuntime(config, store); (runtime as any).setupCache = { botId: 123456, botUsername: "mini_builder_bot", botDisplayName: "Mini Builder", validatedAt: new Date().toISOString() }; @@ -2925,14 +2925,319 @@ describe("InProcessTunnelRuntime", () => { await vi.runOnlyPendingTimersAsync(); await vi.waitFor(() => expect(sent[0]).toContain("Running tests (2×)")); + expect(sent[0]).not.toContain("Pi progress"); expect(route.notification.recentActivity).toHaveLength(2); sent.length = 0; + binding.progressMode = "normal"; + route.notification.progressEvent = createProgressActivity({ id: "assistant-normal", kind: "assistant", text: "Model update", detail: "Drafting text", delivery: "volatile", at: Date.now() }, config); + (runtime as any).syncProgressDelivery(route); + route.notification.progressEvent = createProgressActivity({ id: "tool-result-normal", kind: "tool", text: "Processed tool result", delivery: "volatile", at: Date.now() }, config); + (runtime as any).syncProgressDelivery(route); + await vi.runOnlyPendingTimersAsync(); + expect(sent).toEqual([]); + binding.progressMode = "quiet"; route.notification.progressEvent = createProgressActivity({ id: "p3", kind: "tool", text: "Editing files", at: Date.now() }, config); (runtime as any).syncProgressDelivery(route); await vi.runOnlyPendingTimersAsync(); expect(sent).toEqual([]); + + binding.progressMode = "completionOnly"; + route.notification.lastStatus = "completed"; + route.notification.progressEvent = createProgressActivity({ id: "p4", kind: "compaction", text: "Context compaction started", at: Date.now() }, config); + (runtime as any).syncProgressDelivery(route); + await vi.runOnlyPendingTimersAsync(); + await vi.waitFor(() => expect(sent[0]).toContain("Context compaction started")); + + sent.length = 0; + binding.progressMode = "quiet"; + route.notification.progressEvent = createProgressActivity({ id: "p5", kind: "compaction", text: "Context compaction completed", at: Date.now() }, config); + (runtime as any).syncProgressDelivery(route); + await vi.runOnlyPendingTimersAsync(); + expect(sent).toEqual([]); + }); + + it("edits Telegram live progress in place when supported", async () => { + vi.useFakeTimers(); + const config = await createRuntimeConfig(); + config.progressIntervalMs = 1_000; + const store = new TunnelStateStore(config.stateDir); + const runtime = new InProcessTunnelRuntime(config, store); + const binding: TelegramBindingMetadata = { + sessionKey: "session-progress-edit:/tmp/session-progress-edit.jsonl", + sessionId: "session-progress-edit", + sessionFile: "/tmp/session-progress-edit.jsonl", + sessionLabel: "session-progress-edit.jsonl", + chatId: 1009, + userId: 29, + username: "owner", + boundAt: new Date().toISOString(), + lastSeenAt: new Date().toISOString(), + progressMode: "normal", + }; + const { route } = createRoute(binding, false); + route.notification.lastStatus = "running"; + (runtime as any).routes.set(route.sessionKey, route); + const sends: string[] = []; + const edits: Array<{ messageId: number; text: string }> = []; + (runtime as any).api = { + sendEditablePlainText: async (_chatId: number, text: string) => { + sends.push(text); + return 77; + }, + editPlainText: async (_chatId: number, messageId: number, text: string) => { + edits.push({ messageId, text }); + }, + sendPlainText: async () => undefined, + }; + + route.notification.progressEvent = createProgressActivity({ id: "edit-1", kind: "tool", text: "Running tests", at: Date.now() }, config); + (runtime as any).syncProgressDelivery(route); + await vi.runOnlyPendingTimersAsync(); + await vi.waitFor(() => expect(sends).toHaveLength(1)); + expect(sends[0]).toContain("Running tests"); + + route.notification.progressEvent = createProgressActivity({ id: "edit-2", kind: "tool", text: "Editing files", at: Date.now() + 1 }, config); + (runtime as any).syncProgressDelivery(route); + await vi.runOnlyPendingTimersAsync(); + + await vi.waitFor(() => expect(edits).toHaveLength(1)); + expect(edits[0]).toEqual(expect.objectContaining({ messageId: 77, text: expect.stringContaining("Editing files") })); + }); + + it("falls back to a new Telegram progress snapshot when editing fails", async () => { + vi.useFakeTimers(); + const config = await createRuntimeConfig(); + config.progressIntervalMs = 1; + const store = new TunnelStateStore(config.stateDir); + const runtime = new InProcessTunnelRuntime(config, store); + const binding: TelegramBindingMetadata = { + sessionKey: "session-progress-edit-fallback:/tmp/session-progress-edit-fallback.jsonl", + sessionId: "session-progress-edit-fallback", + sessionFile: "/tmp/session-progress-edit-fallback.jsonl", + sessionLabel: "session-progress-edit-fallback.jsonl", + chatId: 1011, + userId: 31, + username: "owner", + boundAt: new Date().toISOString(), + lastSeenAt: new Date().toISOString(), + progressMode: "normal", + }; + const { route } = createRoute(binding, false); + route.notification.lastStatus = "running"; + (runtime as any).routes.set(route.sessionKey, route); + const sends: string[] = []; + let nextMessageId = 10; + (runtime as any).api = { + sendEditablePlainText: async (_chatId: number, text: string) => { + sends.push(text); + return nextMessageId++; + }, + editPlainText: async () => { + throw new Error("message deleted"); + }, + sendPlainText: async () => undefined, + }; + + route.notification.progressEvent = createProgressActivity({ id: "fallback-1", kind: "tool", text: "Running tests", at: Date.now() }, config); + (runtime as any).syncProgressDelivery(route); + await vi.runOnlyPendingTimersAsync(); + await vi.waitFor(() => expect(sends).toHaveLength(1)); + + route.notification.progressEvent = createProgressActivity({ id: "fallback-2", kind: "tool", text: "Editing files", at: Date.now() + 1 }, config); + (runtime as any).syncProgressDelivery(route); + await vi.runOnlyPendingTimersAsync(); + + await vi.waitFor(() => expect(sends).toHaveLength(2)); + expect(sends[1]).toContain("Editing files"); + }); + + it("clears Telegram progress state when queued progress becomes non-deliverable", async () => { + vi.useFakeTimers(); + const config = await createRuntimeConfig(); + config.progressIntervalMs = 1_000; + const store = new TunnelStateStore(config.stateDir); + const runtime = new InProcessTunnelRuntime(config, store); + const binding: TelegramBindingMetadata = { + sessionKey: "session-progress-filtered-empty:/tmp/session-progress-filtered-empty.jsonl", + sessionId: "session-progress-filtered-empty", + sessionFile: "/tmp/session-progress-filtered-empty.jsonl", + sessionLabel: "session-progress-filtered-empty.jsonl", + chatId: 1013, + userId: 33, + username: "owner", + boundAt: new Date().toISOString(), + lastSeenAt: new Date().toISOString(), + progressMode: "normal", + }; + const { route } = createRoute(binding, false); + route.notification.lastStatus = "running"; + (runtime as any).routes.set(route.sessionKey, route); + const sends: string[] = []; + (runtime as any).api = { + sendEditablePlainText: async (_chatId: number, text: string) => { + sends.push(text); + return 88; + }, + editPlainText: async () => undefined, + sendPlainText: async () => undefined, + }; + + route.notification.progressEvent = createProgressActivity({ id: "filtered-empty-1", kind: "tool", text: "Running tests", at: Date.now() }, config); + (runtime as any).syncProgressDelivery(route); + await vi.runOnlyPendingTimersAsync(); + await vi.waitFor(() => expect(sends).toHaveLength(1)); + + route.notification.progressEvent = createProgressActivity({ id: "filtered-empty-2", kind: "tool", text: "Editing files", at: Date.now() + 1 }, config); + (runtime as any).syncProgressDelivery(route); + expect((runtime as any).progressStates.size).toBe(1); + binding.progressMode = "quiet"; + await vi.advanceTimersByTimeAsync(1_000); + + await vi.waitFor(() => expect((runtime as any).progressStates.size).toBe(0)); + expect(sends).toHaveLength(1); + }); + + it("falls back to plain Telegram progress when editable send fails", async () => { + vi.useFakeTimers(); + const config = await createRuntimeConfig(); + config.progressIntervalMs = 1; + const store = new TunnelStateStore(config.stateDir); + const runtime = new InProcessTunnelRuntime(config, store); + const binding: TelegramBindingMetadata = { + sessionKey: "session-progress-editable-send-fallback:/tmp/session-progress-editable-send-fallback.jsonl", + sessionId: "session-progress-editable-send-fallback", + sessionFile: "/tmp/session-progress-editable-send-fallback.jsonl", + sessionLabel: "session-progress-editable-send-fallback.jsonl", + chatId: 1014, + userId: 34, + username: "owner", + boundAt: new Date().toISOString(), + lastSeenAt: new Date().toISOString(), + progressMode: "normal", + }; + const { route } = createRoute(binding, false); + route.notification.lastStatus = "running"; + (runtime as any).routes.set(route.sessionKey, route); + const plainSends: string[] = []; + (runtime as any).api = { + sendEditablePlainText: async () => { + throw new Error("editable send failed"); + }, + editPlainText: async () => undefined, + sendPlainText: async (_chatId: number, text: string) => { + plainSends.push(text); + }, + }; + + route.notification.progressEvent = createProgressActivity({ id: "editable-send-fallback-1", kind: "tool", text: "Running tests", at: Date.now() }, config); + (runtime as any).syncProgressDelivery(route); + await vi.runOnlyPendingTimersAsync(); + + await vi.waitFor(() => expect(plainSends).toHaveLength(1)); + expect(plainSends[0]).toContain("Running tests"); + const state = (runtime as any).progressStates.get((runtime as any).progressKey(route)); + expect(state?.liveMessageId).toBeUndefined(); + expect(state?.lastText).toContain("Running tests"); + }); + + it("swallows Telegram progress send failures because progress is best-effort", async () => { + vi.useFakeTimers(); + const config = await createRuntimeConfig(); + config.progressIntervalMs = 1; + const store = new TunnelStateStore(config.stateDir); + const runtime = new InProcessTunnelRuntime(config, store); + const binding: TelegramBindingMetadata = { + sessionKey: "session-progress-send-failure:/tmp/session-progress-send-failure.jsonl", + sessionId: "session-progress-send-failure", + sessionFile: "/tmp/session-progress-send-failure.jsonl", + sessionLabel: "session-progress-send-failure.jsonl", + chatId: 1012, + userId: 32, + username: "owner", + boundAt: new Date().toISOString(), + lastSeenAt: new Date().toISOString(), + progressMode: "normal", + }; + const { route } = createRoute(binding, false); + route.notification.lastStatus = "running"; + (runtime as any).routes.set(route.sessionKey, route); + (runtime as any).api = { + sendEditablePlainText: async () => { + throw new Error("telegram unavailable"); + }, + editPlainText: async () => undefined, + sendPlainText: async () => { + throw new Error("telegram unavailable"); + }, + }; + + route.notification.progressEvent = createProgressActivity({ id: "send-failure-1", kind: "tool", text: "Running tests", at: Date.now() }, config); + (runtime as any).syncProgressDelivery(route); + + await vi.runOnlyPendingTimersAsync(); + const state = (runtime as any).progressStates.get((runtime as any).progressKey(route)); + expect(state?.liveMessageId).toBeUndefined(); + }); + + it("reserves progress interval while async progress flush is in flight", async () => { + vi.useFakeTimers(); + const config = await createRuntimeConfig(); + config.progressIntervalMs = 1_000; + const store = new TunnelStateStore(config.stateDir); + const runtime = new InProcessTunnelRuntime(config, store); + const binding: TelegramBindingMetadata = { + sessionKey: "session-progress-race:/tmp/session-progress-race.jsonl", + sessionId: "session-progress-race", + sessionFile: "/tmp/session-progress-race.jsonl", + sessionLabel: "session-progress-race.jsonl", + chatId: 1008, + userId: 28, + username: "owner", + boundAt: new Date().toISOString(), + lastSeenAt: new Date().toISOString(), + progressMode: "normal", + }; + const { route } = createRoute(binding, false); + route.notification.lastStatus = "running"; + (runtime as any).routes.set(route.sessionKey, route); + const sent: string[] = []; + (runtime as any).api = { sendPlainText: async (_chatId: number, text: string) => sent.push(text) }; + + let releaseBindingLookup: (() => void) | undefined; + const bindingLookupGate = new Promise((resolve) => { + releaseBindingLookup = resolve; + }); + let bindingLookups = 0; + (runtime as any).activeOutputBindingForRoute = async () => { + bindingLookups += 1; + if (bindingLookups === 1) await bindingLookupGate; + return binding; + }; + + route.notification.progressEvent = createProgressActivity({ id: "p1", kind: "tool", text: "Running tests", at: Date.now() }, config); + (runtime as any).syncProgressDelivery(route); + vi.advanceTimersByTime(0); + await Promise.resolve(); + expect(bindingLookups).toBe(1); + + route.notification.progressEvent = createProgressActivity({ id: "p2", kind: "tool", text: "Editing files", at: Date.now() }, config); + (runtime as any).syncProgressDelivery(route); + vi.advanceTimersByTime(0); + await Promise.resolve(); + expect(bindingLookups).toBe(1); + expect(sent).toEqual([]); + + releaseBindingLookup?.(); + await Promise.resolve(); + await Promise.resolve(); + expect(sent).toHaveLength(1); + expect(sent[0]).toContain("Running tests"); + expect(sent[0]).toContain("Editing files"); + + await vi.advanceTimersByTimeAsync(1_000); + expect(sent).toHaveLength(1); }); it("sends requester-scoped workspace files from Telegram remote commands", async () => { diff --git a/tests/slack-runtime.test.ts b/tests/slack-runtime.test.ts index 61e5fe3..a52821c 100644 --- a/tests/slack-runtime.test.ts +++ b/tests/slack-runtime.test.ts @@ -789,8 +789,8 @@ describe("SlackRuntime foundations", () => { await runtime.registerRoute(testRoute); await waitForSlackRuntimeCondition(() => operations.posts.length > 0); - expect(operations.posts.at(-1)).toMatchObject({ channel: "D1", threadTs: "parent-progress", text: expect.stringContaining("Pi progress") }); - expect(operations.posts.at(-1)?.text).toContain("Running tests"); + expect(operations.posts.at(-1)).toMatchObject({ channel: "D1", threadTs: "parent-progress", text: expect.stringContaining("Running tests") }); + expect(operations.posts.at(-1)?.text).not.toContain("Pi progress"); }); it("uploads latest, explicit images, and requester-scoped Slack files", async () => { @@ -871,6 +871,53 @@ describe("SlackRuntime foundations", () => { expect(operations.posts).toHaveLength(0); }); + it("keeps queued Slack progress when a suppressed assistant update arrives", async () => { + const operations = new FakeSlackOperations(); + const runtimeConfig = await config(); + runtimeConfig.progressIntervalMs = 1; + const testRoute = route(); + testRoute.notification.lastStatus = "running"; + const store = new TunnelStateStore(runtimeConfig.stateDir); + await store.upsertChannelBinding({ channel: "slack", instanceId: "default", conversationId: "D1", userId: "U_DRIVER", sessionKey: testRoute.sessionKey, sessionId: testRoute.sessionId, sessionLabel: testRoute.sessionLabel, boundAt: new Date().toISOString(), lastSeenAt: new Date().toISOString(), metadata: { progressMode: "normal" } }); + const runtime = new SlackRuntime(runtimeConfig, { operations }); + await runtime.start(); + + testRoute.notification.progressEvent = { id: "tool-progress", kind: "tool", text: "Running tests", at: Date.now() }; + await runtime.registerRoute(testRoute); + testRoute.notification.progressEvent = { id: "assistant-progress", kind: "assistant", text: "Drafting response", at: Date.now() }; + await runtime.registerRoute(testRoute); + await waitForSlackRuntimeCondition(() => operations.posts.length === 1); + + expect(operations.posts).toHaveLength(1); + expect(operations.posts[0]?.text).toContain("Running tests"); + expect(operations.posts[0]?.text).not.toContain("Pi progress"); + expect(operations.posts[0]?.text).not.toContain("Drafting response"); + }); + + it("delivers Slack compaction progress in completion-only mode but not quiet", async () => { + const operations = new FakeSlackOperations(); + const runtimeConfig = await config(); + runtimeConfig.progressIntervalMs = 1; + const testRoute = route(); + testRoute.notification.lastStatus = "completed"; + testRoute.notification.progressEvent = { id: "compact-start", kind: "compaction", text: "Context compaction started", at: Date.now() }; + const store = new TunnelStateStore(runtimeConfig.stateDir); + await store.upsertChannelBinding({ channel: "slack", instanceId: "default", conversationId: "D1", userId: "U_DRIVER", sessionKey: testRoute.sessionKey, sessionId: testRoute.sessionId, sessionLabel: testRoute.sessionLabel, boundAt: new Date().toISOString(), lastSeenAt: new Date().toISOString(), metadata: { progressMode: "completionOnly" } }); + const runtime = new SlackRuntime(runtimeConfig, { operations }); + + await runtime.registerRoute(testRoute); + await waitForSlackRuntimeCondition(() => operations.posts.length > 0); + + expect(operations.posts.at(-1)?.text).toContain("Context compaction started"); + + operations.posts.length = 0; + await store.upsertChannelBinding({ channel: "slack", instanceId: "default", conversationId: "D1", userId: "U_DRIVER", sessionKey: testRoute.sessionKey, sessionId: testRoute.sessionId, sessionLabel: testRoute.sessionLabel, boundAt: new Date().toISOString(), lastSeenAt: new Date().toISOString(), metadata: { progressMode: "quiet" } }); + testRoute.notification.progressEvent = { id: "compact-done", kind: "compaction", text: "Context compaction completed", at: Date.now() }; + await runtime.registerRoute(testRoute); + + expect(operations.posts).toHaveLength(0); + }); + it("clears pending Slack progress when the binding is revoked before flush", async () => { vi.useFakeTimers(); const operations = new FakeSlackOperations(); diff --git a/tests/telegram-adapter.test.ts b/tests/telegram-adapter.test.ts index 8e36c29..977d533 100644 --- a/tests/telegram-adapter.test.ts +++ b/tests/telegram-adapter.test.ts @@ -113,6 +113,65 @@ describe("telegram channel adapter", () => { expect(sent).toEqual(["text:hello", "doc:out.md:3:cap", "doc:base64.bin:3:", "activity:typing", "activity:upload_document", "activity:record_video", "answer:cb-1:done:alert"]); }); + it("falls back to sending plain live progress when editable send fails", async () => { + const sent: string[] = []; + const api: TelegramApiOperations = { + getUpdates: vi.fn(async () => []), + sendPlainTextWithKeyboard: vi.fn(async (_chatId, text) => { sent.push(text); }), + sendEditablePlainText: vi.fn(async () => { throw new Error("editable send failed"); }), + sendDocumentData: vi.fn(async () => undefined), + answerCallbackQuery: vi.fn(async () => undefined), + sendChatAction: vi.fn(async () => undefined), + }; + const adapter = new TelegramChannelAdapter(config(), api); + + await expect(adapter.sendLiveProgress({ channel: "telegram", conversationId: "30", userId: "40" }, "plain fallback")).resolves.toBeUndefined(); + + expect(sent).toEqual(["plain fallback"]); + }); + + it("falls back to sending new live progress when editing is unavailable or fails", async () => { + const sent: string[] = []; + const edited: string[] = []; + const api: TelegramApiOperations = { + getUpdates: vi.fn(async () => []), + sendPlainTextWithKeyboard: vi.fn(async (_chatId, text) => { sent.push(text); }), + sendEditablePlainText: vi.fn(async (_chatId, text) => { sent.push(`editable:${text}`); return 10; }), + editPlainText: vi.fn(async (_chatId, messageId, text) => { + edited.push(`${messageId}:${text}`); + if (messageId === 13) throw new Error("edit failed"); + }), + sendDocumentData: vi.fn(async () => undefined), + answerCallbackQuery: vi.fn(async () => undefined), + sendChatAction: vi.fn(async () => undefined), + }; + const adapter = new TelegramChannelAdapter(config(), api); + const address = { channel: "telegram", conversationId: "30", userId: "40" }; + + await expect(adapter.updateLiveProgress(address, { messageId: "bad-id" }, "fallback invalid")).resolves.toBeUndefined(); + await expect(adapter.updateLiveProgress(address, { messageId: "13" }, "fallback failed")).resolves.toBeUndefined(); + await adapter.updateLiveProgress(address, { messageId: "14" }, "edited ok"); + + expect(sent).toEqual(["fallback invalid", "fallback failed"]); + expect(edited).toEqual(["13:fallback failed", "14:edited ok"]); + }); + + it("falls back to sending new live progress when editing is not supported", async () => { + const sent: string[] = []; + const api: TelegramApiOperations = { + getUpdates: vi.fn(async () => []), + sendPlainTextWithKeyboard: vi.fn(async (_chatId, text) => { sent.push(text); }), + sendDocumentData: vi.fn(async () => undefined), + answerCallbackQuery: vi.fn(async () => undefined), + sendChatAction: vi.fn(async () => undefined), + }; + const adapter = new TelegramChannelAdapter(config(), api); + + await expect(adapter.updateLiveProgress({ channel: "telegram", conversationId: "30", userId: "40" }, { messageId: "10" }, "fallback unsupported")).resolves.toBeUndefined(); + + expect(sent).toEqual(["fallback unsupported"]); + }); + it("rejects invalid Telegram adapter outbound identifiers and file encodings", async () => { const api: TelegramApiOperations = { getUpdates: vi.fn(async () => []),