diff --git a/PRODUCT.md b/PRODUCT.md index 40c5107..7c498a2 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -156,7 +156,7 @@ Open tasks for upcoming iterations: - [ ] `/messages` command: browse session messages with fork/revert actions - [ ] `/skills` command: browse skills and choose one for usage - [ ] `/mcps` command: browse available MCP servers -- [ ] Dynamic subagent activity display during task execution +- [x] Dynamic subagent activity display during task execution - [ ] Git tree support - [ ] Docker runtime support and deployment guide - [ ] OpenCode server monitoring with automatic restart on stop/crash diff --git a/README.md b/README.md index 6aa3cf4..1587a60 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Languages: English (`en`), Deutsch (`de`), Español (`es`), Français (`fr`), Р - **Live status** — pinned message with current project, model, context usage, and changed files list, updated in real time - **Model switching** — pick models from OpenCode favorites and recent history directly in the chat (favorites are shown first) - **Agent modes** — switch between Plan and Build modes on the fly +- **Subagent activity** — watch live subagent progress in chat, including the current task, agent, model, and active tool step - **Custom Commands** — run OpenCode custom commands (and built-ins like `init`/`review`) from an inline menu with confirmation - **Interactive Q&A** — answer agent questions and approve permissions via inline buttons - **Voice prompts** — send voice/audio messages, transcribe them via a Whisper-compatible API, then forward recognized text to OpenCode diff --git a/src/bot/index.ts b/src/bot/index.ts index 179576e..8bbc462 100644 --- a/src/bot/index.ts +++ b/src/bot/index.ts @@ -54,6 +54,7 @@ import { formatToolInfo, getAssistantParseMode, } from "../summary/formatter.js"; +import { renderSubagentCards } from "../summary/subagent-formatter.js"; import { ToolMessageBatcher } from "../summary/tool-message-batcher.js"; import { getCurrentSession } from "../session/manager.js"; import { ingestSessionInfoForCache } from "../session/cache-manager.js"; @@ -89,6 +90,7 @@ const TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH = 1024; const RESPONSE_STREAM_THROTTLE_MS = 200; const RESPONSE_STREAM_TEXT_LIMIT = 3800; const SESSION_RETRY_PREFIX = "🔁"; +const SUBAGENT_STREAM_PREFIX = "🧩"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const TEMP_DIR = path.join(__dirname, "..", ".tmp"); @@ -401,7 +403,7 @@ async function ensureEventSubscription(directory: string): Promise { const chatId = chatIdInstance; try { - const streamedViaMessages = await finalizeAssistantResponse({ + await finalizeAssistantResponse({ responseStreaming: config.bot.responseStreaming, sessionId, messageId, @@ -425,16 +427,16 @@ async function ensureEventSubscription(directory: string): Promise { format, }); }, + deleteMessages: async (messageIds) => { + for (const msgId of messageIds) { + try { + await botApi.deleteMessage(chatId, msgId); + } catch (err) { + logger.warn(`[Bot] Failed to delete streamed message ${msgId}:`, err); + } + } + }, }); - - if (streamedViaMessages) { - logger.debug( - `[Bot] Final assistant message already streamed (session=${sessionId}, message=${messageId})`, - ); - foregroundSessionState.markIdle(sessionId); - await scheduledTaskRuntime.flushDeferredDeliveries(); - return; - } } catch (err) { logger.error("Failed to send message to Telegram:", err); // Stop processing events after critical error to prevent infinite loop @@ -461,7 +463,11 @@ async function ensureEventSubscription(directory: string): Promise { toolInfo.hasFileAttachment && (toolInfo.tool === "write" || toolInfo.tool === "edit" || toolInfo.tool === "apply_patch"); - if (config.bot.hideToolCallMessages || shouldIncludeToolInfoInFileCaption) { + if ( + config.bot.hideToolCallMessages || + shouldIncludeToolInfoInFileCaption || + toolInfo.tool === "task" + ) { return; } @@ -475,6 +481,32 @@ async function ensureEventSubscription(directory: string): Promise { } }); + summaryAggregator.setOnSubagent(async (sessionId, subagents) => { + if (!botInstance || !chatIdInstance) { + return; + } + + if (config.bot.hideToolCallMessages) { + return; + } + + const currentSession = getCurrentSession(); + if (!currentSession || currentSession.id !== sessionId) { + return; + } + + try { + const renderedCards = await renderSubagentCards(subagents); + if (!renderedCards) { + return; + } + + toolCallStreamer.replaceByPrefix(sessionId, SUBAGENT_STREAM_PREFIX, renderedCards); + } catch (err) { + logger.error("Failed to render subagent activity for Telegram:", err); + } + }); + summaryAggregator.setOnToolFile(async (fileInfo) => { if (!botInstance || !chatIdInstance) { logger.error("Bot or chat ID not available for sending file"); @@ -582,25 +614,45 @@ async function ensureEventSubscription(directory: string): Promise { responseStreaming: config.bot.responseStreaming, hideThinkingMessages: config.bot.hideThinkingMessages, }); + + // Refresh pinned message so it shows the latest in-memory context + // (accumulated from silent token updates). 1 API call per thinking event. + if (pinnedMessageManager.isInitialized()) { + await pinnedMessageManager.refresh(); + } }); - summaryAggregator.setOnTokens(async (tokens) => { + summaryAggregator.setOnTokens(async (tokens, isCompleted) => { if (!pinnedMessageManager.isInitialized()) { return; } try { - logger.debug(`[Bot] Received tokens: input=${tokens.input}, output=${tokens.output}`); + logger.debug( + `[Bot] Received tokens: input=${tokens.input}, output=${tokens.output}, completed=${isCompleted}`, + ); - // Update keyboardManager SYNCHRONOUSLY before any await - // This ensures keyboard has correct context when onComplete sends the reply const contextSize = tokens.input + tokens.cacheRead; const contextLimit = pinnedMessageManager.getContextLimit(); + + // Skip non-completed messages with zero context: a new assistant message + // starts with tokens={input:0, ...} which would overwrite valid context + // from the previous step. Only accept zeros from completed messages. + if (!isCompleted && contextSize === 0) { + logger.debug("[Bot] Skipping zero-token intermediate update"); + return; + } + + // Update both keyboard and pinned state in memory (keeps them in sync) if (contextLimit > 0) { keyboardManager.updateContext(contextSize, contextLimit); } + pinnedMessageManager.updateTokensSilent(tokens); - await pinnedMessageManager.onMessageComplete(tokens); + // Full pinned message update (API call) only on completed messages + if (isCompleted) { + await pinnedMessageManager.onMessageComplete(tokens); + } } catch (err) { logger.error("[Bot] Error updating pinned message with tokens:", err); } diff --git a/src/bot/streaming/response-streamer.ts b/src/bot/streaming/response-streamer.ts index 0dbe2d5..fe28f50 100644 --- a/src/bot/streaming/response-streamer.ts +++ b/src/bot/streaming/response-streamer.ts @@ -16,6 +16,11 @@ export interface StreamingMessagePayload { editOptions?: TelegramEditMessageOptions; } +export interface StreamCompleteResult { + streamed: boolean; + telegramMessageIds: number[]; +} + interface ResponseStreamerCompleteOptions { flushFinal?: boolean; } @@ -142,10 +147,12 @@ export class ResponseStreamer { messageId: string, payload?: StreamingMessagePayload, options?: ResponseStreamerCompleteOptions, - ): Promise { + ): Promise { + const notStreamed: StreamCompleteResult = { streamed: false, telegramMessageIds: [] }; + const state = this.states.get(buildStateKey(sessionId, messageId)); if (!state) { - return false; + return notStreamed; } if (payload) { @@ -163,13 +170,13 @@ export class ResponseStreamer { await this.cleanupBrokenStream(state, "complete_broken_stream"); this.cancelState(state); this.states.delete(state.key); - return false; + return notStreamed; } if (state.telegramMessageIds.length === 0) { this.cancelState(state); this.states.delete(state.key); - return false; + return notStreamed; } let synced = true; @@ -180,9 +187,10 @@ export class ResponseStreamer { } } + const messageIds = [...state.telegramMessageIds]; this.cancelState(state); this.states.delete(state.key); - return synced; + return { streamed: synced, telegramMessageIds: messageIds }; } clearMessage(sessionId: string, messageId: string, reason: string): void { diff --git a/src/bot/utils/finalize-assistant-response.ts b/src/bot/utils/finalize-assistant-response.ts index bb05588..0d3942e 100644 --- a/src/bot/utils/finalize-assistant-response.ts +++ b/src/bot/utils/finalize-assistant-response.ts @@ -1,5 +1,6 @@ import type { StreamingMessagePayload, ResponseStreamer } from "../streaming/response-streamer.js"; import type { TelegramTextFormat } from "./telegram-text.js"; +import { logger } from "../../utils/logger.js"; interface FinalizeAssistantResponseOptions { responseStreaming: boolean; @@ -17,6 +18,7 @@ interface FinalizeAssistantResponseOptions { options: { reply_markup: unknown } | undefined, format: TelegramTextFormat, ) => Promise; + deleteMessages: (messageIds: number[]) => Promise; } export async function finalizeAssistantResponse({ @@ -31,8 +33,9 @@ export async function finalizeAssistantResponse({ resolveFormat, getReplyKeyboard, sendText, + deleteMessages, }: FinalizeAssistantResponseOptions): Promise { - let streamedViaMessages = false; + let streamedMessageIds: number[] = []; if (responseStreaming) { const preparedStreamPayload = prepareStreamingPayload(messageText); @@ -41,17 +44,27 @@ export async function finalizeAssistantResponse({ preparedStreamPayload.editOptions = undefined; } - streamedViaMessages = await responseStreamer.complete( + const result = await responseStreamer.complete( sessionId, messageId, preparedStreamPayload ?? undefined, ); + + if (result.streamed) { + streamedMessageIds = result.telegramMessageIds; + } } await flushPendingServiceMessages(); - if (streamedViaMessages) { - return true; + // When the response was streamed, delete the streamed messages and re-send + // via the non-streamed path so the reply keyboard carries the latest context. + if (streamedMessageIds.length > 0) { + try { + await deleteMessages(streamedMessageIds); + } catch (err) { + logger.warn("[FinalizeResponse] Failed to delete streamed messages, sending with keyboard anyway:", err); + } } const parts = formatSummary(messageText); diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 966f156..a60f6f7 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -292,6 +292,18 @@ export const de: I18nDictionary = { "pinned.line.model": "Modell: {model}", "pinned.line.context": "Kontext: {used} / {limit} ({percent}%)", "pinned.line.cost": "Kosten: {cost} ausgegeben", + "subagent.header": "Subagent {agent}: {description}", + "subagent.line.status": "Status: {status}", + "subagent.line.task": "Aufgabe: {task}", + "subagent.line.agent": "Agent: {agent}", + "subagent.working": "Arbeitet...", + "subagent.working_with_details": "Arbeitet: {details}", + "subagent.completed": "Abgeschlossen", + "subagent.failed": "Aufgabe fehlgeschlagen", + "subagent.status.pending": "ausstehend", + "subagent.status.running": "laeuft", + "subagent.status.completed": "abgeschlossen", + "subagent.status.error": "Fehler", "pinned.files.title": "Dateien ({count}):", "pinned.files.item": " {path}{diff}", "pinned.files.more": " ... und {count} mehr", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 0079227..81f6c4c 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -280,6 +280,18 @@ export const en = { "pinned.line.model": "Model: {model}", "pinned.line.context": "Context: {used} / {limit} ({percent}%)", "pinned.line.cost": "Cost: {cost} spent", + "subagent.header": "Subagent {agent}: {description}", + "subagent.line.status": "Status: {status}", + "subagent.line.task": "Task: {task}", + "subagent.line.agent": "Agent: {agent}", + "subagent.working": "Working...", + "subagent.working_with_details": "Working: {details}", + "subagent.completed": "Completed", + "subagent.failed": "Task failed", + "subagent.status.pending": "pending", + "subagent.status.running": "running", + "subagent.status.completed": "completed", + "subagent.status.error": "error", "pinned.files.title": "Files ({count}):", "pinned.files.item": " {path}{diff}", "pinned.files.more": " ... and {count} more", diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 2a32821..eb1a8c5 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -290,6 +290,18 @@ export const es: I18nDictionary = { "pinned.line.model": "Modelo: {model}", "pinned.line.context": "Contexto: {used} / {limit} ({percent}%)", "pinned.line.cost": "Costo: {cost} gastado", + "subagent.header": "Subagente {agent}: {description}", + "subagent.line.status": "Estado: {status}", + "subagent.line.task": "Tarea: {task}", + "subagent.line.agent": "Agente: {agent}", + "subagent.working": "Trabajando...", + "subagent.working_with_details": "Trabajando: {details}", + "subagent.completed": "Completada", + "subagent.failed": "Error de tarea", + "subagent.status.pending": "pendiente", + "subagent.status.running": "en ejecucion", + "subagent.status.completed": "completado", + "subagent.status.error": "error", "pinned.files.title": "Archivos ({count}):", "pinned.files.item": " {path}{diff}", "pinned.files.more": " ... y {count} más", diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts index 7dbad9a..e97b4c2 100644 --- a/src/i18n/fr.ts +++ b/src/i18n/fr.ts @@ -292,6 +292,18 @@ export const fr: I18nDictionary = { "pinned.line.model": "Modèle : {model}", "pinned.line.context": "Contexte : {used} / {limit} ({percent}%)", "pinned.line.cost": "Coût : {cost} dépensé", + "subagent.header": "Sous-agent {agent} : {description}", + "subagent.line.status": "Statut : {status}", + "subagent.line.task": "Tache : {task}", + "subagent.line.agent": "Agent : {agent}", + "subagent.working": "En cours...", + "subagent.working_with_details": "En cours : {details}", + "subagent.completed": "Terminee", + "subagent.failed": "Echec de la tache", + "subagent.status.pending": "en attente", + "subagent.status.running": "en cours", + "subagent.status.completed": "termine", + "subagent.status.error": "erreur", "pinned.files.title": "Fichiers ({count}) :", "pinned.files.item": " {path}{diff}", "pinned.files.more": " ... et encore {count}", diff --git a/src/i18n/ru.ts b/src/i18n/ru.ts index 58e676c..52eaa57 100644 --- a/src/i18n/ru.ts +++ b/src/i18n/ru.ts @@ -281,6 +281,18 @@ export const ru: I18nDictionary = { "pinned.line.model": "Модель: {model}", "pinned.line.context": "Контекст: {used} / {limit} ({percent}%)", "pinned.line.cost": "Стоимость: {cost} потрачено", + "subagent.header": "Сабагент {agent}: {description}", + "subagent.line.status": "Статус: {status}", + "subagent.line.task": "Задача: {task}", + "subagent.line.agent": "Агент: {agent}", + "subagent.working": "В работе...", + "subagent.working_with_details": "В работе: {details}", + "subagent.completed": "Завершена", + "subagent.failed": "Ошибка задачи", + "subagent.status.pending": "ожидание", + "subagent.status.running": "в работе", + "subagent.status.completed": "завершен", + "subagent.status.error": "ошибка", "pinned.files.title": "Файлы ({count}):", "pinned.files.item": " {path}{diff}", "pinned.files.more": " ... и еще {count}", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index b0296cc..b9fa216 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -254,6 +254,18 @@ export const zh: I18nDictionary = { "pinned.line.model": "模型: {model}", "pinned.line.context": "上下文: {used} / {limit} ({percent}%)", "pinned.line.cost": "费用: {cost}", + "subagent.header": "子代理 {agent}: {description}", + "subagent.line.status": "状态: {status}", + "subagent.line.task": "任务: {task}", + "subagent.line.agent": "代理: {agent}", + "subagent.working": "执行中...", + "subagent.working_with_details": "执行中: {details}", + "subagent.completed": "已完成", + "subagent.failed": "任务失败", + "subagent.status.pending": "等待中", + "subagent.status.running": "运行中", + "subagent.status.completed": "已完成", + "subagent.status.error": "错误", "pinned.files.title": "文件({count}):", "pinned.files.item": " {path}{diff}", "pinned.files.more": " ... 还有 {count} 个", diff --git a/src/model/context-limit.ts b/src/model/context-limit.ts new file mode 100644 index 0000000..fcc1ed5 --- /dev/null +++ b/src/model/context-limit.ts @@ -0,0 +1,74 @@ +import { opencodeClient } from "../opencode/client.js"; +import { logger } from "../utils/logger.js"; +import { DEFAULT_CONTEXT_LIMIT } from "../pinned/format.js"; + +const PROVIDER_CACHE_TTL_MS = 10 * 60 * 1000; + +const contextLimitCache = new Map(); + +let providersCacheExpiresAt = 0; +let providersFetchInFlight: Promise | null = null; + +function getModelKey(providerID: string, modelID: string): string { + return `${providerID}/${modelID}`; +} + +async function refreshContextLimitCache(): Promise { + if (Date.now() < providersCacheExpiresAt) { + return; + } + + if (providersFetchInFlight) { + await providersFetchInFlight; + return; + } + + providersFetchInFlight = (async () => { + try { + const { data, error } = await opencodeClient.config.providers(); + + if (error || !data) { + logger.warn("[ModelContextLimit] Failed to fetch providers:", error); + return; + } + + contextLimitCache.clear(); + for (const provider of data.providers) { + for (const [modelID, model] of Object.entries(provider.models)) { + if (model?.limit?.context) { + contextLimitCache.set(getModelKey(provider.id, modelID), model.limit.context); + } + } + } + + providersCacheExpiresAt = Date.now() + PROVIDER_CACHE_TTL_MS; + logger.debug( + `[ModelContextLimit] Cached limits for ${contextLimitCache.size} provider/model pairs`, + ); + } catch (error) { + logger.warn("[ModelContextLimit] Error refreshing providers cache:", error); + } finally { + providersFetchInFlight = null; + } + })(); + + await providersFetchInFlight; +} + +export async function getModelContextLimit( + providerID?: string | null, + modelID?: string | null, +): Promise { + if (!providerID || !modelID) { + return DEFAULT_CONTEXT_LIMIT; + } + + const cacheKey = getModelKey(providerID, modelID); + const cachedLimit = contextLimitCache.get(cacheKey); + if (cachedLimit) { + return cachedLimit; + } + + await refreshContextLimitCache(); + return contextLimitCache.get(cacheKey) ?? DEFAULT_CONTEXT_LIMIT; +} diff --git a/src/pinned/format.ts b/src/pinned/format.ts new file mode 100644 index 0000000..dc0aa9c --- /dev/null +++ b/src/pinned/format.ts @@ -0,0 +1,41 @@ +import { t } from "../i18n/index.js"; + +export const DEFAULT_CONTEXT_LIMIT = 200000; + +export function formatTokenCount(count: number): string { + if (count >= 1000000) { + return `${(count / 1000000).toFixed(1)}M`; + } + + if (count >= 1000) { + return `${Math.round(count / 1000)}K`; + } + + return count.toString(); +} + +export function formatModelDisplayName( + providerID?: string | null, + modelID?: string | null, +): string { + if (providerID && modelID) { + return `${providerID}/${modelID}`; + } + + return t("pinned.unknown"); +} + +export function formatContextLine(tokensUsed: number, tokensLimit?: number | null): string { + const safeLimit = typeof tokensLimit === "number" && tokensLimit > 0 ? tokensLimit : null; + const percentage = safeLimit ? Math.round((tokensUsed / safeLimit) * 100) : 0; + + return t("pinned.line.context", { + used: formatTokenCount(tokensUsed), + limit: safeLimit ? formatTokenCount(safeLimit) : t("pinned.unknown"), + percent: percentage, + }); +} + +export function formatCostLine(cost: number): string { + return t("pinned.line.cost", { cost: `$${cost.toFixed(2)}` }); +} diff --git a/src/pinned/manager.ts b/src/pinned/manager.ts index 2566e6b..bdd846f 100644 --- a/src/pinned/manager.ts +++ b/src/pinned/manager.ts @@ -9,8 +9,15 @@ import { clearPinnedMessageId, } from "../settings/manager.js"; import { getStoredModel } from "../model/manager.js"; +import { getModelContextLimit } from "../model/context-limit.js"; import type { FileChange, PinnedMessageState, TokensInfo } from "./types.js"; import { t } from "../i18n/index.js"; +import { + DEFAULT_CONTEXT_LIMIT, + formatContextLine, + formatCostLine, + formatModelDisplayName, +} from "./format.js"; class PinnedMessageManager { private api: Api | null = null; @@ -200,6 +207,26 @@ class PinnedMessageManager { await this.updatePinnedMessage(); } + /** + * Update tokens in memory without triggering an API call. + * Used for intermediate (non-completed) message.updated events + * to keep pinned state in sync with keyboardManager. + */ + updateTokensSilent(tokens: TokensInfo): void { + this.state.tokensUsed = tokens.input + tokens.cacheRead; + logger.debug( + `[PinnedManager] Tokens updated (silent): ${this.state.tokensUsed}/${this.state.tokensLimit}`, + ); + } + + /** + * Refresh the pinned message with current in-memory state. + * Used at thinking time to push accumulated silent updates to Telegram. + */ + async refresh(): Promise { + await this.updatePinnedMessage(); + } + /** * Called when cost info is received from SSE events */ @@ -218,6 +245,13 @@ class PinnedMessageManager { setOnKeyboardUpdate(callback: (tokensUsed: number, tokensLimit: number) => void): void { this.onKeyboardUpdateCallback = callback; logger.debug("[PinnedManager] Keyboard update callback registered"); + + // Fire immediately with current state to fix race condition: + // onSessionChange may have already run before this callback was registered. + const limit = this.state.tokensLimit > 0 ? this.state.tokensLimit : this.contextLimit || 0; + if (limit > 0) { + callback(this.state.tokensUsed, limit); + } } /** @@ -519,41 +553,12 @@ class PinnedMessageManager { private async fetchContextLimit(): Promise { try { const model = getStoredModel(); - if (!model.providerID || !model.modelID) { - logger.warn("[PinnedManager] No model configured, using default limit"); - this.contextLimit = 200000; - this.state.tokensLimit = this.contextLimit; - return; - } - - const { data: providersData, error } = await opencodeClient.config.providers(); - - if (error || !providersData) { - logger.warn("[PinnedManager] Failed to fetch providers, using default limit"); - this.contextLimit = 200000; - this.state.tokensLimit = this.contextLimit; - return; - } - - // Find the model in providers - for (const provider of providersData.providers) { - if (provider.id === model.providerID) { - const modelInfo = provider.models[model.modelID]; - if (modelInfo?.limit?.context) { - this.contextLimit = modelInfo.limit.context; - this.state.tokensLimit = this.contextLimit; - logger.debug(`[PinnedManager] Context limit: ${this.contextLimit}`); - return; - } - } - } - - logger.warn("[PinnedManager] Model not found in providers, using default limit"); - this.contextLimit = 200000; + this.contextLimit = await getModelContextLimit(model.providerID, model.modelID); this.state.tokensLimit = this.contextLimit; + logger.debug(`[PinnedManager] Context limit: ${this.contextLimit}`); } catch (err) { logger.error("[PinnedManager] Error fetching context limit:", err); - this.contextLimit = 200000; + this.contextLimit = DEFAULT_CONTEXT_LIMIT; this.state.tokensLimit = this.contextLimit; } } @@ -562,34 +567,18 @@ class PinnedMessageManager { * Format the pinned message text */ private formatMessage(): string { - const percentage = - this.state.tokensLimit > 0 - ? Math.round((this.state.tokensUsed / this.state.tokensLimit) * 100) - : 0; - - const tokensFormatted = this.formatTokenCount(this.state.tokensUsed); - const limitFormatted = this.formatTokenCount(this.state.tokensLimit); - - // Get current model info const currentModel = getStoredModel(); - const modelName = - currentModel.providerID && currentModel.modelID - ? `${currentModel.providerID}/${currentModel.modelID}` - : t("pinned.unknown"); + const modelName = formatModelDisplayName(currentModel.providerID, currentModel.modelID); const lines = [ `${this.state.sessionTitle}`, t("pinned.line.project", { project: this.state.projectName }), t("pinned.line.model", { model: modelName }), - t("pinned.line.context", { - used: tokensFormatted, - limit: limitFormatted, - percent: percentage, - }), + formatContextLine(this.state.tokensUsed, this.state.tokensLimit), ]; if (this.state.cost !== undefined && this.state.cost !== null) { - lines.push(t("pinned.line.cost", { cost: `$${this.state.cost.toFixed(2)}` })); + lines.push(formatCostLine(this.state.cost)); } if (this.state.changedFiles.length > 0) { @@ -616,19 +605,6 @@ class PinnedMessageManager { return lines.join("\n"); } - - /** - * Format token count (e.g., 150000 -> "150K") - */ - private formatTokenCount(count: number): string { - if (count >= 1000000) { - return `${(count / 1000000).toFixed(1)}M`; - } else if (count >= 1000) { - return `${Math.round(count / 1000)}K`; - } - return count.toString(); - } - /** * Create and pin a new status message */ diff --git a/src/summary/aggregator.ts b/src/summary/aggregator.ts index 8f3debb..aaaa116 100644 --- a/src/summary/aggregator.ts +++ b/src/summary/aggregator.ts @@ -72,10 +72,34 @@ export interface TokensInfo { cacheWrite: number; } -type TokensCallback = (tokens: TokensInfo) => void; +type TokensCallback = (tokens: TokensInfo, isCompleted: boolean) => void; type CostCallback = (cost: number) => void; +export type SubagentStatus = "pending" | "running" | "completed" | "error"; + +export interface SubagentInfo { + cardId: string; + sessionId: string | null; + parentSessionId: string; + agent: string; + description: string; + prompt: string; + command?: string; + status: SubagentStatus; + providerID?: string; + modelID?: string; + tokens: TokensInfo; + cost: number; + currentTool?: string; + currentToolInput?: { [key: string]: unknown }; + currentToolTitle?: string; + terminalMessage?: string; + updatedAt: number; +} + +type SubagentCallback = (sessionId: string, subagents: SubagentInfo[]) => void; + type SessionCompactedCallback = (sessionId: string, directory: string) => void; type SessionErrorCallback = (sessionId: string, message: string) => void; @@ -108,6 +132,13 @@ interface TextMessageState { optimisticUpdateCount: number; } +interface SubagentState extends SubagentInfo { + hasSubtaskMetadata: boolean; + hasTaskToolMetadata: boolean; + hasSessionTitleMetadata: boolean; + createdAt: number; +} + function extractFirstUpdatedFileFromTitle(title: string): string { for (const rawLine of title.split("\n")) { const line = rawLine.trim(); @@ -151,6 +182,7 @@ class SummaryAggregator { private onThinkingCallback: ThinkingCallback | null = null; private onTokensCallback: TokensCallback | null = null; private onCostCallback: CostCallback | null = null; + private onSubagentCallback: SubagentCallback | null = null; private onSessionCompactedCallback: SessionCompactedCallback | null = null; private onSessionErrorCallback: SessionErrorCallback | null = null; private onSessionRetryCallback: SessionRetryCallback | null = null; @@ -166,6 +198,13 @@ class SummaryAggregator { private typingTimer: ReturnType | null = null; private typingIndicatorEnabled = true; private partHashes: Map> = new Map(); + private trackedSessionParents: Map = new Map(); + private subagentStates: Map = new Map(); + private subagentOrder: string[] = []; + private subagentCardIdBySessionId: Map = new Map(); + private pendingSubagentCardIdsByParent: Map = new Map(); + private pendingChildSessionIdsByParent: Map = new Map(); + private fallbackSubagentCardIdsByParent: Map = new Map(); setBotAndChatId(bot: Bot, chatId: number): void { this.bot = bot; @@ -208,6 +247,10 @@ class SummaryAggregator { this.onCostCallback = callback; } + setOnSubagent(callback: SubagentCallback): void { + this.onSubagentCallback = callback; + } + setOnSessionCompacted(callback: SessionCompactedCallback): void { this.onSessionCompactedCallback = callback; } @@ -297,6 +340,10 @@ class SummaryAggregator { } switch (event.type) { + case "session.created": + case "session.updated": + this.handleSessionCreatedOrUpdated(event); + break; case "message.updated": this.handleMessageUpdated(event); break; @@ -343,6 +390,7 @@ class SummaryAggregator { if (this.currentSessionId !== sessionId) { this.clear(); this.currentSessionId = sessionId; + this.trackedSessionParents.set(sessionId, null); } } @@ -355,6 +403,13 @@ class SummaryAggregator { this.knownTextPartIds.clear(); this.processedToolStates.clear(); this.thinkingFiredForMessages.clear(); + this.trackedSessionParents.clear(); + this.subagentStates.clear(); + this.subagentOrder = []; + this.subagentCardIdBySessionId.clear(); + this.pendingSubagentCardIdsByParent.clear(); + this.pendingChildSessionIdsByParent.clear(); + this.fallbackSubagentCardIdsByParent.clear(); this.messageCount = 0; this.lastUpdated = 0; @@ -367,6 +422,482 @@ class SummaryAggregator { } } + private isTrackedChildSession(sessionId: string): boolean { + return this.trackedSessionParents.has(sessionId) && sessionId !== this.currentSessionId; + } + + private getQueue(map: Map, parentSessionId: string): string[] { + const existing = map.get(parentSessionId); + if (existing) { + return existing; + } + + const queue: string[] = []; + map.set(parentSessionId, queue); + return queue; + } + + private dequeue(map: Map, parentSessionId: string): string | undefined { + const queue = map.get(parentSessionId); + if (!queue || queue.length === 0) { + return undefined; + } + + const value = queue.shift(); + if (queue.length === 0) { + map.delete(parentSessionId); + } + + return value; + } + + private removeFromQueue( + map: Map, + parentSessionId: string, + value: string, + ): void { + const queue = map.get(parentSessionId); + if (!queue) { + return; + } + + const index = queue.indexOf(value); + if (index >= 0) { + queue.splice(index, 1); + } + + if (queue.length === 0) { + map.delete(parentSessionId); + } + } + + private emitSubagentState(): void { + if (!this.currentSessionId || !this.onSubagentCallback || this.subagentOrder.length === 0) { + return; + } + + const subagents = this.subagentOrder + .map((cardId) => this.subagentStates.get(cardId)) + .filter((state): state is SubagentState => Boolean(state)) + .map((state) => ({ + cardId: state.cardId, + sessionId: state.sessionId, + parentSessionId: state.parentSessionId, + agent: state.agent, + description: state.description, + prompt: state.prompt, + command: state.command, + status: state.status, + providerID: state.providerID, + modelID: state.modelID, + tokens: { ...state.tokens }, + cost: state.cost, + currentTool: state.currentTool, + currentToolInput: state.currentToolInput ? { ...state.currentToolInput } : undefined, + currentToolTitle: state.currentToolTitle, + terminalMessage: state.terminalMessage, + updatedAt: state.updatedAt, + })); + + this.onSubagentCallback(this.currentSessionId, subagents); + } + + private createSubagentState( + parentSessionId: string, + sessionId: string | null, + cardId: string = `subagent-${parentSessionId}-${Date.now()}-${this.subagentOrder.length}`, + ): SubagentState { + const state: SubagentState = { + cardId, + sessionId, + parentSessionId, + agent: "", + description: "", + prompt: "", + status: "pending", + tokens: { + input: 0, + output: 0, + reasoning: 0, + cacheRead: 0, + cacheWrite: 0, + }, + cost: 0, + terminalMessage: undefined, + updatedAt: Date.now(), + hasSubtaskMetadata: false, + hasTaskToolMetadata: false, + hasSessionTitleMetadata: false, + createdAt: Date.now(), + }; + + this.subagentStates.set(cardId, state); + this.subagentOrder.push(cardId); + if (sessionId) { + this.subagentCardIdBySessionId.set(sessionId, cardId); + } + return state; + } + + private enrichSubagentFromSubtask( + state: SubagentState, + details: { agent: string; description: string; prompt: string; command?: string }, + ): void { + state.agent = details.agent || state.agent; + state.description = details.description || details.prompt || state.description; + state.prompt = details.prompt; + state.command = details.command; + state.hasSubtaskMetadata = true; + state.updatedAt = Date.now(); + } + + private enrichSubagentFromTaskTool( + state: SubagentState, + details: { + agent?: string; + description?: string; + prompt?: string; + command?: string; + }, + ): void { + const nextDescription = details.description?.trim() || details.prompt?.trim(); + if (details.agent?.trim()) { + state.agent = details.agent.trim(); + } + if (nextDescription) { + state.description = nextDescription; + } + if (details.prompt?.trim()) { + state.prompt = details.prompt.trim(); + } + if (details.command?.trim()) { + state.command = details.command.trim(); + } + state.hasTaskToolMetadata = true; + state.updatedAt = Date.now(); + } + + private enrichSubagentFromSessionTitle(state: SubagentState, title?: string): void { + const trimmedTitle = title?.trim(); + if (!trimmedTitle) { + return; + } + + const match = trimmedTitle.match(/^(.*?)(?:\s+\(@([^\s)]+)\s+subagent\))?$/i); + const rawDescription = match?.[1]?.trim() || trimmedTitle; + const rawAgent = match?.[2]?.trim(); + + if (rawDescription) { + state.description = rawDescription; + } + + if (rawAgent) { + state.agent = rawAgent.replace(/^@/, ""); + } + + state.hasSessionTitleMetadata = true; + state.updatedAt = Date.now(); + } + + private attachSessionToSubagent(cardId: string, sessionId: string): void { + const state = this.subagentStates.get(cardId); + if (!state) { + return; + } + + state.sessionId = sessionId; + state.updatedAt = Date.now(); + this.subagentCardIdBySessionId.set(sessionId, cardId); + this.removeFromQueue(this.pendingSubagentCardIdsByParent, state.parentSessionId, cardId); + } + + private findPendingSubagentWithoutSession(): SubagentState | null { + for (const cardId of this.subagentOrder) { + const state = this.subagentStates.get(cardId); + if (state && !state.sessionId) { + return state; + } + } + + return null; + } + + private attachUnknownSessionToPendingSubagent(sessionId: string): boolean { + const pendingState = this.findPendingSubagentWithoutSession(); + if (!pendingState) { + return false; + } + + this.trackedSessionParents.set(sessionId, pendingState.parentSessionId); + this.attachSessionToSubagent(pendingState.cardId, sessionId); + this.removeFromQueue( + this.pendingChildSessionIdsByParent, + pendingState.parentSessionId, + sessionId, + ); + this.emitSubagentState(); + return true; + } + + private findNextSubagentForTaskTool(parentSessionId: string): SubagentState | null { + for (const cardId of this.subagentOrder) { + const state = this.subagentStates.get(cardId); + if (state && state.parentSessionId === parentSessionId && !state.hasTaskToolMetadata) { + return state; + } + } + + return null; + } + + private updateSubagentFromTaskTool( + parentSessionId: string, + input?: { [key: string]: unknown }, + ): void { + const subagent = this.findNextSubagentForTaskTool(parentSessionId); + if (!subagent || !input) { + return; + } + + const description = typeof input.description === "string" ? input.description : undefined; + const prompt = typeof input.prompt === "string" ? input.prompt : undefined; + const agent = typeof input.subagent_type === "string" ? input.subagent_type : undefined; + const command = typeof input.command === "string" ? input.command : undefined; + + if (!description && !prompt && !agent && !command) { + return; + } + + this.enrichSubagentFromTaskTool(subagent, { agent, description, prompt, command }); + this.emitSubagentState(); + } + + private getOrCreateSubagentForSession(sessionId: string): SubagentState { + const existingCardId = this.subagentCardIdBySessionId.get(sessionId); + if (existingCardId) { + return this.subagentStates.get(existingCardId)!; + } + + const parentSessionId = + this.trackedSessionParents.get(sessionId) ?? this.currentSessionId ?? sessionId; + this.removeFromQueue(this.pendingChildSessionIdsByParent, parentSessionId, sessionId); + const state = this.createSubagentState(parentSessionId, sessionId); + this.getQueue(this.fallbackSubagentCardIdsByParent, parentSessionId).push(state.cardId); + return state; + } + + private registerSubtaskPart( + parentSessionId: string, + partId: string, + agent: string, + description: string, + prompt: string, + command?: string, + ): void { + const fallbackCardId = this.dequeue(this.fallbackSubagentCardIdsByParent, parentSessionId); + if (fallbackCardId) { + const fallbackState = this.subagentStates.get(fallbackCardId); + if (fallbackState) { + this.enrichSubagentFromSubtask(fallbackState, { agent, description, prompt, command }); + this.emitSubagentState(); + return; + } + } + + const state = this.createSubagentState( + parentSessionId, + null, + `subtask-${parentSessionId}-${partId}`, + ); + this.enrichSubagentFromSubtask(state, { agent, description, prompt, command }); + + const pendingChildSessionId = this.dequeue( + this.pendingChildSessionIdsByParent, + parentSessionId, + ); + if (pendingChildSessionId) { + this.attachSessionToSubagent(state.cardId, pendingChildSessionId); + } else { + this.getQueue(this.pendingSubagentCardIdsByParent, parentSessionId).push(state.cardId); + } + + this.emitSubagentState(); + } + + private trackChildSession(sessionId: string, parentSessionId: string): void { + this.trackedSessionParents.set(sessionId, parentSessionId); + + const pendingCardId = this.dequeue(this.pendingSubagentCardIdsByParent, parentSessionId); + if (pendingCardId) { + this.attachSessionToSubagent(pendingCardId, sessionId); + this.emitSubagentState(); + return; + } + + this.getQueue(this.pendingChildSessionIdsByParent, parentSessionId).push(sessionId); + } + + private handleSessionCreatedOrUpdated( + event: Event & { + type: "session.created" | "session.updated"; + }, + ): void { + if (!this.currentSessionId) { + return; + } + + const { info } = event.properties; + if (!info.parentID) { + return; + } + + if (!this.trackedSessionParents.has(info.parentID)) { + return; + } + + if (info.id === this.currentSessionId) { + return; + } + + if (!this.trackedSessionParents.has(info.id)) { + this.trackChildSession(info.id, info.parentID); + } + + const subagent = this.getOrCreateSubagentForSession(info.id); + this.enrichSubagentFromSessionTitle(subagent, info.title); + this.emitSubagentState(); + } + + private updateSubagentFromAssistantMessage(info: { + sessionID: string; + providerID?: string; + modelID?: string; + agent?: string; + tokens?: { + input: number; + output: number; + reasoning: number; + cache?: { read: number; write: number }; + }; + cost?: number; + }): void { + const subagent = this.getOrCreateSubagentForSession(info.sessionID); + if (info.agent) { + subagent.agent = info.agent; + } + if (info.providerID) { + subagent.providerID = info.providerID; + } + if (info.modelID) { + subagent.modelID = info.modelID; + } + if (info.tokens) { + subagent.tokens = { + input: info.tokens.input, + output: info.tokens.output, + reasoning: info.tokens.reasoning, + cacheRead: info.tokens.cache?.read || 0, + cacheWrite: info.tokens.cache?.write || 0, + }; + } + if (typeof info.cost === "number") { + subagent.cost = info.cost; + } + subagent.updatedAt = Date.now(); + this.emitSubagentState(); + } + + private updateSubagentToolState( + sessionId: string, + state: ToolState, + tool: string, + input?: { [key: string]: unknown }, + title?: string, + ): void { + const subagent = this.getOrCreateSubagentForSession(sessionId); + const status = "status" in state ? state.status : undefined; + + if (status === "running") { + subagent.status = "running"; + subagent.terminalMessage = undefined; + } + + if (status === "pending" && subagent.status === "pending") { + subagent.status = "pending"; + subagent.terminalMessage = undefined; + } + + subagent.currentTool = tool; + subagent.currentToolInput = input ? { ...input } : undefined; + subagent.currentToolTitle = title; + subagent.updatedAt = Date.now(); + this.emitSubagentState(); + } + + private updateSubagentStepStart(sessionId: string, snapshot?: string): void { + const subagent = this.getOrCreateSubagentForSession(sessionId); + subagent.status = "running"; + subagent.terminalMessage = undefined; + subagent.currentTool = undefined; + subagent.currentToolInput = undefined; + subagent.currentToolTitle = snapshot?.trim() || subagent.currentToolTitle; + subagent.updatedAt = Date.now(); + this.emitSubagentState(); + } + + private updateSubagentStepFinish( + sessionId: string, + tokens: { + input: number; + output: number; + reasoning: number; + cache: { read: number; write: number }; + }, + cost: number, + snapshot?: string, + ): void { + const subagent = this.getOrCreateSubagentForSession(sessionId); + subagent.status = "running"; + subagent.terminalMessage = undefined; + subagent.tokens = { + input: tokens.input, + output: tokens.output, + reasoning: tokens.reasoning, + cacheRead: tokens.cache.read, + cacheWrite: tokens.cache.write, + }; + subagent.cost += cost; + if (snapshot?.trim()) { + subagent.currentToolTitle = snapshot.trim(); + } + subagent.updatedAt = Date.now(); + this.emitSubagentState(); + } + + private setSubagentTerminalStatus( + sessionId: string, + status: Extract, + terminalMessage?: string, + ): void { + const cardId = this.subagentCardIdBySessionId.get(sessionId); + if (!cardId) { + return; + } + + const subagent = this.subagentStates.get(cardId); + if (!subagent) { + return; + } + + subagent.status = status; + subagent.currentTool = undefined; + subagent.currentToolInput = undefined; + subagent.currentToolTitle = undefined; + subagent.terminalMessage = terminalMessage?.trim() || undefined; + subagent.updatedAt = Date.now(); + this.emitSubagentState(); + } + private handleMessageUpdated( event: Event & { type: "message.updated"; @@ -374,6 +905,34 @@ class SummaryAggregator { ): void { const { info } = event.properties; + if ( + info.sessionID !== this.currentSessionId && + !this.trackedSessionParents.has(info.sessionID) && + info.role === "assistant" + ) { + this.attachUnknownSessionToPendingSubagent(info.sessionID); + } + + if (this.isTrackedChildSession(info.sessionID)) { + if (info.role === "assistant") { + const assistantInfo = info as { + sessionID: string; + providerID?: string; + modelID?: string; + agent?: string; + tokens?: { + input: number; + output: number; + reasoning: number; + cache: { read: number; write: number }; + }; + cost?: number; + }; + this.updateSubagentFromAssistantMessage(assistantInfo); + } + return; + } + if (info.sessionID !== this.currentSessionId) { return; } @@ -404,6 +963,33 @@ class SummaryAggregator { this.emitPartialText(info.sessionID, messageID, messageText); } + // Extract and report tokens for EVERY message.updated with token data + // (both intermediate and completed). This keeps keyboard context in sync. + const assistantInfo = info as { + tokens?: { + input: number; + output: number; + reasoning: number; + cache: { read: number; write: number }; + }; + cost?: number; + }; + + if (this.onTokensCallback && assistantInfo.tokens) { + const tokens: TokensInfo = { + input: assistantInfo.tokens.input, + output: assistantInfo.tokens.output, + reasoning: assistantInfo.tokens.reasoning, + cacheRead: assistantInfo.tokens.cache?.read || 0, + cacheWrite: assistantInfo.tokens.cache?.write || 0, + }; + logger.debug( + `[Aggregator] Tokens: input=${tokens.input}, output=${tokens.output}, reasoning=${tokens.reasoning}, cacheRead=${tokens.cacheRead}, cacheWrite=${tokens.cacheWrite}, completed=${isCompleted}`, + ); + // Call synchronously so keyboardManager is updated before onComplete sends the reply + this.onTokensCallback(tokens, isCompleted); + } + if (isCompleted) { const finalText = messageText; @@ -411,32 +997,6 @@ class SummaryAggregator { `[Aggregator] Message part completed: messageId=${messageID}, textLength=${finalText.length}, totalParts=${textState.orderedPartIds.length}, session=${this.currentSessionId}`, ); - // Extract and report tokens BEFORE onComplete so keyboard context is updated - const assistantInfo = info as { - tokens?: { - input: number; - output: number; - reasoning: number; - cache: { read: number; write: number }; - }; - cost?: number; - }; - - if (this.onTokensCallback && assistantInfo.tokens) { - const tokens: TokensInfo = { - input: assistantInfo.tokens.input, - output: assistantInfo.tokens.output, - reasoning: assistantInfo.tokens.reasoning, - cacheRead: assistantInfo.tokens.cache?.read || 0, - cacheWrite: assistantInfo.tokens.cache?.write || 0, - }; - logger.debug( - `[Aggregator] Tokens: input=${tokens.input}, output=${tokens.output}, reasoning=${tokens.reasoning}`, - ); - // Call synchronously so keyboardManager is updated before onComplete sends the reply - this.onTokensCallback(tokens); - } - // Extract and report cost if (this.onCostCallback && assistantInfo.cost !== undefined) { logger.debug(`[Aggregator] Cost: $${assistantInfo.cost.toFixed(2)}`); @@ -473,7 +1033,51 @@ class SummaryAggregator { ): void { const { part } = event.properties; - if (part.sessionID !== this.currentSessionId) { + if ( + part.sessionID !== this.currentSessionId && + !this.trackedSessionParents.has(part.sessionID) && + part.type !== "subtask" + ) { + this.attachUnknownSessionToPendingSubagent(part.sessionID); + } + + const isCurrentRootSession = part.sessionID === this.currentSessionId; + const isTrackedChildSession = this.isTrackedChildSession(part.sessionID); + + if (!isCurrentRootSession && !isTrackedChildSession) { + return; + } + + if (part.type === "subtask") { + this.registerSubtaskPart( + part.sessionID, + part.id, + part.agent, + part.description, + part.prompt, + part.command, + ); + this.lastUpdated = Date.now(); + return; + } + + if (isTrackedChildSession) { + if (part.type === "tool") { + const state = part.state; + const input = "input" in state ? (state.input as { [key: string]: unknown }) : undefined; + const title = "title" in state ? state.title : undefined; + this.updateSubagentToolState(part.sessionID, state, part.tool, input, title); + } + + if (part.type === "step-start") { + this.updateSubagentStepStart(part.sessionID, part.snapshot); + } + + if (part.type === "step-finish") { + this.updateSubagentStepFinish(part.sessionID, part.tokens, part.cost, part.snapshot); + } + + this.lastUpdated = Date.now(); return; } @@ -536,6 +1140,10 @@ class SummaryAggregator { const input = "input" in state ? (state.input as { [key: string]: unknown }) : undefined; const title = "title" in state ? state.title : undefined; + if (part.tool === "task") { + this.updateSubagentFromTaskTool(part.sessionID, input); + } + logger.debug( `[Aggregator] Tool event: callID=${part.callID}, tool=${part.tool}, status=${"status" in state ? state.status : "unknown"}`, ); @@ -937,6 +1545,12 @@ class SummaryAggregator { ): void { const { sessionID } = event.properties; + if (this.isTrackedChildSession(sessionID)) { + logger.info(`[Aggregator] Subagent session became idle: ${sessionID}`); + this.setSubagentTerminalStatus(sessionID, "completed"); + return; + } + if (sessionID !== this.currentSessionId) { return; } @@ -986,12 +1600,18 @@ class SummaryAggregator { }; }; - if (sessionID !== this.currentSessionId) { + const message = + error?.data?.message || error?.message || error?.name || "Unknown session error"; + + if (sessionID && this.isTrackedChildSession(sessionID)) { + logger.warn(`[Aggregator] Subagent session error: ${sessionID}: ${message}`); + this.setSubagentTerminalStatus(sessionID, "error", message); return; } - const message = - error?.data?.message || error?.message || error?.name || "Unknown session error"; + if (sessionID !== this.currentSessionId) { + return; + } logger.warn(`[Aggregator] Session error: ${sessionID}: ${message}`); this.stopTypingIndicator(); diff --git a/src/summary/formatter.ts b/src/summary/formatter.ts index 73d85f4..333cd34 100644 --- a/src/summary/formatter.ts +++ b/src/summary/formatter.ts @@ -492,6 +492,21 @@ export function formatToolInfo(toolInfo: ToolInfo): string | null { return `${toolIcon} ${description}${tool}${detailsStr}${lineInfo}`; } +export function formatCompactToolInfo(toolInfo: ToolInfo, maxLength = 64, fallback = "-"): string { + const formatted = formatToolInfo(toolInfo); + const normalized = formatted?.replace(/\s*\n+\s*/g, " ").trim() ?? ""; + + if (!normalized) { + return fallback; + } + + if (normalized.length <= maxLength) { + return normalized; + } + + return `${normalized.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`; +} + function countLines(text: string): number { return text.split("\n").length; } diff --git a/src/summary/subagent-formatter.ts b/src/summary/subagent-formatter.ts new file mode 100644 index 0000000..2fffad9 --- /dev/null +++ b/src/summary/subagent-formatter.ts @@ -0,0 +1,77 @@ +import { formatModelDisplayName } from "../pinned/format.js"; +import { t } from "../i18n/index.js"; +import { formatCompactToolInfo } from "./formatter.js"; +import type { SubagentInfo } from "./aggregator.js"; +import type { ToolInfo } from "./aggregator.js"; + +function formatToolStep(subagent: SubagentInfo): string { + if (!subagent.currentTool) { + return ""; + } + + const toolInfo: ToolInfo = { + sessionId: subagent.sessionId ?? subagent.parentSessionId, + messageId: subagent.cardId, + callId: subagent.cardId, + tool: subagent.currentTool, + state: { + status: "running", + input: subagent.currentToolInput ?? {}, + title: subagent.currentToolTitle, + metadata: {}, + time: { start: subagent.updatedAt }, + }, + input: subagent.currentToolInput, + title: subagent.currentToolTitle, + metadata: {}, + hasFileAttachment: false, + }; + + const formatted = formatCompactToolInfo(toolInfo, 128, "").trim(); + const firstSpaceIndex = formatted.indexOf(" "); + if (firstSpaceIndex >= 0 && formatted.slice(firstSpaceIndex + 1) === subagent.currentTool) { + return ""; + } + + return formatted; +} + +function formatSubagentActivity(subagent: SubagentInfo): string { + if (subagent.status === "completed") { + return `✅ ${t("subagent.completed")}`; + } + + if (subagent.status === "error") { + const message = subagent.terminalMessage?.trim() || t("subagent.failed"); + return `❌ ${message}`; + } + + const toolStep = formatToolStep(subagent); + if (toolStep) { + return toolStep; + } + + return `⚙️ ${t("subagent.working")}`; +} + +async function formatSubagentCard(subagent: SubagentInfo): Promise { + const modelName = formatModelDisplayName(subagent.providerID, subagent.modelID); + const lines = [ + `🧩 ${t("subagent.line.task", { task: subagent.description })}`, + t("subagent.line.agent", { agent: subagent.agent }), + t("pinned.line.model", { model: modelName }), + "", + formatSubagentActivity(subagent), + ]; + + return lines.join("\n"); +} + +export async function renderSubagentCards(subagents: SubagentInfo[]): Promise { + if (subagents.length === 0) { + return ""; + } + + const parts = await Promise.all(subagents.map((subagent) => formatSubagentCard(subagent))); + return parts.filter(Boolean).join("\n\n"); +} diff --git a/tests/bot/streaming/response-streamer.test.ts b/tests/bot/streaming/response-streamer.test.ts index 672b604..058bc02 100644 --- a/tests/bot/streaming/response-streamer.test.ts +++ b/tests/bot/streaming/response-streamer.test.ts @@ -82,9 +82,10 @@ describe("bot/streaming/response-streamer", () => { streamer.enqueue("s1", "m1", { parts: ["partial"], format: "raw" }); await vi.advanceTimersByTimeAsync(500); - const synced = await streamer.complete("s1", "m1", { parts: ["final"], format: "raw" }); + const result = await streamer.complete("s1", "m1", { parts: ["final"], format: "raw" }); - expect(synced).toBe(true); + expect(result.streamed).toBe(true); + expect(result.telegramMessageIds).toEqual([1]); expect(sendText).toHaveBeenCalledTimes(1); expect(editText).toHaveBeenCalledTimes(1); expect(editText).toHaveBeenCalledWith(1, "final", "raw", undefined); @@ -173,9 +174,10 @@ describe("bot/streaming/response-streamer", () => { expect(editText).toHaveBeenCalledTimes(1); - const synced = await streamer.complete("s1", "m1", { parts: ["final"], format: "raw" }); + const result = await streamer.complete("s1", "m1", { parts: ["final"], format: "raw" }); - expect(synced).toBe(false); + expect(result.streamed).toBe(false); + expect(result.telegramMessageIds).toEqual([]); expect(deleteText).toHaveBeenCalledTimes(1); expect(deleteText).toHaveBeenCalledWith(42); expect(sendText).toHaveBeenCalledTimes(1); @@ -206,9 +208,10 @@ describe("bot/streaming/response-streamer", () => { expect(sendText).toHaveBeenCalledTimes(1); - const synced = await streamer.complete("s1", "m1", { parts: ["final"], format: "raw" }); + const result = await streamer.complete("s1", "m1", { parts: ["final"], format: "raw" }); - expect(synced).toBe(false); + expect(result.streamed).toBe(false); + expect(result.telegramMessageIds).toEqual([]); expect(editText).not.toHaveBeenCalled(); expect(deleteText).not.toHaveBeenCalled(); }); @@ -246,7 +249,9 @@ describe("bot/streaming/response-streamer", () => { resolveSend?.(1); - await expect(completionPromise).resolves.toBe(true); + const result = await completionPromise; + expect(result.streamed).toBe(true); + expect(result.telegramMessageIds).toEqual([1]); expect(sendText).toHaveBeenCalledTimes(1); expect(editText).not.toHaveBeenCalled(); expect(deleteText).not.toHaveBeenCalled(); @@ -283,7 +288,8 @@ describe("bot/streaming/response-streamer", () => { expect(sendText).toHaveBeenCalledTimes(2); }); - expect(completedAfterClear).toBe(false); + expect(completedAfterClear.streamed).toBe(false); + expect(completedAfterClear.telegramMessageIds).toEqual([]); expect(editText).not.toHaveBeenCalled(); expect(deleteText).not.toHaveBeenCalled(); expect(sendText).toHaveBeenNthCalledWith(2, "new partial", "raw", undefined); @@ -315,7 +321,8 @@ describe("bot/streaming/response-streamer", () => { format: "raw", }); - expect(completedAfterClear).toBe(false); + expect(completedAfterClear.streamed).toBe(false); + expect(completedAfterClear.telegramMessageIds).toEqual([]); expect(editText).not.toHaveBeenCalled(); expect(deleteText).not.toHaveBeenCalled(); expect(sendText).toHaveBeenCalledTimes(1); @@ -340,7 +347,8 @@ describe("bot/streaming/response-streamer", () => { await vi.advanceTimersByTimeAsync(1000); - expect(synced).toBe(false); + expect(synced.streamed).toBe(false); + expect(synced.telegramMessageIds).toEqual([]); expect(sendText).not.toHaveBeenCalled(); expect(editText).not.toHaveBeenCalled(); expect(deleteText).not.toHaveBeenCalled(); diff --git a/tests/bot/utils/finalize-assistant-response.test.ts b/tests/bot/utils/finalize-assistant-response.test.ts index 280fdd7..fa7e2de 100644 --- a/tests/bot/utils/finalize-assistant-response.test.ts +++ b/tests/bot/utils/finalize-assistant-response.test.ts @@ -4,12 +4,13 @@ import { finalizeAssistantResponse } from "../../../src/bot/utils/finalize-assis describe("bot/utils/finalize-assistant-response", () => { it("uses the non-streaming send path when response streaming is disabled", async () => { const responseStreamer = { - complete: vi.fn().mockResolvedValue(true), + complete: vi.fn().mockResolvedValue({ streamed: false, telegramMessageIds: [] }), }; const flushPendingServiceMessages = vi.fn().mockResolvedValue(undefined); const sendText = vi.fn().mockResolvedValue(undefined); + const deleteMessages = vi.fn().mockResolvedValue(undefined); - const streamed = await finalizeAssistantResponse({ + await finalizeAssistantResponse({ responseStreaming: false, sessionId: "s1", messageId: "m1", @@ -21,11 +22,12 @@ describe("bot/utils/finalize-assistant-response", () => { resolveFormat: vi.fn(() => "markdown_v2"), getReplyKeyboard: vi.fn(() => ({ keyboard: [[{ text: "A" }]] })), sendText, + deleteMessages, }); - expect(streamed).toBe(false); expect(responseStreamer.complete).not.toHaveBeenCalled(); expect(flushPendingServiceMessages).toHaveBeenCalledTimes(1); + expect(deleteMessages).not.toHaveBeenCalled(); expect(sendText).toHaveBeenCalledTimes(2); expect(sendText).toHaveBeenNthCalledWith( 1, @@ -41,15 +43,17 @@ describe("bot/utils/finalize-assistant-response", () => { ); }); - it("skips the non-streaming send path when streaming already delivered the final message", async () => { + it("deletes streamed messages and re-sends with keyboard when streaming delivered the message", async () => { const responseStreamer = { - complete: vi.fn().mockResolvedValue(true), + complete: vi.fn().mockResolvedValue({ streamed: true, telegramMessageIds: [101] }), }; const flushPendingServiceMessages = vi.fn().mockResolvedValue(undefined); const sendText = vi.fn().mockResolvedValue(undefined); + const deleteMessages = vi.fn().mockResolvedValue(undefined); const prepareStreamingPayload = vi.fn(() => ({ parts: ["reply"], format: "raw" as const })); + const keyboard = { keyboard: [[{ text: "ctx" }]] }; - const streamed = await finalizeAssistantResponse({ + await finalizeAssistantResponse({ responseStreaming: true, sessionId: "s1", messageId: "m1", @@ -59,11 +63,11 @@ describe("bot/utils/finalize-assistant-response", () => { prepareStreamingPayload, formatSummary: vi.fn(() => ["reply"]), resolveFormat: vi.fn(() => "raw"), - getReplyKeyboard: vi.fn(() => undefined), + getReplyKeyboard: vi.fn(() => keyboard), sendText, + deleteMessages, }); - expect(streamed).toBe(true); expect(responseStreamer.complete).toHaveBeenCalledWith("s1", "m1", { parts: ["reply"], format: "raw", @@ -71,6 +75,37 @@ describe("bot/utils/finalize-assistant-response", () => { editOptions: undefined, }); expect(flushPendingServiceMessages).toHaveBeenCalledTimes(1); - expect(sendText).not.toHaveBeenCalled(); + expect(deleteMessages).toHaveBeenCalledWith([101]); + expect(sendText).toHaveBeenCalledTimes(1); + expect(sendText).toHaveBeenCalledWith("reply", { reply_markup: keyboard }, "raw"); + }); + + it("still sends with keyboard when streamer reports not streamed", async () => { + const responseStreamer = { + complete: vi.fn().mockResolvedValue({ streamed: false, telegramMessageIds: [] }), + }; + const flushPendingServiceMessages = vi.fn().mockResolvedValue(undefined); + const sendText = vi.fn().mockResolvedValue(undefined); + const deleteMessages = vi.fn().mockResolvedValue(undefined); + const prepareStreamingPayload = vi.fn(() => ({ parts: ["reply"], format: "raw" as const })); + + await finalizeAssistantResponse({ + responseStreaming: true, + sessionId: "s1", + messageId: "m1", + messageText: "reply", + responseStreamer, + flushPendingServiceMessages, + prepareStreamingPayload, + formatSummary: vi.fn(() => ["reply"]), + resolveFormat: vi.fn(() => "raw"), + getReplyKeyboard: vi.fn(() => undefined), + sendText, + deleteMessages, + }); + + expect(deleteMessages).not.toHaveBeenCalled(); + expect(sendText).toHaveBeenCalledTimes(1); + expect(sendText).toHaveBeenCalledWith("reply", undefined, "raw"); }); }); diff --git a/tests/pinned/manager.test.ts b/tests/pinned/manager.test.ts new file mode 100644 index 0000000..1729e5c --- /dev/null +++ b/tests/pinned/manager.test.ts @@ -0,0 +1,161 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocked = vi.hoisted(() => ({ + opencodeClient: { + session: { list: vi.fn().mockResolvedValue({ data: [] }) }, + config: { get: vi.fn().mockResolvedValue({ data: {} }) }, + }, + getCurrentSession: vi.fn(), + getCurrentProject: vi.fn(), + getPinnedMessageId: vi.fn().mockReturnValue(null), + setPinnedMessageId: vi.fn(), + clearPinnedMessageId: vi.fn(), + getStoredModel: vi.fn().mockReturnValue(null), + getModelContextLimit: vi.fn().mockResolvedValue(204800), +})); + +vi.mock("../../src/opencode/client.js", () => ({ opencodeClient: mocked.opencodeClient })); +vi.mock("../../src/session/manager.js", () => ({ getCurrentSession: mocked.getCurrentSession })); +vi.mock("../../src/settings/manager.js", () => ({ + getCurrentProject: mocked.getCurrentProject, + getPinnedMessageId: mocked.getPinnedMessageId, + setPinnedMessageId: mocked.setPinnedMessageId, + clearPinnedMessageId: mocked.clearPinnedMessageId, +})); +vi.mock("../../src/model/manager.js", () => ({ getStoredModel: mocked.getStoredModel })); +vi.mock("../../src/model/context-limit.js", () => ({ + getModelContextLimit: mocked.getModelContextLimit, +})); +vi.mock("../../src/i18n/index.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, t: (key: string) => key }; +}); +vi.mock("../../src/pinned/format.js", () => ({ + DEFAULT_CONTEXT_LIMIT: 204800, + formatContextLine: (used: number, limit: number) => `${used}/${limit}`, + formatCostLine: (cost: number) => `$${cost.toFixed(2)}`, + formatModelDisplayName: () => "test-model", +})); + +// Must import AFTER vi.mock calls +const { pinnedMessageManager } = await import("../../src/pinned/manager.js"); + +describe("pinned/manager", () => { + let fakeApi: { + sendMessage: ReturnType; + editMessageText: ReturnType; + pinChatMessage: ReturnType; + unpinAllChatMessages: ReturnType; + }; + + beforeEach(() => { + fakeApi = { + sendMessage: vi.fn().mockResolvedValue({ message_id: 999 }), + editMessageText: vi.fn().mockResolvedValue(undefined), + pinChatMessage: vi.fn().mockResolvedValue(undefined), + unpinAllChatMessages: vi.fn().mockResolvedValue(undefined), + }; + + // Reset manager state by re-initializing + pinnedMessageManager.initialize(fakeApi as never, 123); + + mocked.getCurrentSession.mockReturnValue({ id: "ses-1", title: "Test Session" }); + mocked.getCurrentProject.mockReturnValue({ id: "p1", worktree: "D:/repo", name: "repo" }); + mocked.getStoredModel.mockReturnValue({ providerID: "openai", modelID: "gpt-5" }); + mocked.getModelContextLimit.mockResolvedValue(204800); + mocked.getPinnedMessageId.mockReturnValue(null); + }); + + describe("updateTokensSilent", () => { + it("updates tokensUsed in memory without triggering API call", () => { + pinnedMessageManager.updateTokensSilent({ + input: 5000, + output: 200, + reasoning: 0, + cacheRead: 1000, + cacheWrite: 0, + }); + + const contextInfo = pinnedMessageManager.getContextInfo(); + // tokensUsed = input + cacheRead = 5000 + 1000 = 6000 + // contextInfo may be null if tokensLimit is 0, so check via getContextInfo + // The key assertion: no API call was made + expect(fakeApi.editMessageText).not.toHaveBeenCalled(); + expect(fakeApi.sendMessage).not.toHaveBeenCalled(); + }); + + it("accumulates token updates correctly", () => { + pinnedMessageManager.updateTokensSilent({ + input: 500, + output: 100, + reasoning: 0, + cacheRead: 100, + cacheWrite: 0, + }); + + pinnedMessageManager.updateTokensSilent({ + input: 5000, + output: 200, + reasoning: 0, + cacheRead: 1000, + cacheWrite: 0, + }); + + // Should reflect the LATEST values, not accumulated + // No API calls + expect(fakeApi.editMessageText).not.toHaveBeenCalled(); + }); + }); + + describe("refresh", () => { + it("calls editMessageText to push current state to Telegram", async () => { + // Set up state: create a pinned message first + await pinnedMessageManager.onSessionChange("ses-1", "Test Session"); + + // Reset to track only refresh calls + fakeApi.editMessageText.mockClear(); + + await pinnedMessageManager.refresh(); + + expect(fakeApi.editMessageText).toHaveBeenCalledTimes(1); + }); + + it("does not throw when no pinned message exists", async () => { + // No pinned message was created → refresh should be a no-op + await expect(pinnedMessageManager.refresh()).resolves.not.toThrow(); + }); + }); + + describe("setOnKeyboardUpdate race condition fix", () => { + it("fires callback immediately with current state when contextLimit is known", async () => { + // Create session to set contextLimit + await pinnedMessageManager.onSessionChange("ses-1", "Test Session"); + + const callback = vi.fn(); + pinnedMessageManager.setOnKeyboardUpdate(callback); + + // Should have been called immediately with (tokensUsed=0, limit=204800) + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(0, 204800); + }); + + it("fires callback with updated tokens after silent update", async () => { + await pinnedMessageManager.onSessionChange("ses-1", "Test Session"); + + pinnedMessageManager.updateTokensSilent({ + input: 3000, + output: 100, + reasoning: 0, + cacheRead: 500, + cacheWrite: 0, + }); + + const callback = vi.fn(); + pinnedMessageManager.setOnKeyboardUpdate(callback); + + // Should fire with tokensUsed = 3000 + 500 = 3500 + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(3500, 204800); + }); + }); +}); diff --git a/tests/summary/aggregator.test.ts b/tests/summary/aggregator.test.ts index d82e523..64b5be4 100644 --- a/tests/summary/aggregator.test.ts +++ b/tests/summary/aggregator.test.ts @@ -27,6 +27,7 @@ describe("summary/aggregator", () => { summaryAggregator.setOnToolFile(() => {}); summaryAggregator.setOnPartial(() => {}); summaryAggregator.setOnThinking(() => {}); + summaryAggregator.setOnSubagent(() => {}); summaryAggregator.setOnSessionError(() => {}); summaryAggregator.setOnSessionRetry(() => {}); }); @@ -89,6 +90,369 @@ describe("summary/aggregator", () => { ); }); + it("emits live subagent updates with per-session model, context, cost, and current tool", () => { + const onSubagent = vi.fn(); + summaryAggregator.setOnSubagent(onSubagent); + summaryAggregator.setSession("root-session"); + + summaryAggregator.processEvent({ + type: "message.part.updated", + properties: { + part: { + id: "subtask-1", + sessionID: "root-session", + messageID: "root-message", + type: "subtask", + prompt: "Inspect pinned manager", + description: "task description", + agent: "explore", + command: "inspect", + }, + }, + } as unknown as Event); + + summaryAggregator.processEvent({ + type: "message.part.updated", + properties: { + part: { + id: "task-tool-1", + sessionID: "root-session", + messageID: "root-message", + type: "tool", + callID: "task-call-1", + tool: "task", + state: { + status: "running", + input: { + description: "Explore project architecture", + subagent_type: "explore", + prompt: "Inspect architecture", + }, + title: "Launching subagent", + metadata: {}, + time: { start: Date.now() }, + }, + }, + }, + } as unknown as Event); + + summaryAggregator.processEvent({ + type: "session.created", + properties: { + info: { + id: "child-session-1", + parentID: "root-session", + title: "Explore project architecture (@explore subagent)", + slug: "child", + directory: "D:/repo", + projectID: "p1", + version: "1", + time: { created: Date.now(), updated: Date.now() }, + }, + }, + } as unknown as Event); + + summaryAggregator.processEvent({ + type: "message.updated", + properties: { + info: { + id: "child-message-1", + sessionID: "child-session-1", + role: "assistant", + parentID: "root-message", + providerID: "openai", + modelID: "gpt-5.4", + agent: "explore", + path: { cwd: "D:/repo", root: "D:/repo" }, + mode: "all", + cost: 0.18, + tokens: { + input: 54000, + output: 1200, + reasoning: 0, + cache: { read: 1000, write: 0 }, + }, + time: { created: Date.now() }, + }, + }, + } as unknown as Event); + + summaryAggregator.processEvent({ + type: "message.part.updated", + properties: { + part: { + id: "child-tool-1", + sessionID: "child-session-1", + messageID: "child-message-1", + type: "tool", + callID: "call-child-1", + tool: "read", + state: { + status: "running", + input: { + filePath: "src/pinned/manager.ts", + offset: 1, + limit: 280, + }, + title: "Reading pinned manager", + metadata: {}, + time: { start: Date.now() }, + }, + }, + }, + } as unknown as Event); + + expect(onSubagent).toHaveBeenCalled(); + expect(onSubagent.mock.lastCall?.[0]).toBe("root-session"); + expect(onSubagent.mock.lastCall?.[1]).toEqual([ + expect.objectContaining({ + sessionId: "child-session-1", + parentSessionId: "root-session", + agent: "explore", + description: "Explore project architecture", + status: "running", + providerID: "openai", + modelID: "gpt-5.4", + cost: 0.18, + currentTool: "read", + currentToolTitle: "Reading pinned manager", + currentToolInput: expect.objectContaining({ + filePath: "src/pinned/manager.ts", + offset: 1, + limit: 280, + }), + tokens: expect.objectContaining({ + input: 54000, + cacheRead: 1000, + }), + }), + ]); + }); + + it("attaches unknown child session events to pending subagent cards before session.created", () => { + const onSubagent = vi.fn(); + summaryAggregator.setOnSubagent(onSubagent); + summaryAggregator.setSession("root-session"); + + summaryAggregator.processEvent({ + type: "message.part.updated", + properties: { + part: { + id: "subtask-1", + sessionID: "root-session", + messageID: "root-message", + type: "subtask", + prompt: "Explore architecture", + description: "Explore architecture", + agent: "explore", + }, + }, + } as unknown as Event); + + summaryAggregator.processEvent({ + type: "message.part.updated", + properties: { + part: { + id: "step-1", + sessionID: "child-unknown", + messageID: "child-message-1", + type: "step-finish", + reason: "done", + cost: 0.12, + snapshot: "step snapshot", + tokens: { + input: 1000, + output: 50, + reasoning: 0, + cache: { read: 200, write: 0 }, + }, + }, + }, + } as unknown as Event); + + expect(onSubagent.mock.lastCall?.[1]).toEqual([ + expect.objectContaining({ + sessionId: "child-unknown", + cost: 0.12, + tokens: expect.objectContaining({ input: 1000, cacheRead: 200 }), + currentToolTitle: "step snapshot", + }), + ]); + }); + + it("tracks multiple parallel subagents independently", () => { + const onSubagent = vi.fn(); + summaryAggregator.setOnSubagent(onSubagent); + summaryAggregator.setSession("root-session"); + + const subtasks = [ + { id: "subtask-1", agent: "explore", description: "first task", child: "child-1" }, + { id: "subtask-2", agent: "general", description: "second task", child: "child-2" }, + ]; + + for (const item of subtasks) { + summaryAggregator.processEvent({ + type: "message.part.updated", + properties: { + part: { + id: item.id, + sessionID: "root-session", + messageID: "root-message", + type: "subtask", + prompt: item.description, + description: item.description, + agent: item.agent, + }, + }, + } as unknown as Event); + + summaryAggregator.processEvent({ + type: "session.created", + properties: { + info: { + id: item.child, + parentID: "root-session", + title: `${item.description} (@${item.agent} subagent)`, + slug: item.child, + directory: "D:/repo", + projectID: "p1", + version: "1", + time: { created: Date.now(), updated: Date.now() }, + }, + }, + } as unknown as Event); + + summaryAggregator.processEvent({ + type: "message.part.updated", + properties: { + part: { + id: `tool-${item.child}`, + sessionID: item.child, + messageID: `message-${item.child}`, + type: "tool", + callID: `call-${item.child}`, + tool: "bash", + state: { + status: "running", + input: { command: `echo ${item.child}` }, + title: `Running ${item.child}`, + metadata: {}, + time: { start: Date.now() }, + }, + }, + }, + } as unknown as Event); + } + + expect(onSubagent.mock.lastCall?.[1]).toHaveLength(2); + expect(onSubagent.mock.lastCall?.[1]).toEqual([ + expect.objectContaining({ + sessionId: "child-1", + description: "first task", + agent: "explore", + }), + expect.objectContaining({ + sessionId: "child-2", + description: "second task", + agent: "general", + }), + ]); + }); + + it("keeps subagent cards and updates terminal status for child sessions", () => { + const onSubagent = vi.fn(); + summaryAggregator.setOnSubagent(onSubagent); + summaryAggregator.setSession("root-session"); + + summaryAggregator.processEvent({ + type: "message.part.updated", + properties: { + part: { + id: "subtask-1", + sessionID: "root-session", + messageID: "root-message", + type: "subtask", + prompt: "done task", + description: "done task", + agent: "explore", + }, + }, + } as unknown as Event); + + summaryAggregator.processEvent({ + type: "session.created", + properties: { + info: { + id: "child-done", + parentID: "root-session", + title: "done task (@explore subagent)", + slug: "child-done", + directory: "D:/repo", + projectID: "p1", + version: "1", + time: { created: Date.now(), updated: Date.now() }, + }, + }, + } as unknown as Event); + + summaryAggregator.processEvent({ + type: "session.idle", + properties: { + sessionID: "child-done", + }, + } as unknown as Event); + + summaryAggregator.processEvent({ + type: "message.part.updated", + properties: { + part: { + id: "subtask-2", + sessionID: "root-session", + messageID: "root-message", + type: "subtask", + prompt: "failed task", + description: "failed task", + agent: "general", + }, + }, + } as unknown as Event); + + summaryAggregator.processEvent({ + type: "session.created", + properties: { + info: { + id: "child-error", + parentID: "root-session", + title: "failed task (@general subagent)", + slug: "child-error", + directory: "D:/repo", + projectID: "p1", + version: "1", + time: { created: Date.now(), updated: Date.now() }, + }, + }, + } as unknown as Event); + + summaryAggregator.processEvent({ + type: "session.error", + properties: { + sessionID: "child-error", + error: { + data: { message: "Task failed" }, + }, + }, + } as unknown as Event); + + expect(onSubagent.mock.lastCall?.[1]).toEqual([ + expect.objectContaining({ sessionId: "child-done", status: "completed" }), + expect.objectContaining({ + sessionId: "child-error", + status: "error", + terminalMessage: "Task failed", + }), + ]); + }); + it("marks write tool without file attachment when payload is oversized", () => { const onTool = vi.fn(); const onToolFile = vi.fn(); @@ -733,4 +1097,151 @@ describe("summary/aggregator", () => { expect(filePayload.fileData.filename).toBe("edit_README.md.txt"); expect(filePayload.fileData.buffer.toString("utf8")).toContain("Edit File/Path: README.md"); }); + + it("fires onTokens with isCompleted=true when message has completed timestamp", () => { + const onTokens = vi.fn(); + summaryAggregator.setOnTokens(onTokens); + summaryAggregator.setOnComplete(() => {}); + summaryAggregator.setSession("session-1"); + + summaryAggregator.processEvent({ + type: "message.updated", + properties: { + info: { + id: "msg-tokens-completed", + sessionID: "session-1", + role: "assistant", + time: { created: Date.now() }, + }, + }, + } as unknown as Event); + + summaryAggregator.processEvent({ + type: "message.part.updated", + properties: { + part: { + id: "part-text-tokens", + sessionID: "session-1", + messageID: "msg-tokens-completed", + type: "text", + text: "Done", + time: { start: Date.now() }, + }, + }, + } as unknown as Event); + + summaryAggregator.processEvent({ + type: "message.updated", + properties: { + info: { + id: "msg-tokens-completed", + sessionID: "session-1", + role: "assistant", + tokens: { input: 800, output: 200, reasoning: 0, cache: { read: 100, write: 0 } }, + cost: 0.01, + time: { created: Date.now(), completed: Date.now() }, + }, + }, + } as unknown as Event); + + expect(onTokens).toHaveBeenCalledTimes(1); + expect(onTokens).toHaveBeenCalledWith( + expect.objectContaining({ input: 800, output: 200, cacheRead: 100 }), + true, + ); + }); + + it("fires onTokens with isCompleted=false for non-completed message with tokens", () => { + const onTokens = vi.fn(); + summaryAggregator.setOnTokens(onTokens); + summaryAggregator.setSession("session-1"); + + summaryAggregator.processEvent({ + type: "message.updated", + properties: { + info: { + id: "msg-tokens-intermediate", + sessionID: "session-1", + role: "assistant", + tokens: { input: 500, output: 50, reasoning: 0, cache: { read: 200, write: 0 } }, + time: { created: Date.now() }, + }, + }, + } as unknown as Event); + + expect(onTokens).toHaveBeenCalledTimes(1); + expect(onTokens).toHaveBeenCalledWith( + expect.objectContaining({ input: 500, output: 50, cacheRead: 200 }), + false, + ); + }); + + it("fires onTokens for non-completed message with non-zero tokens (intermediate update)", () => { + const onTokens = vi.fn(); + summaryAggregator.setOnTokens(onTokens); + summaryAggregator.setSession("session-1"); + + // First message with zero tokens (new message starting) + summaryAggregator.processEvent({ + type: "message.updated", + properties: { + info: { + id: "msg-step2", + sessionID: "session-1", + role: "assistant", + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: Date.now() }, + }, + }, + } as unknown as Event); + + // The callback IS fired (filtering zero tokens is done at bot/index.ts level) + expect(onTokens).toHaveBeenCalledTimes(1); + expect(onTokens).toHaveBeenCalledWith( + expect.objectContaining({ input: 0, cacheRead: 0 }), + false, + ); + + onTokens.mockClear(); + + // Later update with real tokens + summaryAggregator.processEvent({ + type: "message.updated", + properties: { + info: { + id: "msg-step2", + sessionID: "session-1", + role: "assistant", + tokens: { input: 4000, output: 300, reasoning: 0, cache: { read: 12000, write: 0 } }, + time: { created: Date.now() }, + }, + }, + } as unknown as Event); + + expect(onTokens).toHaveBeenCalledTimes(1); + expect(onTokens).toHaveBeenCalledWith( + expect.objectContaining({ input: 4000, cacheRead: 12000 }), + false, + ); + }); + + it("does not fire onTokens when message.updated has no tokens field", () => { + const onTokens = vi.fn(); + summaryAggregator.setOnTokens(onTokens); + summaryAggregator.setSession("session-1"); + + summaryAggregator.processEvent({ + type: "message.updated", + properties: { + info: { + id: "msg-no-tokens", + sessionID: "session-1", + role: "assistant", + time: { created: Date.now() }, + }, + }, + } as unknown as Event); + + expect(onTokens).not.toHaveBeenCalled(); + }); }); diff --git a/tests/summary/subagent-formatter.test.ts b/tests/summary/subagent-formatter.test.ts new file mode 100644 index 0000000..54014ec --- /dev/null +++ b/tests/summary/subagent-formatter.test.ts @@ -0,0 +1,174 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { renderSubagentCards } from "../../src/summary/subagent-formatter.js"; +import { resetRuntimeLocale, setRuntimeLocale } from "../../src/i18n/index.js"; + +describe("summary/subagent-formatter", () => { + afterEach(() => { + resetRuntimeLocale(); + }); + + it("renders subagent cards with requested OpenCode-like layout", async () => { + setRuntimeLocale("en"); + + const text = await renderSubagentCards([ + { + cardId: "card-1", + sessionId: "child-1", + parentSessionId: "root-1", + agent: "explore", + description: "task description", + prompt: "task description", + status: "running", + providerID: "openai", + modelID: "gpt-5.4", + tokens: { + input: 54000, + output: 10, + reasoning: 0, + cacheRead: 0, + cacheWrite: 0, + }, + cost: 0.18, + currentTool: "read", + currentToolInput: { + filePath: "src/pinned/manager.ts", + offset: 1, + limit: 280, + }, + currentToolTitle: "Reading pinned manager", + updatedAt: Date.now(), + }, + ]); + + expect(text).toContain("🧩 Task: task description"); + expect(text).toContain("Agent: explore"); + expect(text).toContain("Model: openai/gpt-5.4"); + expect(text).not.toContain("Context:"); + expect(text).not.toContain("Cost:"); + expect(text).toContain("📖 read Reading pinned manager"); + expect(text).not.toContain("Working:"); + }); + + it("localizes labels and shows terminal completion state", async () => { + setRuntimeLocale("ru"); + + const text = await renderSubagentCards([ + { + cardId: "card-1", + sessionId: "child-1", + parentSessionId: "root-1", + agent: "explore", + description: "описание", + prompt: "описание", + status: "completed", + providerID: "openai", + modelID: "gpt-5.4", + tokens: { + input: 1000, + output: 10, + reasoning: 0, + cacheRead: 0, + cacheWrite: 0, + }, + cost: 0, + updatedAt: Date.now(), + }, + ]); + + expect(text).toContain("🧩 Задача: описание"); + expect(text).toContain("Агент: explore"); + expect(text).toContain("Модель: openai/gpt-5.4"); + expect(text).toContain("✅ Завершена"); + }); + + it("shows error message on failed subagent", async () => { + setRuntimeLocale("en"); + + const text = await renderSubagentCards([ + { + cardId: "card-1", + sessionId: "child-1", + parentSessionId: "root-1", + agent: "explore", + description: "task description", + prompt: "task description", + status: "error", + providerID: "openai", + modelID: "gpt-5.4", + tokens: { + input: 0, + output: 0, + reasoning: 0, + cacheRead: 0, + cacheWrite: 0, + }, + cost: 0, + terminalMessage: "Permission denied", + updatedAt: Date.now(), + }, + ]); + + expect(text).toContain("❌ Permission denied"); + }); + + it("shows idle working state when no tool call is active", async () => { + setRuntimeLocale("ru"); + + const text = await renderSubagentCards([ + { + cardId: "card-1", + sessionId: "child-1", + parentSessionId: "root-1", + agent: "explore", + description: "описание", + prompt: "описание", + status: "running", + providerID: "openai", + modelID: "gpt-5.4", + tokens: { + input: 0, + output: 0, + reasoning: 0, + cacheRead: 0, + cacheWrite: 0, + }, + cost: 0, + updatedAt: Date.now(), + }, + ]); + + expect(text).toContain("⚙️ В работе..."); + }); + + it("falls back to working state when tool event has no details yet", async () => { + setRuntimeLocale("en"); + + const text = await renderSubagentCards([ + { + cardId: "card-1", + sessionId: "child-1", + parentSessionId: "root-1", + agent: "explore", + description: "task description", + prompt: "task description", + status: "running", + providerID: "openai", + modelID: "gpt-5.4", + tokens: { + input: 0, + output: 0, + reasoning: 0, + cacheRead: 0, + cacheWrite: 0, + }, + cost: 0, + currentTool: "read", + currentToolInput: {}, + updatedAt: Date.now(), + }, + ]); + + expect(text).toContain("⚙️ Working..."); + expect(text).not.toContain("📖 read\n"); + }); +});