diff --git a/.env.example b/.env.example index ebeacf78..d0c0fc29 100644 --- a/.env.example +++ b/.env.example @@ -90,6 +90,9 @@ OPENCODE_MODEL_ID=big-pickle # Higher value = fewer Telegram edit requests, lower value = more real-time updates # RESPONSE_STREAM_THROTTLE_MS=1000 +# Automatically attach assistant responses longer than this many characters as Markdown (default: 5000) +# ASSISTANT_RESPONSE_FILE_THRESHOLD=5000 + # Maximum displayed length for bash tool commands in Telegram summaries (default: 128) # Longer commands are truncated with "..." # BASH_TOOL_DISPLAY_MAX_LENGTH=128 diff --git a/PRODUCT.md b/PRODUCT.md index f4d53518..a03324fc 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -105,6 +105,7 @@ No public inbound ports are required for normal usage. Current command set: - `/status` - server, project, and session status +- `/lastfile` - export the latest delivered assistant response as Markdown - `/new` - create a new session - `/abort` - stop the current task - `/detach` - detach the bot from the current session without stopping it diff --git a/README.md b/README.md index 5257c4e5..6eaaaed5 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,7 @@ opencode-telegram config | Command | Description | | ----------------- | ------------------------------------------------------- | | `/status` | Server health, current project, session, and model info | +| `/lastfile` | Export the latest delivered assistant response as Markdown | | `/new` | Create a new session | | `/abort` | Abort the current task | | `/detach` | Detach from the current session without stopping it | @@ -235,6 +236,7 @@ Configuration can be provided through process environment variables or an `.env` | `BASH_TOOL_DISPLAY_MAX_LENGTH` | Maximum displayed length for `bash` tool commands in Telegram summaries; longer commands are truncated | No | `128` | | `TRACK_BACKGROUND_SESSIONS` | Track detached/non-current sessions in the current selected project/worktree and send short notifications | No | `true` | | `RESPONSE_STREAM_THROTTLE_MS` | Stream update throttle in milliseconds for assistant, thinking, and tool message edits | No | `1000` | +| `ASSISTANT_RESPONSE_FILE_THRESHOLD` | Automatically attach assistant replies longer than this many characters as Markdown | No | `5000` | | `MESSAGE_FORMAT_MODE` | Assistant reply formatting mode: `markdown` (native Telegram rich blocks) or `raw` (plain text) | No | `markdown` | | `MESSAGE_MERGE_WINDOW_MS` | Merge Telegram-split long text messages into one prompt after this wait window (ms); `0` disables merging | No | `1500` | | `INITIAL_SETTINGS_PRESET` | JSON object that seeds default `/settings` values on first run (keys not yet persisted); see [Runtime Settings](#runtime-settings) | No | `{}` | diff --git a/src/app/managers/summary-aggregation-manager.ts b/src/app/managers/summary-aggregation-manager.ts index 7bf72ffc..efd6b57d 100644 --- a/src/app/managers/summary-aggregation-manager.ts +++ b/src/app/managers/summary-aggregation-manager.ts @@ -23,6 +23,16 @@ export interface MessageCompletionInfo { modelID?: string; createdAt?: number; completedAt?: number; + finishReason?: string; + tokens?: TokensInfo; + cost?: number; + hasToolActivity: boolean; + hasReasoningActivity: boolean; + /** True when the completed assistant message carries an upstream error + * (aborted, output length, provider error, ...). Such a completion is not a + * successfully delivered task response. */ + hasError?: boolean; + errorName?: string; } type MessageCompleteCallback = ( @@ -32,6 +42,8 @@ type MessageCompleteCallback = ( completionInfo: MessageCompletionInfo, ) => void; +type AssistantMessageStartedCallback = (sessionId: string, messageId: string) => void; + type MessagePartialCallback = (sessionId: string, messageId: string, messageText: string) => void; export interface ThinkingSection { @@ -183,6 +195,16 @@ interface TextMessageState { optimisticUpdateCount: number; } +interface MessageActivityState { + hasToolActivity: boolean; + hasReasoningActivity: boolean; +} + +interface PendingEmptyCompletion { + messageId: string; + info: MessageCompletionInfo; +} + interface ThinkingMessageState { orderedPartIds: string[]; sections: Map; @@ -288,6 +310,7 @@ class SummaryAggregator { private messageCount = 0; private lastUpdated = 0; private onCompleteCallback: MessageCompleteCallback | null = null; + private onAssistantMessageStartedCallback: AssistantMessageStartedCallback | null = null; private onPartialCallback: MessagePartialCallback | null = null; private onExternalUserInputCallback: ExternalUserInputCallback | null = null; private onToolCallback: ToolCallback | null = null; @@ -324,6 +347,8 @@ class SummaryAggregator { private typingIndicatorEnabled = true; private partHashes: Map> = new Map(); private trackedSessionParents: Map = new Map(); + private messageActivityStates: Map = new Map(); + private pendingEmptyCompletions: Map = new Map(); private subagentStates: Map = new Map(); private subagentOrder: string[] = []; private subagentCardIdBySessionId: Map = new Map(); @@ -341,6 +366,10 @@ class SummaryAggregator { this.onCompleteCallback = callback; } + setOnAssistantMessageStarted(callback: AssistantMessageStartedCallback): void { + this.onAssistantMessageStartedCallback = callback; + } + setOnPartial(callback: MessagePartialCallback): void { this.onPartialCallback = callback; } @@ -556,6 +585,8 @@ class SummaryAggregator { this.partHashes.clear(); this.knownTextPartIds.clear(); this.syntheticPartIds.clear(); + this.messageActivityStates.clear(); + this.pendingEmptyCompletions.clear(); this.processedToolStates.clear(); this.thinkingFiredForMessages.clear(); this.thinkingFinishedForMessages.clear(); @@ -1153,6 +1184,11 @@ class SummaryAggregator { }); this.messageCount++; this.startTypingIndicator(); + + // Fired synchronously, like the completion callback: the consumer must + // observe the message start in event order so it can invalidate an + // older pending final response before any newer completion lands. + this.onAssistantMessageStartedCallback?.(info.sessionID, messageID); } const textState = this.getOrCreateTextMessageState(messageID); @@ -1160,6 +1196,7 @@ class SummaryAggregator { const time = info.time; const isCompleted = Boolean(time?.completed); const messageText = this.getCombinedMessageText(messageID, isCompleted); + const activity = this.getOrCreateMessageActivityState(messageID); if (!isCompleted && textState.optimisticUpdateCount === 1) { this.emitPartialText(info.sessionID, messageID, messageText); @@ -1187,6 +1224,33 @@ class SummaryAggregator { if (isCompleted) { const finalText = messageText; + const completionInfo: MessageCompletionInfo = { + agent: info.agent, + providerID: info.providerID, + modelID: info.modelID, + createdAt: time?.created, + completedAt: time?.completed, + // Authoritative message finish only: OpenCode always stamps the + // message-level finish reason when a run ends; a missing value means + // the message was interrupted and must fail closed, so a per-step + // finish reason is never substituted here. + finishReason: + typeof info.finish === "string" && info.finish.trim() ? info.finish.trim() : undefined, + tokens: info.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, + } + : undefined, + cost: typeof info.cost === "number" ? info.cost : undefined, + hasToolActivity: activity.hasToolActivity, + hasReasoningActivity: activity.hasReasoningActivity, + hasError: info.error !== undefined, + errorName: info.error !== undefined ? String(info.error.name) : undefined, + }; logger.debug( `[Aggregator] Message part completed: messageId=${messageID}, textLength=${finalText.length}, totalParts=${textState.orderedPartIds.length}, session=${this.currentSessionId}`, @@ -1209,13 +1273,15 @@ class SummaryAggregator { this.onCostCallback(assistantInfo.cost); } - if (this.onCompleteCallback && finalText.length > 0) { + if (this.onCompleteCallback && finalText.trim().length > 0) { + this.pendingEmptyCompletions.delete(this.currentSessionId!); this.onCompleteCallback(this.currentSessionId!, messageID, finalText, { - agent: info.agent, - providerID: info.providerID, - modelID: info.modelID, - createdAt: time?.created, - completedAt: time?.completed, + ...completionInfo, + }); + } else if (finalText.trim().length === 0) { + this.pendingEmptyCompletions.set(this.currentSessionId!, { + messageId: messageID, + info: completionInfo, }); } @@ -1287,6 +1353,7 @@ class SummaryAggregator { const messageID = part.messageID; const messageInfo = this.messages.get(messageID); + const activity = this.getOrCreateMessageActivityState(messageID); // OpenCode injects synthetic text parts of its own: expanded file attachments, // MCP resource dumps, plan-mode hints. They are context for the model, never content @@ -1303,6 +1370,7 @@ class SummaryAggregator { } if (part.type === "reasoning") { + activity.hasReasoningActivity = true; this.registerThinkingPart( messageID, part.id, @@ -1386,6 +1454,7 @@ class SummaryAggregator { } } } else if (part.type === "tool") { + activity.hasToolActivity = true; const state = part.state; const input = state.input; const title = "title" in state ? state.title : undefined; @@ -1818,6 +1887,20 @@ class SummaryAggregator { return state; } + private getOrCreateMessageActivityState(messageID: string): MessageActivityState { + const existing = this.messageActivityStates.get(messageID); + if (existing) { + return existing; + } + + const state: MessageActivityState = { + hasToolActivity: false, + hasReasoningActivity: false, + }; + this.messageActivityStates.set(messageID, state); + return state; + } + private registerKnownTextPart(messageID: string, partID: string): void { if (!this.knownTextPartIds.has(messageID)) { this.knownTextPartIds.set(messageID, new Set()); @@ -2052,6 +2135,17 @@ class SummaryAggregator { logger.info(`[Aggregator] Session became idle: ${sessionID}`); + const pendingEmptyCompletion = this.pendingEmptyCompletions.get(sessionID); + this.pendingEmptyCompletions.delete(sessionID); + if (pendingEmptyCompletion && this.onCompleteCallback) { + this.onCompleteCallback( + sessionID, + pendingEmptyCompletion.messageId, + "", + pendingEmptyCompletion.info, + ); + } + // Stop typing indicator when session goes idle this.stopTypingIndicator(); diff --git a/src/bot/commands/abort-command.ts b/src/bot/commands/abort-command.ts index 71803884..706670cb 100644 --- a/src/bot/commands/abort-command.ts +++ b/src/bot/commands/abort-command.ts @@ -7,7 +7,7 @@ import { t } from "../../i18n/index.js"; import { foregroundSessionState } from "../../app/managers/foreground-session-state-manager.js"; import { assistantRunState } from "../../app/managers/assistant-run-state-manager.js"; import { markAttachedSessionIdle } from "../../app/services/attach-service.js"; -import { clearPromptResponseMode } from "../handlers/prompt.js"; +import { clearPromptResponseMode, clearPromptRetry } from "../handlers/prompt.js"; import { markUserAbortRequested } from "../../app/managers/abort-suppression-manager.js"; import { promptQueue } from "../../app/managers/prompt-queue-manager.js"; import { promptAttachment } from "../../app/managers/prompt-attachment-manager.js"; @@ -29,6 +29,7 @@ async function releaseAbortBusyState(sessionId: string, reason: string): Promise assistantRunState.clearRun(sessionId, reason); await markAttachedSessionIdle(sessionId); clearPromptResponseMode(sessionId); + clearPromptRetry(sessionId); } async function pollSessionStatus( diff --git a/src/bot/commands/definitions.ts b/src/bot/commands/definitions.ts index 32439383..cac5590c 100644 --- a/src/bot/commands/definitions.ts +++ b/src/bot/commands/definitions.ts @@ -22,6 +22,7 @@ interface BotCommandI18nDefinition { */ const COMMAND_DEFINITIONS: BotCommandI18nDefinition[] = [ { command: "status", descriptionKey: "cmd.description.status" }, + { command: "lastfile", descriptionKey: "cmd.description.lastfile" }, { command: "new", descriptionKey: "cmd.description.new" }, { command: "abort", descriptionKey: "cmd.description.stop" }, { command: "detach", descriptionKey: "cmd.description.detach" }, diff --git a/src/bot/commands/detach-command.ts b/src/bot/commands/detach-command.ts index 24e6ec5d..92f466ce 100644 --- a/src/bot/commands/detach-command.ts +++ b/src/bot/commands/detach-command.ts @@ -7,7 +7,7 @@ import { pinnedMessageManager } from "../pinned/pinned-message-manager.js"; import { keyboardManager } from "../keyboards/keyboard-manager.js"; import { foregroundSessionState } from "../../app/managers/foreground-session-state-manager.js"; import { assistantRunState } from "../../app/managers/assistant-run-state-manager.js"; -import { clearPromptResponseMode } from "../handlers/prompt.js"; +import { clearPromptResponseMode, clearPromptRetry } from "../handlers/prompt.js"; import { logger } from "../../utils/logger.js"; import { t } from "../../i18n/index.js"; @@ -27,6 +27,7 @@ export async function detachCommand(ctx: CommandContext): Promise detachAttachedSession("detach_command"); clearPromptResponseMode(currentSession.id); + clearPromptRetry(currentSession.id); foregroundSessionState.markIdle(currentSession.id); assistantRunState.clearRun(currentSession.id, "detach_command"); clearAllInteractionState("detach_command"); diff --git a/src/bot/commands/lastfile-command.ts b/src/bot/commands/lastfile-command.ts new file mode 100644 index 00000000..d90e4b46 --- /dev/null +++ b/src/bot/commands/lastfile-command.ts @@ -0,0 +1,28 @@ +import { Context } from "grammy"; +import { getCurrentSession } from "../../app/services/session-service.js"; +import { t } from "../../i18n/index.js"; +import { + getRememberedAssistantResponse, + sendAssistantResponseDocument, +} from "../services/assistant-response-export-service.js"; + +export async function lastfileCommand(ctx: Context): Promise { + const chatId = ctx.chat?.id; + const sessionId = getCurrentSession()?.id; + if (!chatId || !sessionId) { + await ctx.reply(t("bot.lastfile_empty")); + return; + } + + const response = getRememberedAssistantResponse(chatId, sessionId); + if (!response) { + await ctx.reply(t("bot.lastfile_empty")); + return; + } + + try { + await sendAssistantResponseDocument(ctx.api, chatId, response); + } catch { + await ctx.reply(t("bot.lastfile_error")); + } +} diff --git a/src/bot/handlers/prompt.ts b/src/bot/handlers/prompt.ts index 803b9b0e..d20acd72 100644 --- a/src/bot/handlers/prompt.ts +++ b/src/bot/handlers/prompt.ts @@ -33,12 +33,54 @@ import { import { externalUserInputSuppressionManager } from "../../app/managers/external-input-suppression-manager.js"; import { promptAttachment } from "../../app/managers/prompt-attachment-manager.js"; import { resolvePendingAttachment } from "../../app/services/prompt-attachment-service.js"; +import { scheduledTaskRuntime } from "../../app/services/scheduled-task-runtime-service.js"; +import { dispatchNextQueuedPrompt } from "./prompt-queue-dispatch.js"; +import { + createEmptyTaskAttemptEvidence, + isSafeZeroWorkEmptyCompletion, + mergeTaskAttemptEvidence, + type TaskAttemptEvidence, +} from "../services/empty-completion-policy.js"; /** Module-level references for async callbacks that don't have ctx. */ let botInstance: Bot | null = null; let chatIdInstance: number | null = null; const promptResponseModes = new Map(); +export interface PromptDispatchOptions { + sessionID: string; + directory: string; + parts: Array; + model?: { providerID: string; modelID: string }; + agent?: string; + variant?: string; +} + +/** + * Lifecycle of a single prompt attempt. Distinguishes the original attempt from + * the one automatic retry, guards session.idle events that close the original + * attempt while the retry is in flight, and accumulates conservative work + * evidence across every assistant turn of the current attempt. + */ +interface PromptAttemptState { + bot: Bot; + chatId: number; + promptOptions: PromptDispatchOptions; + promptText: string; + responseMode: PromptResponseMode; + retryDispatched: boolean; + /** True once the retry produced a terminal-eligible non-empty response. From + * that point an idle is only finalized after the authoritative OpenCode + * session status confirms the session is genuinely idle; a stale idle from + * the original attempt while the retry is still busy is consumed instead. */ + retryResponseDelivered: boolean; + workEvidence: TaskAttemptEvidence; +} + +const promptRetryStates = new Map(); + +export type EmptyCompletionOutcome = "retried" | "failed" | "no_retry" | "ignored"; + export type PromptResponseMode = "text_only" | "text_and_tts"; type ProcessPromptOptions = { @@ -67,6 +109,236 @@ export function consumePromptResponseMode(sessionId: string): PromptResponseMode return responseMode; } +export function registerPromptRetry( + sessionId: string, + state: Omit, +): void { + promptRetryStates.set(sessionId, { + ...state, + retryDispatched: false, + retryResponseDelivered: false, + workEvidence: createEmptyTaskAttemptEvidence(), + }); +} + +export function clearPromptRetry(sessionId: string): void { + promptRetryStates.delete(sessionId); +} + +export function clearAllPromptRetry(): void { + promptRetryStates.clear(); +} + +export function hasPromptRetryAttempted(sessionId: string): boolean { + return promptRetryStates.get(sessionId)?.retryDispatched ?? false; +} + +export function getPromptRetryChatId(sessionId: string): number | null { + return promptRetryStates.get(sessionId)?.chatId ?? null; +} + +/** + * Records that the retry produced a terminal-eligible non-empty response. The + * retry state is kept alive so the retry is only finalized once the + * authoritative OpenCode session status confirms the session is genuinely idle. + */ +export function markPromptRetryResponseDelivered(sessionId: string): void { + const state = promptRetryStates.get(sessionId); + if (!state) { + return; + } + + state.retryResponseDelivered = true; +} + +/** + * Queries the authoritative OpenCode session status for a retried run. OpenCode + * keeps a session in its active status map while it is busy and deletes it when + * it goes idle (publishing session.idle at the same time), and its own + * `SessionStatus.get()` defaults a missing session to "idle" - so a missing or + * "idle" entry is positive proof the run finished. A failed or unexpected + * lookup returns "unknown". + */ +async function queryAuthoritativeSessionState( + sessionId: string, + directory: string, +): Promise<"idle" | "busy" | "unknown"> { + try { + const { data, error } = await opencodeClient.session.status({ directory }); + + if (error || !data) { + logger.warn(`[Bot] Failed to verify retry session status: session=${sessionId}`, error); + return "unknown"; + } + + const status = (data as Record)[sessionId]; + if (!status || status.type === "idle") { + return "idle"; + } + + if (status.type === "busy" || status.type === "retry") { + return "busy"; + } + + return "unknown"; + } catch (err) { + logger.warn(`[Bot] Error verifying retry session status: session=${sessionId}`, err); + return "unknown"; + } +} + +export type RetryIdleDecision = "none" | "consumed" | "finalize"; + +/** + * Decides how a session.idle during a retry lifecycle should be handled. + * + * - `none`: no active retry for this session; the idle is a normal one. + * - `consumed`: the idle belongs to the retry (the original attempt's idle, a + * stale duplicate, or the retry still busy per the authoritative status). It + * must not finalize anything. + * - `finalize`: the retry produced a terminal response AND the authoritative + * OpenCode session status confirms the session is genuinely idle, so the + * caller finalizes the run exactly once. + * + * The guard only activates once the retry is actually dispatched, so a + * registered-but-unreplayed attempt never swallows a normal run's idle. When + * the status lookup fails or is ambiguous the idle is consumed and success is + * never finalized (fail closed). + */ +export async function decidePromptRetryIdle(sessionId: string): Promise { + const state = promptRetryStates.get(sessionId); + if (!state || !state.retryDispatched) { + return "none"; + } + + if (!state.retryResponseDelivered) { + return "consumed"; + } + + const sessionState = await queryAuthoritativeSessionState( + sessionId, + state.promptOptions.directory, + ); + return sessionState === "idle" ? "finalize" : "consumed"; +} + +/** + * Merges the completion evidence of one assistant turn into the running + * attempt-wide evidence, so a later empty completion is judged by everything the + * run actually did, not by the final message alone. + */ +export function recordAttemptEvidence( + sessionId: string, + evidence: TaskAttemptEvidence, +): void { + const state = promptRetryStates.get(sessionId); + if (!state) { + return; + } + + state.workEvidence = mergeTaskAttemptEvidence(state.workEvidence, evidence); +} + +/** + * Decides what an empty completion means for the current prompt attempt: + * retried (zero-work original, retry dispatched), failed (the retry itself came + * back empty), no_retry (work evidence says the run is not provably zero-work), + * or ignored (no prompt attempt is registered for this session). + */ +export function handleEmptyCompletion(sessionId: string): EmptyCompletionOutcome { + const state = promptRetryStates.get(sessionId); + if (!state) { + return "ignored"; + } + + if (!state.retryDispatched && isSafeZeroWorkEmptyCompletion(state.workEvidence)) { + return retryPromptOnce(sessionId) ? "retried" : "no_retry"; + } + + if (state.retryDispatched) { + clearPromptRetry(sessionId); + return "failed"; + } + + clearPromptRetry(sessionId); + return "no_retry"; +} + +/** + * Clears the retry state and restores a coherent idle state after the retry API + * call itself failed. Only the state this attempt registered is invalidated, so + * a slower duplicate callback can never wipe out a newer prompt's state. + */ +function abandonRetryAttempt( + sessionId: string, + state: PromptAttemptState, + reason: string, +): void { + if (promptRetryStates.get(sessionId) !== state) { + return; + } + + clearPromptRetry(sessionId); + foregroundSessionState.markIdle(sessionId); + void markAttachedSessionIdle(sessionId); + assistantRunState.clearRun(sessionId, reason); + clearPromptResponseMode(sessionId); + void state.bot.api.sendMessage(state.chatId, t("bot.prompt_send_error")).catch(() => {}); + // The idle that would normally drive the queue was consumed by the guard, so + // the canonical lifecycle is resumed from here. + void dispatchNextQueuedPrompt(); + void scheduledTaskRuntime.flushDeferredDeliveries(); +} + +export function retryPromptOnce(sessionId: string): boolean { + const state = promptRetryStates.get(sessionId); + if (!state || state.retryDispatched) { + return false; + } + + state.retryDispatched = true; + state.retryResponseDelivered = false; + state.workEvidence = createEmptyTaskAttemptEvidence(); + foregroundSessionState.markBusy(sessionId, state.promptOptions.directory); + void markAttachedSessionBusy(sessionId); + assistantRunState.startRun(sessionId, { + startedAt: Date.now(), + configuredAgent: state.promptOptions.agent, + configuredProviderID: state.promptOptions.model?.providerID, + configuredModelID: state.promptOptions.model?.modelID, + }); + setPromptResponseMode(sessionId, state.responseMode); + if (state.promptText.trim().length > 0) { + externalUserInputSuppressionManager.register(sessionId, state.promptText); + } + + safeBackgroundTask({ + taskName: "session.promptAsync.retry", + task: () => opencodeClient.session.promptAsync(state.promptOptions), + onSuccess: ({ error }) => { + if (!error) { + logger.info(`[Bot] Automatic empty-completion retry accepted: session=${sessionId}`); + return; + } + + logger.error( + `[Bot] Automatic empty-completion retry rejected by OpenCode: session=${sessionId}`, + error, + ); + abandonRetryAttempt(sessionId, state, "session_prompt_retry_api_error"); + }, + onError: (error) => { + logger.error( + `[Bot] Automatic empty-completion retry background failure: session=${sessionId}`, + error, + ); + abandonRetryAttempt(sessionId, state, "session_prompt_retry_background_error"); + }, + }); + + return true; +} + async function isSessionBusy(sessionId: string, directory: string): Promise { try { const { data, error } = await opencodeClient.session.status({ directory }); @@ -95,6 +367,10 @@ async function resetMismatchedSessionContext(): Promise { summaryAggregator.clear(); foregroundSessionState.clearAll("session_mismatch_reset"); assistantRunState.clearAll("session_mismatch_reset"); + const currentSession = getCurrentSession(); + if (currentSession) { + clearPromptRetry(currentSession.id); + } clearAllInteractionState("session_mismatch_reset"); clearSession(); keyboardManager.clearContext(); @@ -287,14 +563,7 @@ export async function processUserPrompt( // above and would otherwise be missing from the logs. const filePartCount = parts.filter((part) => part.type === "file").length; - const promptOptions: { - sessionID: string; - directory: string; - parts: Array; - model?: { providerID: string; modelID: string }; - agent?: string; - variant?: string; - } = { + const promptOptions: PromptDispatchOptions = { sessionID: currentSession.id, directory: currentSession.directory, parts, @@ -338,6 +607,13 @@ export async function processUserPrompt( configuredModelID: storedModel.modelID, }); setPromptResponseMode(currentSession.id, responseMode); + registerPromptRetry(currentSession.id, { + bot, + chatId: ctx.chat!.id, + promptOptions, + promptText: text, + responseMode, + }); if (text.trim().length > 0) { externalUserInputSuppressionManager.register(currentSession.id, text); @@ -353,6 +629,7 @@ export async function processUserPrompt( task: () => opencodeClient.session.promptAsync(promptOptions), onSuccess: ({ error }) => { if (error) { + clearPromptRetry(currentSession.id); foregroundSessionState.markIdle(currentSession.id); void markAttachedSessionIdle(currentSession.id); assistantRunState.clearRun(currentSession.id, "session_prompt_api_error"); @@ -373,6 +650,7 @@ export async function processUserPrompt( logger.info("[Bot] session.promptAsync accepted"); }, onError: (error) => { + clearPromptRetry(currentSession.id); foregroundSessionState.markIdle(currentSession.id); void markAttachedSessionIdle(currentSession.id); assistantRunState.clearRun(currentSession.id, "session_prompt_background_error"); @@ -391,6 +669,7 @@ export async function processUserPrompt( foregroundSessionState.markIdle(currentSession.id); await markAttachedSessionIdle(currentSession.id); assistantRunState.clearRun(currentSession.id, "session_prompt_handler_error"); + clearPromptRetry(currentSession.id); } logger.error("Error in prompt handler:", err); if (interactionManager.getSnapshot()) { diff --git a/src/bot/routers/command-router.ts b/src/bot/routers/command-router.ts index 4e2faf1c..32ab8687 100644 --- a/src/bot/routers/command-router.ts +++ b/src/bot/routers/command-router.ts @@ -21,6 +21,7 @@ import { mcpsCommand } from "../commands/mcp-catalog-command.js"; import { startCommand } from "../commands/start-command.js"; import { helpCommand } from "../commands/help-command.js"; import { statusCommand } from "../commands/status-command.js"; +import { lastfileCommand } from "../commands/lastfile-command.js"; import { BOT_COMMANDS } from "../commands/definitions.js"; import { logger } from "../../utils/logger.js"; import { flushPendingPrompt } from "../handlers/message-merger.js"; @@ -71,6 +72,7 @@ export function registerCommandRouter(bot: Bot, deps: CommandRouterDeps bot.command("start", startCommand); bot.command("help", helpCommand); bot.command("status", statusCommand); + bot.command("lastfile", lastfileCommand); bot.command("settings", settingsCommand); bot.command("opencode_start", opencodeStartCommand); bot.command("opencode_stop", opencodeStopCommand); diff --git a/src/bot/services/assistant-response-export-service.ts b/src/bot/services/assistant-response-export-service.ts new file mode 100644 index 00000000..c513f847 --- /dev/null +++ b/src/bot/services/assistant-response-export-service.ts @@ -0,0 +1,72 @@ +import { InputFile } from "grammy"; +import { config } from "../../config.js"; + +const MAX_CACHED_RESPONSES = 128; + +export interface AssistantResponseExportApi { + sendDocument: (chatId: number, document: InputFile) => Promise; +} + +interface CachedAssistantResponse { + chatId: number; + sessionId: string; + text: string; +} + +const cachedResponses = new Map(); + +function getCacheKey(chatId: number, sessionId: string): string { + return `${chatId}:${sessionId}`; +} + +function createResponseFilename(now: Date): string { + const pad = (value: number): string => String(value).padStart(2, "0"); + + return `opencode-response-${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}.md`; +} + +export function createAssistantResponseDocument( + text: string, + now: Date = new Date(), +): { filename: string; buffer: Buffer } { + return { + filename: createResponseFilename(now), + buffer: Buffer.from(text, "utf8"), + }; +} + +export function rememberAssistantResponse(chatId: number, sessionId: string, text: string): void { + const key = getCacheKey(chatId, sessionId); + cachedResponses.delete(key); + cachedResponses.set(key, { chatId, sessionId, text }); + + while (cachedResponses.size > MAX_CACHED_RESPONSES) { + const oldestKey = cachedResponses.keys().next().value; + if (!oldestKey) { + break; + } + + cachedResponses.delete(oldestKey); + } +} + +export function getRememberedAssistantResponse(chatId: number, sessionId: string): string | null { + return cachedResponses.get(getCacheKey(chatId, sessionId))?.text ?? null; +} + +export async function sendAssistantResponseDocument( + api: AssistantResponseExportApi, + chatId: number, + text: string, +): Promise { + const { filename, buffer } = createAssistantResponseDocument(text); + await api.sendDocument(chatId, new InputFile(buffer, filename)); +} + +export function shouldAutomaticallyExportAssistantResponse(text: string): boolean { + return text.length > config.bot.assistantResponseFileThreshold; +} + +export function __resetAssistantResponseExportsForTests(): void { + cachedResponses.clear(); +} diff --git a/src/bot/services/empty-completion-policy.ts b/src/bot/services/empty-completion-policy.ts new file mode 100644 index 00000000..618ab99d --- /dev/null +++ b/src/bot/services/empty-completion-policy.ts @@ -0,0 +1,147 @@ +import type { TokensInfo } from "../../app/managers/summary-aggregation-manager.js"; + +function isZero(value: number | undefined): boolean { + return value !== undefined && Number.isFinite(value) && value === 0; +} + +function hasNoTokenUsage(evidence: TaskAttemptEvidence): boolean { + return ( + isZero(evidence.tokens?.input) && + isZero(evidence.tokens?.output) && + isZero(evidence.tokens?.reasoning) && + isZero(evidence.tokens?.cacheRead) && + isZero(evidence.tokens?.cacheWrite) + ); +} + +function hasUnknownFinishReason(finishReason: string | undefined): boolean { + const normalized = finishReason?.trim().toLowerCase() ?? ""; + return normalized.length === 0 || ["unknown", "invalid", "none", "null"].includes(normalized); +} + +/** + * Work evidence accumulated across every assistant turn of a single prompt + * attempt. Any unknown value stays undefined so the zero-work check below fails + * closed: if the run cannot be proven to have done nothing, it is never retried. + * `turnCount` is internal aggregation bookkeeping and is only read by the + * accumulator, never by the safety check. + */ +export interface TaskAttemptEvidence { + tokens?: TokensInfo; + cost?: number; + finishReason?: string; + hasToolActivity: boolean; + hasReasoningActivity: boolean; + turnCount?: number; +} + +export function createEmptyTaskAttemptEvidence(): TaskAttemptEvidence { + return { + hasToolActivity: false, + hasReasoningActivity: false, + turnCount: 0, + }; +} + +/** + * Conservative attempt-wide accumulation: activity is OR-ed across turns, and a + * token/cost field only stays known when every turn reported it. The sum is only + * ever compared against zero, so it cannot hide work performed by any turn. + */ +export function mergeTaskAttemptEvidence( + current: TaskAttemptEvidence, + next: TaskAttemptEvidence, +): TaskAttemptEvidence { + const currentTurns = current.turnCount ?? 0; + + let tokens: TokensInfo | undefined; + if (currentTurns === 0) { + tokens = next.tokens; + } else if (current.tokens !== undefined && next.tokens !== undefined) { + tokens = { + input: current.tokens.input + next.tokens.input, + output: current.tokens.output + next.tokens.output, + reasoning: current.tokens.reasoning + next.tokens.reasoning, + cacheRead: current.tokens.cacheRead + next.tokens.cacheRead, + cacheWrite: current.tokens.cacheWrite + next.tokens.cacheWrite, + }; + } + + let cost: number | undefined; + if (currentTurns === 0) { + cost = next.cost; + } else if (current.cost !== undefined && next.cost !== undefined) { + cost = current.cost + next.cost; + } + + return { + tokens, + cost, + finishReason: next.finishReason ?? current.finishReason, + hasToolActivity: current.hasToolActivity || next.hasToolActivity, + hasReasoningActivity: current.hasReasoningActivity || next.hasReasoningActivity, + turnCount: currentTurns + 1, + }; +} + +export function isGenuinelyEmptyAssistantResponse(messageText: string): boolean { + return messageText.trim().length === 0; +} + +export function isSafeZeroWorkEmptyCompletion(evidence: TaskAttemptEvidence): boolean { + return ( + hasNoTokenUsage(evidence) && + isZero(evidence.cost) && + !evidence.hasToolActivity && + !evidence.hasReasoningActivity && + hasUnknownFinishReason(evidence.finishReason) + ); +} + +/** + * Finish reasons OpenCode actually emits on a completed assistant message, + * from the @opencode-ai/llm `FinishReason` literal schema + * (`packages/llm/src/schema/ids.ts`): + * + * ["stop", "length", "tool-calls", "content-filter", "error", "unknown"] + * + * Only `"stop"` means the model ended its turn with a successfully delivered + * answer. Every other value means the run stopped early, called a tool, was + * filtered, errored, or has an unknown reason. session/llm/ai-sdk.ts also maps + * any unrecognized AI SDK finish reason to `"unknown"`, so no other value can + * appear on a real message. + */ +const TERMINAL_FINISH_REASONS = new Set(["stop"]); + +export function isKnownTerminalFinishReason(finishReason: string | undefined): boolean { + if (finishReason === undefined) { + return false; + } + + const normalized = finishReason.trim().toLowerCase(); + return normalized.length > 0 && TERMINAL_FINISH_REASONS.has(normalized); +} + +/** + * A completed non-empty assistant message is a terminal final-response + * candidate only when it has positive evidence of success: no upstream error, + * no tool activity (a message that called a tool is never the closing answer + * of a run), and an explicitly known successful finish reason. Missing, + * unknown, or arbitrary finish reasons fail closed - a non-empty message by + * itself is never treated as a completed task. + */ +export function isTerminalAssistantResponse(completion: { + hasError?: boolean; + hasToolActivity?: boolean; + finishReason?: string; +}): boolean { + if (completion.hasError) { + return false; + } + + if (completion.hasToolActivity) { + return false; + } + + return isKnownTerminalFinishReason(completion.finishReason); +} diff --git a/src/bot/services/event-subscription-service.ts b/src/bot/services/event-subscription-service.ts index bf613939..abc2a983 100644 --- a/src/bot/services/event-subscription-service.ts +++ b/src/bot/services/event-subscription-service.ts @@ -33,7 +33,17 @@ import { logger } from "../../utils/logger.js"; import { safeBackgroundTask } from "../../utils/safe-background-task.js"; import { pinnedMessageManager } from "../pinned/pinned-message-manager.js"; import { keyboardManager } from "../keyboards/keyboard-manager.js"; -import { clearPromptResponseMode } from "../handlers/prompt.js"; +import { + clearAllPromptRetry, + clearPromptResponseMode, + clearPromptRetry, + decidePromptRetryIdle, + getPromptRetryChatId, + handleEmptyCompletion, + hasPromptRetryAttempted, + markPromptRetryResponseDelivered, + recordAttemptEvidence, +} from "../handlers/prompt.js"; import { reconcileBusyState, setPromptResponseModeClearerForReconciliation, @@ -54,7 +64,10 @@ import { import { formatAssistantRunFooter } from "../../app/formatters/assistant-run-footer-formatter.js"; import { foregroundSessionState } from "../../app/managers/foreground-session-state-manager.js"; import { scheduledTaskRuntime } from "../../app/services/scheduled-task-runtime-service.js"; -import { assistantRunState } from "../../app/managers/assistant-run-state-manager.js"; +import { + assistantRunState, + type AssistantRunInfo, +} from "../../app/managers/assistant-run-state-manager.js"; import { ResponseStreamer, type StreamingMessagePayload } from "../streaming/response-streamer.js"; import { ToolCallStreamer, type ToolStreamKey } from "../streaming/tool-call-streamer.js"; import { RunningToolTracker, type RunningToolTick } from "../streaming/running-tool-tracker.js"; @@ -90,6 +103,15 @@ import { interactionManager, } from "../../app/managers/interaction-manager.js"; import { stopEventListening, subscribeToEvents } from "../../opencode/events.js"; +import { + rememberAssistantResponse, + sendAssistantResponseDocument, + shouldAutomaticallyExportAssistantResponse, +} from "./assistant-response-export-service.js"; +import { + isGenuinelyEmptyAssistantResponse, + isTerminalAssistantResponse, +} from "./empty-completion-policy.js"; const TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH = 1024; const RESPONSE_STREAM_THROTTLE_MS = config.bot.responseStreamThrottleMs; @@ -144,6 +166,17 @@ class EventSubscriptionService implements BotEventSubscriptionService { { callId: string; activity: string } >(); private readonly subagentSnapshots = new Map(); + // Terminal assistant response of the in-flight run, keyed by session. A + // candidate is only established by a terminal-eligible (non-empty, no error, + // no tool activity) completion and is invalidated whenever a newer assistant + // message starts; marked null when a completion is empty, so a run that ends + // without a deliverable never overwrites the previous last good response. The + // chatId is captured from the originating prompt rather than the mutable + // service-wide chat context. + private readonly pendingFinalResponses = new Map< + string, + { chatId: number; text: string; messageId: string; hasError: boolean } | null + >(); constructor() { this.runningToolTracker = new RunningToolTracker({ @@ -488,8 +521,10 @@ class EventSubscriptionService implements BotEventSubscriptionService { this.compactProgressFinalizationTasks.clear(); this.thinkingSections.clear(); this.sessionCompletionTasks.clear(); + this.pendingFinalResponses.clear(); this.clearToolElapsedState(null, reason); assistantRunState.clearAll(reason); + clearAllPromptRetry(); } cleanup(reason: string): void { @@ -523,6 +558,16 @@ class EventSubscriptionService implements BotEventSubscriptionService { this.clearToolElapsedState(null, "summary_aggregator_clear"); }); + summaryAggregator.setOnAssistantMessageStarted((sessionId, messageId) => { + // Routed through the same serialized task queue as completions so the + // invalidation is observed in event order: an older completion that set + // the candidate always lands before the start that supersedes it. + void this.enqueueSessionCompletionTask(sessionId, () => { + this.invalidateIntermediateFinalCandidate(sessionId, messageId); + return Promise.resolve(); + }); + }); + summaryAggregator.setOnPartial((sessionId, messageId, messageText) => { if (!this.botInstance || !this.chatIdInstance) { return; @@ -573,6 +618,8 @@ class EventSubscriptionService implements BotEventSubscriptionService { if (!this.botInstance || !this.chatIdInstance) { logger.error("Bot or chat ID not available for sending message"); clearPromptResponseMode(sessionId); + clearPromptRetry(sessionId); + this.pendingFinalResponses.delete(sessionId); this.clearAssistantResponseStream(sessionId, messageId, "bot_context_missing"); this.clearThinkingStream(sessionId, messageId, "bot_context_missing"); this.toolCallStreamer.clearSession(sessionId, "bot_context_missing"); @@ -586,6 +633,8 @@ class EventSubscriptionService implements BotEventSubscriptionService { const currentSession = getCurrentSession(); if (currentSession?.id !== sessionId) { clearPromptResponseMode(sessionId); + clearPromptRetry(sessionId); + this.pendingFinalResponses.delete(sessionId); this.clearAssistantResponseStream(sessionId, messageId, "session_mismatch"); this.clearThinkingStream(sessionId, messageId, "session_mismatch"); this.toolCallStreamer.clearSession(sessionId, "session_mismatch"); @@ -601,11 +650,38 @@ class EventSubscriptionService implements BotEventSubscriptionService { const chatId = this.chatIdInstance; try { - assistantRunState.markResponseCompleted(sessionId, { - agent: completionInfo.agent, - providerID: completionInfo.providerID, - modelID: completionInfo.modelID, - }); + recordAttemptEvidence(sessionId, completionInfo); + + if (isGenuinelyEmptyAssistantResponse(messageText)) { + this.clearAssistantResponseStream(sessionId, messageId, "empty_completion"); + this.clearThinkingStream(sessionId, messageId, "empty_completion"); + this.compactProgressStreamer.clearSession(sessionId, "empty_completion"); + + this.pendingFinalResponses.set(sessionId, null); + const originChatId = getPromptRetryChatId(sessionId) ?? chatId; + const outcome = handleEmptyCompletion(sessionId); + if (outcome === "retried") { + await botApi.sendMessage(originChatId, t("bot.empty_completion_retry")); + } else if (outcome === "failed") { + await botApi.sendMessage(originChatId, t("bot.empty_completion_failed")); + } else if (outcome === "no_retry") { + await botApi.sendMessage(originChatId, t("bot.empty_completion_no_retry")); + } + + return; + } + + const isTerminalResponse = isTerminalAssistantResponse(completionInfo); + if (isTerminalResponse) { + if (hasPromptRetryAttempted(sessionId)) { + markPromptRetryResponseDelivered(sessionId); + } + assistantRunState.markResponseCompleted(sessionId, { + agent: completionInfo.agent, + providerID: completionInfo.providerID, + modelID: completionInfo.modelID, + }); + } await this.completeThinkingStream(sessionId, messageId); @@ -651,10 +727,23 @@ class EventSubscriptionService implements BotEventSubscriptionService { }, }); + // Intermediate commentary, truncated output, or an errored/aborted + // message is streamed so the user sees it, but never becomes the + // /lastfile candidate or the automatic Markdown export. + const originChatId = getPromptRetryChatId(sessionId) ?? chatId; + if (isTerminalResponse) { + this.pendingFinalResponses.set(sessionId, { + chatId: originChatId, + text: messageText, + messageId, + hasError: false, + }); + } + await sendTtsResponseForSession({ api: botApi, sessionId, - chatId, + chatId: originChatId, text: messageText, }); } catch (err) { @@ -1105,66 +1194,29 @@ class EventSubscriptionService implements BotEventSubscriptionService { }); summaryAggregator.setOnSessionIdle(async (sessionId) => { - await markAttachedSessionIdle(sessionId); // Cleared unconditionally: a session can go idle after it stopped being // the current one, and the early returns below would leak the tracker. this.clearToolElapsedState(sessionId, "session_idle"); await this.sessionCompletionTasks.get(sessionId)?.catch(() => undefined); - const completedRun = assistantRunState.finishRun(sessionId, "session_idle"); - clearPromptResponseMode(sessionId); - - if (!this.botInstance || !this.chatIdInstance) { - foregroundSessionState.markIdle(sessionId); - return; - } - - const currentSession = getCurrentSession(); - if (!currentSession || currentSession.id !== sessionId) { - foregroundSessionState.markIdle(sessionId); - await scheduledTaskRuntime.flushDeferredDeliveries(); + const retryDecision = await decidePromptRetryIdle(sessionId); + if (retryDecision === "consumed") { + logger.debug( + `[Bot] Ignoring idle event while a retry lifecycle is active: session=${sessionId}`, + ); return; } - try { - await Promise.all([ - this.toolMessageBatcher.flushSession(sessionId, "session_idle"), - this.toolCallStreamer.flushSession(sessionId, "session_idle"), - ]); - - if (getShowAssistantRunFooter() && completedRun?.hasCompletedResponse) { - const agent = completedRun.actualAgent || completedRun.configuredAgent; - const providerID = completedRun.actualProviderID || completedRun.configuredProviderID; - const modelID = completedRun.actualModelID || completedRun.configuredModelID; - - if (agent && providerID && modelID) { - const keyboard = this.getCurrentReplyKeyboard(); - await this.botInstance.api.sendMessage( - this.chatIdInstance, - formatAssistantRunFooter({ - agent, - providerID, - modelID, - elapsedMs: Date.now() - completedRun.startedAt, - }), - { - ...(keyboard ? { reply_markup: keyboard } : {}), - }, - ); - } - } - } catch (err) { - logger.error("[Bot] Failed to send session idle footer:", err); - } finally { - foregroundSessionState.markIdle(sessionId); - await scheduledTaskRuntime.flushDeferredDeliveries(); - void dispatchNextQueuedPrompt(); - } + // "none" (no retry lifecycle) and "finalize" (genuine retry idle, + // confirmed against the authoritative session status) both finalize. + await this.finalizeIdleSession(sessionId); }); summaryAggregator.setOnSessionError(async (sessionId, message) => { await markAttachedSessionIdle(sessionId); this.clearToolElapsedState(sessionId, "session_error"); + clearPromptRetry(sessionId); + this.pendingFinalResponses.delete(sessionId); if (!this.botInstance || !this.chatIdInstance) { clearPromptResponseMode(sessionId); @@ -1604,6 +1656,125 @@ class EventSubscriptionService implements BotEventSubscriptionService { return nextTask; } + /** + * A newer assistant message starting proves the previous pending candidate + * was only intermediate commentary. The candidate is dropped so a run that + * never delivers a terminal answer cannot commit it as /lastfile, export it, + * or report the task as complete. + */ + private invalidateIntermediateFinalCandidate(sessionId: string, messageId: string): void { + const pending = this.pendingFinalResponses.get(sessionId); + if (!pending || pending.messageId === messageId) { + return; + } + + logger.debug( + `[Bot] Invalidated intermediate final response candidate: session=${sessionId}, supersededMessageId=${pending.messageId}, startedMessageId=${messageId}`, + ); + this.pendingFinalResponses.delete(sessionId); + } + + /** + * Shared idle finalization for a normal run and for a retried run whose idle + * was confirmed against the authoritative OpenCode session status. The + * success footer is emitted only when a terminal final response was actually + * committed, so a run that ended on intermediate or truncated output is never + * announced as a completed task. + */ + private async finalizeIdleSession(sessionId: string): Promise { + await markAttachedSessionIdle(sessionId); + + const completedRun = assistantRunState.finishRun(sessionId, "session_idle"); + clearPromptResponseMode(sessionId); + const committedChatId = await this.commitPendingFinalResponse(sessionId, completedRun); + clearPromptRetry(sessionId); + + if (!this.botInstance || !this.chatIdInstance) { + foregroundSessionState.markIdle(sessionId); + return; + } + + const currentSession = getCurrentSession(); + if (!currentSession || currentSession.id !== sessionId) { + foregroundSessionState.markIdle(sessionId); + await scheduledTaskRuntime.flushDeferredDeliveries(); + return; + } + + try { + await Promise.all([ + this.toolMessageBatcher.flushSession(sessionId, "session_idle"), + this.toolCallStreamer.flushSession(sessionId, "session_idle"), + ]); + + if (getShowAssistantRunFooter() && completedRun && committedChatId !== null) { + const agent = completedRun.actualAgent || completedRun.configuredAgent; + const providerID = completedRun.actualProviderID || completedRun.configuredProviderID; + const modelID = completedRun.actualModelID || completedRun.configuredModelID; + + if (agent && providerID && modelID) { + const keyboard = this.getCurrentReplyKeyboard(); + await this.botInstance.api.sendMessage( + committedChatId, + formatAssistantRunFooter({ + agent, + providerID, + modelID, + elapsedMs: Date.now() - completedRun.startedAt, + }), + { + ...(keyboard ? { reply_markup: keyboard } : {}), + }, + ); + } + } + } catch (err) { + logger.error("[Bot] Failed to send session idle footer:", err); + } finally { + foregroundSessionState.markIdle(sessionId); + await scheduledTaskRuntime.flushDeferredDeliveries(); + void dispatchNextQueuedPrompt(); + } + } + + /** + * Stores the terminal successfully delivered assistant response of a finished + * run for /lastfile and automatic Markdown export, returning the originating + * chat the response is bound to (or null when nothing was committed). + * Intermediate commentary, errored/truncated messages, and empty runs never + * commit, so the previous last good response is preserved. + */ + private async commitPendingFinalResponse( + sessionId: string, + completedRun: AssistantRunInfo | null, + ): Promise { + const pending = this.pendingFinalResponses.get(sessionId); + this.pendingFinalResponses.delete(sessionId); + + if ( + pending === undefined || + pending === null || + pending.hasError || + !completedRun?.hasCompletedResponse || + !this.botInstance + ) { + return null; + } + + rememberAssistantResponse(pending.chatId, sessionId, pending.text); + if (shouldAutomaticallyExportAssistantResponse(pending.text)) { + await sendAssistantResponseDocument(this.botInstance.api, pending.chatId, pending.text).catch( + (error) => { + logger.warn( + `[Bot] Failed to send automatic Markdown response export: session=${sessionId}`, + error, + ); + }, + ); + } + return pending.chatId; + } + private finalizeCompactProgress(sessionId: string): Promise { const existingTask = this.compactProgressFinalizationTasks.get(sessionId); if (existingTask) { diff --git a/src/config.ts b/src/config.ts index c5d37ae4..e03df823 100644 --- a/src/config.ts +++ b/src/config.ts @@ -222,6 +222,10 @@ export const config = { false, ), responseStreamThrottleMs: getOptionalPositiveIntEnvVar("RESPONSE_STREAM_THROTTLE_MS", 1000), + assistantResponseFileThreshold: getOptionalPositiveIntEnvVar( + "ASSISTANT_RESPONSE_FILE_THRESHOLD", + 5000, + ), bashToolDisplayMaxLength: getOptionalPositiveIntEnvVar("BASH_TOOL_DISPLAY_MAX_LENGTH", 128), locale: getOptionalLocaleEnvVar("BOT_LOCALE", "en"), trackBackgroundSessions: getOptionalBooleanEnvVar("TRACK_BACKGROUND_SESSIONS", true), diff --git a/src/i18n/ar.ts b/src/i18n/ar.ts index 22b6895f..f8b5c08a 100644 --- a/src/i18n/ar.ts +++ b/src/i18n/ar.ts @@ -9,6 +9,7 @@ import type { I18nDictionary } from "./en.js"; */ export const ar: I18nDictionary = { "cmd.description.status": "عرض حالة الخادم والجلسة", + "cmd.description.lastfile": "تصدير آخر رد للمساعد", "cmd.description.new": "بدء جلسة جديدة", "cmd.description.stop": "إيقاف المهمة الحالية", "cmd.description.detach": "الخروج من الجلسة دون إيقافها", @@ -86,6 +87,13 @@ export const ar: I18nDictionary = { "bot.session_reset_project_mismatch": "⚠️ الجلسة النشطة مرتبطة بمشروع مختلف، لذلك تمت إعادة ضبطها. استخدم /sessions لاختيار جلسة أو /new لبدء جلسة جديدة.", "bot.prompt_send_error": "تعذر إرسال الطلب إلى OpenCode.", + "bot.empty_completion_retry": "⚠️ أعاد OpenCode إكمالًا فارغًا.\nجارٍ إعادة المحاولة مرة واحدة…", + "bot.empty_completion_failed": + "⚠️ انتهى OpenCode دون رد صالح بعد إعادة محاولة واحدة. لن تتم إعادة المحاولة.", + "bot.empty_completion_no_retry": + "⚠️ انتهى OpenCode دون رد صالح.\nلم تتم إعادة المحاولة تلقائيًا لأن المهمة ربما نفذت عملًا بالفعل. أعد المحاولة يدويًا.", + "bot.lastfile_empty": "لا يوجد رد مساعد مُسلّم بنجاح لهذه الجلسة.", + "bot.lastfile_error": "⚠️ تعذر تصدير آخر رد للمساعد.", "bot.session_error": "🔴 أعاد OpenCode الخطأ التالي: {message}", "bot.session_retry": "🔁 {message}\n\nاستمر مزوّد الخدمة في إرجاع الخطأ نفسه بعد عدة محاولات. استخدم /abort لإيقاف المهمة.", diff --git a/src/i18n/de.ts b/src/i18n/de.ts index aba736c1..d1b03901 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -2,6 +2,7 @@ import type { I18nDictionary } from "./en.js"; export const de: I18nDictionary = { "cmd.description.status": "Server- und Sitzungsstatus", + "cmd.description.lastfile": "Letzte Assistentenantwort exportieren", "cmd.description.new": "Neue Sitzung erstellen", "cmd.description.stop": "Aktuelle Aktion stoppen", "cmd.description.detach": "Von aktueller Sitzung trennen", @@ -86,6 +87,15 @@ export const de: I18nDictionary = { "bot.session_reset_project_mismatch": "⚠️ Die aktive Sitzung passt nicht zum ausgewählten Projekt und wurde daher zurückgesetzt. Nutze /sessions zur Auswahl oder /new, um eine neue Sitzung zu erstellen.", "bot.prompt_send_error": "Anfrage konnte nicht an OpenCode gesendet werden.", + "bot.empty_completion_retry": + "⚠️ OpenCode hat eine leere Antwort geliefert.\nEin erneuter Versuch wird einmal ausgeführt…", + "bot.empty_completion_failed": + "⚠️ OpenCode endete auch nach einem erneuten Versuch ohne brauchbare Antwort. Kein weiterer Versuch.", + "bot.empty_completion_no_retry": + "⚠️ OpenCode endete ohne brauchbare Antwort.\nKein automatischer Versuch, da die Aufgabe möglicherweise bereits Änderungen ausgeführt hat. Bitte manuell erneut versuchen.", + "bot.lastfile_empty": + "Für diese Sitzung ist keine erfolgreich zugestellte Assistentenantwort verfügbar.", + "bot.lastfile_error": "⚠️ Die letzte Assistentenantwort konnte nicht exportiert werden.", "bot.session_error": "🔴 OpenCode meldete einen Fehler: {message}", "bot.session_retry": "🔁 {message}\n\nDer Provider liefert bei wiederholten Versuchen immer wieder denselben Fehler. Mit /abort abbrechen.", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index ba0a444c..cbbfbe80 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -1,5 +1,6 @@ export const en = { "cmd.description.status": "Server and session status", + "cmd.description.lastfile": "Export the last assistant response", "cmd.description.new": "Create a new session", "cmd.description.stop": "Stop current action", "cmd.description.detach": "Detach from current session", @@ -83,6 +84,14 @@ export const en = { "bot.session_reset_project_mismatch": "⚠️ Active session does not match the selected project, so it was reset. Use /sessions to pick one or /new to create a new session.", "bot.prompt_send_error": "Failed to send request to OpenCode.", + "bot.empty_completion_retry": "⚠️ OpenCode returned an empty completion.\nRetrying once…", + "bot.empty_completion_failed": + "⚠️ OpenCode ended without a usable response after one retry. No further retry was attempted.", + "bot.empty_completion_no_retry": + "⚠️ OpenCode ended without a usable response.\nNo automatic retry was attempted because the task may already have performed work. Please retry manually.", + "bot.lastfile_empty": + "No successfully delivered assistant response is available for this session.", + "bot.lastfile_error": "⚠️ Failed to export the last assistant response.", "bot.session_error": "🔴 OpenCode returned an error: {message}", "bot.session_retry": "🔁 {message}\n\nProvider keeps returning the same error on repeated retries. Use /abort to abort.", diff --git a/src/i18n/es.ts b/src/i18n/es.ts index f9f453f2..a385d294 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -2,6 +2,7 @@ import type { I18nDictionary } from "./en.js"; export const es: I18nDictionary = { "cmd.description.status": "Estado del servidor y de la sesión", + "cmd.description.lastfile": "Exportar la última respuesta del asistente", "cmd.description.new": "Crear una sesión nueva", "cmd.description.stop": "Detener la acción actual", "cmd.description.detach": "Desconectar de la sesión actual", @@ -87,6 +88,14 @@ export const es: I18nDictionary = { "bot.session_reset_project_mismatch": "⚠️ La sesión activa no coincide con el proyecto seleccionado, así que se reinició. Usa /sessions para elegir una o /new para crear una nueva.", "bot.prompt_send_error": "No se pudo enviar la solicitud a OpenCode.", + "bot.empty_completion_retry": "⚠️ OpenCode devolvió una respuesta vacía.\nReintentando una vez…", + "bot.empty_completion_failed": + "⚠️ OpenCode terminó sin una respuesta utilizable después de un reintento. No habrá más reintentos.", + "bot.empty_completion_no_retry": + "⚠️ OpenCode terminó sin una respuesta utilizable.\nNo se reintentó automáticamente porque la tarea quizá ya realizó cambios. Inténtalo de nuevo manualmente.", + "bot.lastfile_empty": + "No hay una respuesta del asistente entregada correctamente para esta sesión.", + "bot.lastfile_error": "⚠️ No se pudo exportar la última respuesta del asistente.", "bot.session_error": "🔴 OpenCode devolvió un error: {message}", "bot.session_retry": "🔁 {message}\n\nEl proveedor devuelve el mismo error en intentos repetidos. Usa /abort para detenerlo.", diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts index e7e17e62..f5387652 100644 --- a/src/i18n/fr.ts +++ b/src/i18n/fr.ts @@ -2,6 +2,7 @@ import type { I18nDictionary } from "./en.js"; export const fr: I18nDictionary = { "cmd.description.status": "Statut du serveur et de la session", + "cmd.description.lastfile": "Exporter la dernière réponse de l’assistant", "cmd.description.new": "Créer une nouvelle session", "cmd.description.stop": "Arrêter l'action en cours", "cmd.description.detach": "Se détacher de la session actuelle", @@ -86,6 +87,15 @@ export const fr: I18nDictionary = { "bot.session_reset_project_mismatch": "⚠️ La session active ne correspond pas au projet sélectionné, elle a donc été réinitialisée. Utilisez /sessions pour en choisir une ou /new pour créer une nouvelle session.", "bot.prompt_send_error": "Impossible d'envoyer la requête à OpenCode.", + "bot.empty_completion_retry": + "⚠️ OpenCode a renvoyé une réponse vide.\nNouvelle tentative unique…", + "bot.empty_completion_failed": + "⚠️ OpenCode s’est terminé sans réponse exploitable après une nouvelle tentative. Aucune autre tentative.", + "bot.empty_completion_no_retry": + "⚠️ OpenCode s’est terminé sans réponse exploitable.\nAucune nouvelle tentative automatique, car la tâche a peut-être déjà effectué des changements. Réessayez manuellement.", + "bot.lastfile_empty": + "Aucune réponse de l’assistant livrée avec succès n’est disponible pour cette session.", + "bot.lastfile_error": "⚠️ Échec de l’export de la dernière réponse de l’assistant.", "bot.session_error": "🔴 OpenCode a renvoyé une erreur : {message}", "bot.session_retry": "🔁 {message}\n\nLe fournisseur renvoie la même erreur à chaque nouvelle tentative. Utilisez /abort pour arrêter.", diff --git a/src/i18n/it.ts b/src/i18n/it.ts index 09e88d3a..ffbc524a 100644 --- a/src/i18n/it.ts +++ b/src/i18n/it.ts @@ -2,6 +2,7 @@ import type { I18nDictionary } from "./en.js"; export const it: I18nDictionary = { "cmd.description.status": "Stato del server e della sessione", + "cmd.description.lastfile": "Esporta l'ultima risposta dell'assistente", "cmd.description.new": "Crea una nuova sessione", "cmd.description.stop": "Interrompi l'azione corrente", "cmd.description.detach": "Scollega la sessione corrente", @@ -88,6 +89,15 @@ export const it: I18nDictionary = { "bot.session_reset_project_mismatch": "⚠️ La sessione attiva non corrisponde al progetto selezionato, quindi è stata reimpostata. Usa /sessions per sceglierne una o /new per crearne una nuova.", "bot.prompt_send_error": "Invio della richiesta a OpenCode non riuscito.", + "bot.empty_completion_retry": + "⚠️ OpenCode ha restituito una risposta vuota.\nNuovo tentativo, una sola volta…", + "bot.empty_completion_failed": + "⚠️ OpenCode è terminato senza una risposta utilizzabile dopo un nuovo tentativo. Nessun altro tentativo.", + "bot.empty_completion_no_retry": + "⚠️ OpenCode è terminato senza una risposta utilizzabile.\nNessun nuovo tentativo automatico: l'attività potrebbe aver già eseguito modifiche. Riprova manualmente.", + "bot.lastfile_empty": + "Non è disponibile alcuna risposta dell'assistente consegnata correttamente per questa sessione.", + "bot.lastfile_error": "⚠️ Impossibile esportare l'ultima risposta dell'assistente.", "bot.session_error": "🔴 OpenCode ha restituito un errore: {message}", "bot.session_retry": "🔁 {message}\n\nIl provider restituisce sempre lo stesso errore dopo ripetuti tentativi. Usa /abort per annullare.", diff --git a/src/i18n/pt.ts b/src/i18n/pt.ts index f0adda4b..0a66d37d 100644 --- a/src/i18n/pt.ts +++ b/src/i18n/pt.ts @@ -2,6 +2,7 @@ import type { I18nDictionary } from "./en.js"; export const pt: I18nDictionary = { "cmd.description.status": "Status do servidor e da sessão", + "cmd.description.lastfile": "Exportar a última resposta do assistente", "cmd.description.new": "Criar uma nova sessão", "cmd.description.stop": "Parar a ação atual", "cmd.description.detach": "Desconectar da sessão atual", @@ -86,6 +87,15 @@ export const pt: I18nDictionary = { "bot.session_reset_project_mismatch": "⚠️ A sessão ativa não corresponde ao projeto selecionado, então ela foi redefinida. Use /sessions para escolher uma ou /new para criar uma nova sessão.", "bot.prompt_send_error": "Não foi possível enviar a solicitação ao OpenCode.", + "bot.empty_completion_retry": + "⚠️ O OpenCode retornou uma conclusão vazia.\nTentando novamente uma vez…", + "bot.empty_completion_failed": + "⚠️ O OpenCode terminou sem uma resposta utilizável após uma nova tentativa. Nenhuma outra tentativa será feita.", + "bot.empty_completion_no_retry": + "⚠️ O OpenCode terminou sem uma resposta utilizável.\nNenhuma nova tentativa automática: a tarefa pode já ter realizado alterações. Tente novamente manualmente.", + "bot.lastfile_empty": + "Não há uma resposta do assistente entregue com sucesso disponível para esta sessão.", + "bot.lastfile_error": "⚠️ Não foi possível exportar a última resposta do assistente.", "bot.session_error": "🔴 O OpenCode retornou um erro: {message}", "bot.session_retry": "🔁 {message}\n\nO provedor continua retornando o mesmo erro nas novas tentativas. Use /abort para abortar.", diff --git a/src/i18n/ru.ts b/src/i18n/ru.ts index b3780e8a..8ff80d51 100644 --- a/src/i18n/ru.ts +++ b/src/i18n/ru.ts @@ -2,6 +2,7 @@ import type { I18nDictionary } from "./en.js"; export const ru: I18nDictionary = { "cmd.description.status": "Статус сервера и сессии", + "cmd.description.lastfile": "Экспортировать последний ответ ассистента", "cmd.description.new": "Создать новую сессию", "cmd.description.stop": "Прервать текущее действие", "cmd.description.detach": "Отсоединиться от текущей сессии", @@ -82,6 +83,13 @@ export const ru: I18nDictionary = { "bot.session_reset_project_mismatch": "⚠️ Активная сессия не соответствует выбранному проекту, поэтому была сброшена. Используйте /sessions для выбора или /new для создания новой сессии.", "bot.prompt_send_error": "Не удалось отправить запрос в OpenCode.", + "bot.empty_completion_retry": "⚠️ OpenCode вернул пустой ответ.\nПовторяю один раз…", + "bot.empty_completion_failed": + "⚠️ OpenCode завершил работу без пригодного ответа после одной повторной попытки. Дальше повторов не будет.", + "bot.empty_completion_no_retry": + "⚠️ OpenCode завершил работу без пригодного ответа.\nАвтоматический повтор не выполнен, поскольку задача могла уже внести изменения. Повторите вручную.", + "bot.lastfile_empty": "Для этой сессии нет успешно доставленного ответа ассистента.", + "bot.lastfile_error": "⚠️ Не удалось экспортировать последний ответ ассистента.", "bot.session_error": "🔴 OpenCode вернул ошибку: {message}", "bot.session_retry": "🔁 {message}\n\nПровайдер возвращает одну и ту же ошибку при повторных запросах. Используйте /abort для остановки.", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 3b511d87..bd7428e0 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -2,6 +2,7 @@ import type { I18nDictionary } from "./en.js"; export const zh: I18nDictionary = { "cmd.description.status": "服务器和会话状态", + "cmd.description.lastfile": "导出助手的最后一条回复", "cmd.description.new": "创建新会话", "cmd.description.stop": "停止当前操作", "cmd.description.detach": "从当前会话分离", @@ -75,6 +76,12 @@ export const zh: I18nDictionary = { "bot.session_reset_project_mismatch": "⚠️ 活动会话与所选项目不匹配,因此已重置。使用 /sessions 选择一个会话,或 /new 创建新会话。", "bot.prompt_send_error": "向 OpenCode 发送请求失败。", + "bot.empty_completion_retry": "⚠️ OpenCode 返回了空完成结果。\n将自动重试一次…", + "bot.empty_completion_failed": "⚠️ OpenCode 重试一次后仍未返回可用内容。不会继续重试。", + "bot.empty_completion_no_retry": + "⚠️ OpenCode 结束时没有可用回复。\n未自动重试,因为任务可能已经执行了操作。请手动重试。", + "bot.lastfile_empty": "当前会话没有可导出的已成功发送的助手回复。", + "bot.lastfile_error": "⚠️ 导出助手最后一条回复失败。", "bot.session_error": "🔴 OpenCode 返回错误:{message}", "bot.session_retry": "🔁 {message}\n\n提供方在重复重试时持续返回同一错误。使用 /abort 可停止。", "bot.external_user_input": "外部用户输入", diff --git a/tests/app/managers/summary-aggregation-manager.test.ts b/tests/app/managers/summary-aggregation-manager.test.ts index fb3d41f4..9b143247 100644 --- a/tests/app/managers/summary-aggregation-manager.test.ts +++ b/tests/app/managers/summary-aggregation-manager.test.ts @@ -2144,6 +2144,96 @@ describe("summary/aggregator", () => { expect(onComplete).not.toHaveBeenCalled(); }); + it("reports an empty completed response at session idle with final metadata", () => { + const onComplete = vi.fn(); + summaryAggregator.setOnComplete(onComplete); + summaryAggregator.setSession("session-empty-final"); + + summaryAggregator.processEvent({ + type: "message.updated", + properties: { + info: { + id: "message-empty-final", + sessionID: "session-empty-final", + role: "assistant", + finish: "unknown", + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + time: { created: 1 }, + }, + }, + } as unknown as Event); + + for (const type of ["step-start", "step-finish"] as const) { + summaryAggregator.processEvent({ + type: "message.part.updated", + properties: { + part: { + id: `${type}-1`, + sessionID: "session-empty-final", + messageID: "message-empty-final", + type, + ...(type === "step-finish" + ? { + reason: "unknown", + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + } + : {}), + }, + }, + } as unknown as Event); + } + + summaryAggregator.processEvent({ + type: "message.updated", + properties: { + info: { + id: "message-empty-final", + sessionID: "session-empty-final", + role: "assistant", + finish: "unknown", + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + time: { created: 1, completed: 2 }, + }, + }, + } as unknown as Event); + + summaryAggregator.processEvent({ + type: "session.idle", + properties: { sessionID: "session-empty-final" }, + } as unknown as Event); + + expect(onComplete).toHaveBeenCalledWith( + "session-empty-final", + "message-empty-final", + "", + expect.objectContaining({ + finishReason: "unknown", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 }, + hasToolActivity: false, + hasReasoningActivity: false, + }), + ); + }); + it("drops the empty-response placeholder while it is still streaming in", () => { const onPartial = vi.fn(); summaryAggregator.setOnPartial(onPartial); diff --git a/tests/bot/commands/abort.test.ts b/tests/bot/commands/abort.test.ts index 5e2725b4..ce49e307 100644 --- a/tests/bot/commands/abort.test.ts +++ b/tests/bot/commands/abort.test.ts @@ -24,6 +24,7 @@ const mocked = vi.hoisted(() => ({ clearRunMock: vi.fn(), markAttachedSessionIdleMock: vi.fn(), clearPromptResponseModeMock: vi.fn(), + clearPromptRetryMock: vi.fn(), })); vi.mock("../../../src/app/services/session-service.js", () => ({ @@ -51,6 +52,7 @@ vi.mock("../../../src/app/services/attach-service.js", () => ({ vi.mock("../../../src/bot/handlers/prompt.js", () => ({ clearPromptResponseMode: mocked.clearPromptResponseModeMock, + clearPromptRetry: mocked.clearPromptRetryMock, })); const TEST_QUESTION: Question = { @@ -93,6 +95,7 @@ describe("bot/commands/abort", () => { mocked.markAttachedSessionIdleMock.mockReset(); mocked.markAttachedSessionIdleMock.mockResolvedValue(undefined); mocked.clearPromptResponseModeMock.mockReset(); + mocked.clearPromptRetryMock.mockReset(); __resetUserAbortErrorSuppressionForTests(); }); @@ -105,6 +108,7 @@ describe("bot/commands/abort", () => { expect(mocked.clearRunMock).toHaveBeenCalledWith("session-1", reason); expect(mocked.markAttachedSessionIdleMock).toHaveBeenCalledWith("session-1"); expect(mocked.clearPromptResponseModeMock).toHaveBeenCalledWith("session-1"); + expect(mocked.clearPromptRetryMock).toHaveBeenCalledWith("session-1"); } it("clears interaction state even when there is no active session", async () => { diff --git a/tests/bot/commands/detach.test.ts b/tests/bot/commands/detach.test.ts index 4404c4af..6dcb89ad 100644 --- a/tests/bot/commands/detach.test.ts +++ b/tests/bot/commands/detach.test.ts @@ -19,6 +19,7 @@ const mocked = vi.hoisted(() => ({ foregroundMarkIdleMock: vi.fn(), assistantClearRunMock: vi.fn(), clearPromptResponseModeMock: vi.fn(), + clearPromptRetryMock: vi.fn(), })); vi.mock("../../../src/app/stores/settings-store.js", () => ({ @@ -70,6 +71,7 @@ vi.mock("../../../src/app/managers/assistant-run-state-manager.js", () => ({ vi.mock("../../../src/bot/handlers/prompt.js", () => ({ clearPromptResponseMode: mocked.clearPromptResponseModeMock, + clearPromptRetry: mocked.clearPromptRetryMock, })); function createContext(): Context { @@ -107,6 +109,7 @@ describe("bot/commands/detach", () => { mocked.foregroundMarkIdleMock.mockClear(); mocked.assistantClearRunMock.mockClear(); mocked.clearPromptResponseModeMock.mockClear(); + mocked.clearPromptRetryMock.mockClear(); }); it("detaches selected session locally without stopping the OpenCode session", async () => { @@ -120,6 +123,7 @@ describe("bot/commands/detach", () => { expect(mocked.foregroundMarkIdleMock).toHaveBeenCalledWith("session-1"); expect(mocked.assistantClearRunMock).toHaveBeenCalledWith("session-1", "detach_command"); expect(mocked.clearPromptResponseModeMock).toHaveBeenCalledWith("session-1"); + expect(mocked.clearPromptRetryMock).toHaveBeenCalledWith("session-1"); expect(mocked.pinnedClearMock).toHaveBeenCalledTimes(1); expect(mocked.pinnedRefreshContextLimitMock).toHaveBeenCalledTimes(1); expect(mocked.pinnedGetContextLimitMock).toHaveBeenCalledTimes(1); diff --git a/tests/bot/commands/lastfile.test.ts b/tests/bot/commands/lastfile.test.ts new file mode 100644 index 00000000..e13df1fd --- /dev/null +++ b/tests/bot/commands/lastfile.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Context } from "grammy"; + +const mocked = vi.hoisted(() => ({ + currentSessionMock: vi.fn(), + getResponseMock: vi.fn(), + sendDocumentMock: vi.fn(), +})); + +vi.mock("../../../src/app/services/session-service.js", () => ({ + getCurrentSession: mocked.currentSessionMock, +})); + +vi.mock("../../../src/bot/services/assistant-response-export-service.js", () => ({ + getRememberedAssistantResponse: mocked.getResponseMock, + sendAssistantResponseDocument: mocked.sendDocumentMock, +})); + +import { lastfileCommand } from "../../../src/bot/commands/lastfile-command.js"; + +function createContext(): Context { + return { + chat: { id: 123 }, + api: {}, + reply: vi.fn().mockResolvedValue(undefined), + } as unknown as Context; +} + +describe("/lastfile", () => { + beforeEach(() => { + mocked.currentSessionMock.mockReset(); + mocked.getResponseMock.mockReset(); + mocked.sendDocumentMock.mockReset(); + mocked.currentSessionMock.mockReturnValue({ id: "session-1" }); + }); + + it("explains when no response is available", async () => { + mocked.getResponseMock.mockReturnValue(null); + const ctx = createContext(); + + await lastfileCommand(ctx); + + expect(ctx.reply).toHaveBeenCalledWith( + "No successfully delivered assistant response is available for this session.", + ); + }); + + it("exports only the current chat and session response", async () => { + mocked.getResponseMock.mockReturnValue("# Final answer"); + const ctx = createContext(); + + await lastfileCommand(ctx); + + expect(mocked.getResponseMock).toHaveBeenCalledWith(123, "session-1"); + expect(mocked.sendDocumentMock).toHaveBeenCalledWith(ctx.api, 123, "# Final answer"); + }); +}); diff --git a/tests/bot/handlers/prompt.test.ts b/tests/bot/handlers/prompt.test.ts index 2a5fd6ef..6c6f601f 100644 --- a/tests/bot/handlers/prompt.test.ts +++ b/tests/bot/handlers/prompt.test.ts @@ -2,7 +2,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Bot, Context } from "grammy"; import { consumePromptResponseMode, + hasPromptRetryAttempted, processUserPrompt, + retryPromptOnce, type ProcessPromptDeps, } from "../../../src/bot/handlers/prompt.js"; import { promptAttachment } from "../../../src/app/managers/prompt-attachment-manager.js"; @@ -238,6 +240,25 @@ describe("bot/handlers/prompt", () => { expect(mocked.suppressionRegisterMock).toHaveBeenCalledWith("session-1", "Review README"); }); + it("replays the same prompt at most once", async () => { + await processUserPrompt(createContext(), "Review README", createDeps()); + + expect(retryPromptOnce("session-1")).toBe(true); + expect(retryPromptOnce("session-1")).toBe(false); + expect(hasPromptRetryAttempted("session-1")).toBe(true); + expect(mocked.safeBackgroundTaskMock).toHaveBeenCalledTimes(2); + expect(mocked.safeBackgroundTaskMock.mock.calls[1][0].task).toBeTypeOf("function"); + await mocked.safeBackgroundTaskMock.mock.calls[1][0].task(); + expect(mocked.sessionPromptAsyncMock).toHaveBeenLastCalledWith({ + sessionID: "session-1", + directory: "D:\\Projects\\Repo", + parts: [{ type: "text", text: "Review README" }], + agent: "build", + model: { providerID: "openai", modelID: "gpt-5" }, + variant: "default", + }); + }); + it("starts prompts through promptAsync instead of the streaming prompt endpoint", async () => { const handled = await processUserPrompt(createContext(), "Review README", createDeps()); diff --git a/tests/bot/routers/command-router.test.ts b/tests/bot/routers/command-router.test.ts index 61fb2653..5b9b2daa 100644 --- a/tests/bot/routers/command-router.test.ts +++ b/tests/bot/routers/command-router.test.ts @@ -25,6 +25,7 @@ describe("bot/routers/command-router", () => { "start", "help", "status", + "lastfile", "settings", "opencode_start", "opencode_stop", diff --git a/tests/bot/services/assistant-response-export-service.test.ts b/tests/bot/services/assistant-response-export-service.test.ts new file mode 100644 index 00000000..d17ba7cb --- /dev/null +++ b/tests/bot/services/assistant-response-export-service.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { + __resetAssistantResponseExportsForTests, + createAssistantResponseDocument, + getRememberedAssistantResponse, + rememberAssistantResponse, + shouldAutomaticallyExportAssistantResponse, +} from "../../../src/bot/services/assistant-response-export-service.js"; + +describe("assistant response Markdown exports", () => { + beforeEach(() => { + __resetAssistantResponseExportsForTests(); + }); + + it("does not automatically export responses at or below the default threshold", () => { + expect(shouldAutomaticallyExportAssistantResponse("a".repeat(5000))).toBe(false); + }); + + it("automatically exports responses above the default threshold", () => { + expect(shouldAutomaticallyExportAssistantResponse("a".repeat(5001))).toBe(true); + }); + + it("preserves UTF-8 Markdown exactly once in the document buffer", () => { + const text = '# Résumé\n\n```ts\nconst value = "日本語";\n```'; + const document = createAssistantResponseDocument(text, new Date(2026, 7, 10, 19, 45)); + + expect(document.filename).toBe("opencode-response-2026-08-10-1945.md"); + expect(document.buffer.toString("utf8")).toBe(text); + }); + + it("keeps only the latest response per chat and session", () => { + rememberAssistantResponse(10, "session-a", "first"); + rememberAssistantResponse(10, "session-a", "second"); + rememberAssistantResponse(10, "session-b", "other chat session"); + rememberAssistantResponse(11, "session-a", "other chat"); + + expect(getRememberedAssistantResponse(10, "session-a")).toBe("second"); + expect(getRememberedAssistantResponse(10, "session-b")).toBe("other chat session"); + expect(getRememberedAssistantResponse(11, "session-a")).toBe("other chat"); + expect(getRememberedAssistantResponse(12, "session-a")).toBeNull(); + }); +}); diff --git a/tests/bot/services/empty-completion-policy.test.ts b/tests/bot/services/empty-completion-policy.test.ts new file mode 100644 index 00000000..b5d6dd03 --- /dev/null +++ b/tests/bot/services/empty-completion-policy.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from "vitest"; +import type { MessageCompletionInfo } from "../../../src/app/managers/summary-aggregation-manager.js"; +import { + createEmptyTaskAttemptEvidence, + isGenuinelyEmptyAssistantResponse, + isSafeZeroWorkEmptyCompletion, + isTerminalAssistantResponse, + mergeTaskAttemptEvidence, +} from "../../../src/bot/services/empty-completion-policy.js"; + +function createInfo(overrides: Partial = {}): MessageCompletionInfo { + return { + tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 }, + cost: 0, + finishReason: "unknown", + hasToolActivity: false, + hasReasoningActivity: false, + ...overrides, + }; +} + +describe("empty completion policy", () => { + it("recognizes the observed zero-work empty completion", () => { + expect(isGenuinelyEmptyAssistantResponse(" ")).toBe(true); + expect(isSafeZeroWorkEmptyCompletion(createInfo())).toBe(true); + }); + + it("does not classify meaningful assistant text as empty", () => { + expect(isGenuinelyEmptyAssistantResponse("Useful answer")).toBe(false); + }); + + it("does not retry when tool activity occurred", () => { + expect(isSafeZeroWorkEmptyCompletion(createInfo({ hasToolActivity: true }))).toBe(false); + }); + + it("does not retry when reasoning activity occurred", () => { + expect(isSafeZeroWorkEmptyCompletion(createInfo({ hasReasoningActivity: true }))).toBe(false); + }); + + it("does not retry an empty response with non-zero work metadata", () => { + expect( + isSafeZeroWorkEmptyCompletion( + createInfo({ tokens: { input: 1, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 } }), + ), + ).toBe(false); + expect(isSafeZeroWorkEmptyCompletion(createInfo({ finishReason: "stop" }))).toBe(false); + }); + + it("fails closed when any turn is missing token or cost data", () => { + expect(isSafeZeroWorkEmptyCompletion(createInfo({ tokens: undefined }))).toBe(false); + expect(isSafeZeroWorkEmptyCompletion(createInfo({ cost: undefined }))).toBe(false); + }); + + describe("attempt-wide evidence aggregation", () => { + it("keeps a single zero-work empty turn retry-safe", () => { + const evidence = mergeTaskAttemptEvidence( + createEmptyTaskAttemptEvidence(), + createInfo(), + ); + expect(isSafeZeroWorkEmptyCompletion(evidence)).toBe(true); + }); + + it("blocks retry when an earlier turn used a tool", () => { + const earlier = createInfo({ hasToolActivity: true, tokens: { ...createInfo().tokens! } }); + const evidence = mergeTaskAttemptEvidence(createEmptyTaskAttemptEvidence(), earlier); + const final = mergeTaskAttemptEvidence(evidence, createInfo()); + expect(isSafeZeroWorkEmptyCompletion(final)).toBe(false); + }); + + it("blocks retry when an earlier turn reasoned", () => { + const earlier = createInfo({ hasReasoningActivity: true }); + const evidence = mergeTaskAttemptEvidence(createEmptyTaskAttemptEvidence(), earlier); + const final = mergeTaskAttemptEvidence(evidence, createInfo()); + expect(isSafeZeroWorkEmptyCompletion(final)).toBe(false); + }); + + it("blocks retry when an earlier turn consumed tokens even if the final is empty", () => { + const earlier = createInfo({ + tokens: { input: 42, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 }, + }); + const evidence = mergeTaskAttemptEvidence(createEmptyTaskAttemptEvidence(), earlier); + const final = mergeTaskAttemptEvidence(evidence, createInfo()); + expect(isSafeZeroWorkEmptyCompletion(final)).toBe(false); + }); + + it("blocks retry when any turn lacked token or cost reporting", () => { + const missingTokens = createInfo({ tokens: undefined }); + const evidence = mergeTaskAttemptEvidence(createEmptyTaskAttemptEvidence(), missingTokens); + expect(isSafeZeroWorkEmptyCompletion(mergeTaskAttemptEvidence(evidence, createInfo()))).toBe( + false, + ); + + const missingCost = createInfo({ cost: undefined }); + const evidence2 = mergeTaskAttemptEvidence(createEmptyTaskAttemptEvidence(), missingCost); + expect(isSafeZeroWorkEmptyCompletion(mergeTaskAttemptEvidence(evidence2, createInfo()))).toBe( + false, + ); + }); + + it("uses the last known finish reason and ORs activity flags", () => { + const evidence = mergeTaskAttemptEvidence( + createEmptyTaskAttemptEvidence(), + createInfo({ finishReason: "stop" }), + ); + const final = mergeTaskAttemptEvidence(evidence, createInfo({ finishReason: "length" })); + expect(final.finishReason).toBe("length"); + expect(final.hasToolActivity).toBe(false); + + const tooled = mergeTaskAttemptEvidence( + createEmptyTaskAttemptEvidence(), + createInfo({ hasToolActivity: true }), + ); + expect(mergeTaskAttemptEvidence(tooled, createInfo()).hasToolActivity).toBe(true); + }); + }); + + describe("terminal response detection", () => { + it("treats a clean non-empty completion with the successful finish reason as terminal", () => { + expect(isTerminalAssistantResponse({ hasError: false, hasToolActivity: false, finishReason: "stop" })).toBe( + true, + ); + expect(isTerminalAssistantResponse({ finishReason: "stop" })).toBe(true); + }); + + it("rejects an errored or aborted completion even with a successful finish", () => { + expect(isTerminalAssistantResponse({ hasError: true, finishReason: "stop" })).toBe(false); + expect(isTerminalAssistantResponse({ hasError: true, hasToolActivity: false, finishReason: "stop" })).toBe( + false, + ); + }); + + it("rejects a completion whose message called a tool even with a successful finish", () => { + expect(isTerminalAssistantResponse({ hasToolActivity: true, finishReason: "stop" })).toBe(false); + }); + + it("fails closed when the finish reason is missing", () => { + expect(isTerminalAssistantResponse({ finishReason: undefined })).toBe(false); + expect(isTerminalAssistantResponse({})).toBe(false); + expect(isTerminalAssistantResponse({ hasError: false, hasToolActivity: false })).toBe(false); + }); + + it("rejects unknown and arbitrary unexpected finish reasons", () => { + expect(isTerminalAssistantResponse({ finishReason: "unknown" })).toBe(false); + expect(isTerminalAssistantResponse({ finishReason: "unusual-reason" })).toBe(false); + expect(isTerminalAssistantResponse({ finishReason: "end_turn" })).toBe(false); + expect(isTerminalAssistantResponse({ finishReason: "" })).toBe(false); + }); + + it("rejects every known non-terminal OpenCode finish reason", () => { + // The authoritative @opencode-ai/llm FinishReason literal schema. + for (const reason of ["length", "tool-calls", "content-filter", "error", "unknown"]) { + expect(isTerminalAssistantResponse({ finishReason: reason })).toBe(false); + } + // Reviewer-flagged truncation/interruption names must also fail closed. + for (const reason of ["max_tokens", "aborted", "tool_use", "function_call", "incomplete"]) { + expect(isTerminalAssistantResponse({ finishReason: reason })).toBe(false); + } + }); + + it("fails closed when error and tool activity are combined with a successful finish", () => { + expect( + isTerminalAssistantResponse({ + hasError: true, + hasToolActivity: false, + finishReason: "stop", + }), + ).toBe(false); + expect( + isTerminalAssistantResponse({ + hasError: false, + hasToolActivity: true, + finishReason: "stop", + }), + ).toBe(false); + }); + }); +}); diff --git a/tests/bot/services/event-subscription-service.lifecycle.test.ts b/tests/bot/services/event-subscription-service.lifecycle.test.ts index 56f4721b..b3dc2421 100644 --- a/tests/bot/services/event-subscription-service.lifecycle.test.ts +++ b/tests/bot/services/event-subscription-service.lifecycle.test.ts @@ -98,6 +98,8 @@ function emitAssistantCompleted(aggregator: Aggregator): void { agent: "test-agent", providerID: "test-provider", modelID: "test-model", + // The authoritative OpenCode message finish for a successful run. + finish: "stop", time: { created: Date.now() - 1000, completed: Date.now() }, }, }, diff --git a/tests/bot/services/event-subscription-service.test.ts b/tests/bot/services/event-subscription-service.test.ts index fc7f636f..f560a4e7 100644 --- a/tests/bot/services/event-subscription-service.test.ts +++ b/tests/bot/services/event-subscription-service.test.ts @@ -10,6 +10,10 @@ import { resetSingletonState } from "../../helpers/reset-singleton-state.js"; const mocked = vi.hoisted(() => ({ subscribeToEvents: vi.fn(), stopEventListening: vi.fn(), + safeBackgroundTask: vi.fn(), + dispatchNextQueuedPrompt: vi.fn(), + promptAsyncMock: vi.fn(), + sessionStatusMock: vi.fn(), })); vi.mock("../../../src/opencode/events.js", () => ({ @@ -17,6 +21,24 @@ vi.mock("../../../src/opencode/events.js", () => ({ stopEventListening: mocked.stopEventListening, })); +vi.mock("../../../src/opencode/client.js", () => ({ + opencodeClient: { + session: { + status: mocked.sessionStatusMock, + promptAsync: mocked.promptAsyncMock, + }, + }, +})); + +vi.mock("../../../src/utils/safe-background-task.js", () => ({ + safeBackgroundTask: mocked.safeBackgroundTask, +})); + +vi.mock("../../../src/bot/handlers/prompt-queue-dispatch.js", () => ({ + dispatchNextQueuedPrompt: mocked.dispatchNextQueuedPrompt, + __resetPromptQueueDispatchForTests: () => {}, +})); + type FakeBotApi = { sendMessage: ReturnType; sendMessageDraft: ReturnType; @@ -122,6 +144,8 @@ function emitAssistantCompleted(summaryAggregator: { processEvent(event: Event): agent: "test-agent", providerID: "test-provider", modelID: "test-model", + // The authoritative OpenCode message finish for a successful run. + finish: "stop", time: { created: Date.now() - 1000, completed: Date.now() }, }, }, @@ -330,6 +354,15 @@ describe("bot/services/event-subscription-service", () => { mocked.subscribeToEvents.mockReset(); mocked.stopEventListening.mockReset(); mocked.subscribeToEvents.mockResolvedValue(undefined); + mocked.safeBackgroundTask.mockReset(); + mocked.dispatchNextQueuedPrompt.mockReset(); + mocked.promptAsyncMock.mockReset(); + mocked.promptAsyncMock.mockResolvedValue({ data: {}, error: null }); + mocked.sessionStatusMock.mockReset(); + mocked.sessionStatusMock.mockResolvedValue({ data: {}, error: null }); + + const exportService = await import("../../../src/bot/services/assistant-response-export-service.js"); + exportService.__resetAssistantResponseExportsForTests(); const settingsStore = await import("../../../src/app/stores/settings-store.js"); settingsStore.__resetSettingsForTests(); @@ -362,6 +395,7 @@ describe("bot/services/event-subscription-service", () => { } = {}, ): Promise<{ api: FakeBotApi; + service: ReturnType; summaryAggregator: { setSession(sessionId: string): void; processEvent(event: Event): void }; }> { const [ @@ -406,7 +440,7 @@ describe("bot/services/event-subscription-service", () => { summaryAggregator.setSession("session-1"); emitAssistantMessage(summaryAggregator); - return { api, summaryAggregator }; + return { api, service, summaryAggregator }; } it("sends write tool output as a document attachment when diff files are enabled", async () => { @@ -785,4 +819,834 @@ describe("bot/services/event-subscription-service", () => { }); expect(interactionManager.getSnapshot()?.kind).toBe("rename"); }); + + describe("empty completion retry lifecycle", () => { + type RetryTaskOptions = { + taskName: string; + task: () => Promise; + onSuccess?: (value: { error: unknown | null }) => void; + onError?: (error: unknown) => void; + }; + + function emitAssistantText( + aggregator: { processEvent(event: Event): void }, + text: string, + messageId: string, + ): void { + aggregator.processEvent({ + type: "message.part.updated", + properties: { + part: { + id: `text-${messageId}`, + sessionID: "session-1", + messageID: messageId, + type: "text", + text, + }, + }, + } as unknown as Event); + } + + function emitAssistantCompleted( + aggregator: { processEvent(event: Event): void }, + messageId: string, + overrides: Record = {}, + ): void { + aggregator.processEvent({ + type: "message.updated", + properties: { + info: { + id: messageId, + sessionID: "session-1", + role: "assistant", + agent: "test-agent", + providerID: "test-provider", + modelID: "test-model", + // The authoritative OpenCode message finish for a successful run; + // overrides may replace it with a non-terminal reason. + finish: "stop", + time: { created: Date.now() - 1000, completed: Date.now() }, + ...overrides, + }, + }, + } as unknown as Event); + } + + function emitAssistantStarted( + aggregator: { processEvent(event: Event): void }, + messageId: string, + ): void { + aggregator.processEvent({ + type: "message.updated", + properties: { + info: { + id: messageId, + sessionID: "session-1", + role: "assistant", + time: { created: Date.now() }, + }, + }, + } as unknown as Event); + } + + function emitToolPart( + aggregator: { processEvent(event: Event): void }, + messageId: string, + callId: string, + ): void { + aggregator.processEvent({ + type: "message.part.updated", + properties: { + part: { + id: `tool-${callId}`, + sessionID: "session-1", + messageID: messageId, + type: "tool", + callID: callId, + tool: "bash", + state: { + status: "completed", + input: { command: "npm test" }, + metadata: {}, + output: "ok", + }, + }, + }, + } as unknown as Event); + } + + function emitZeroWorkEmptyCompletion( + aggregator: { processEvent(event: Event): void }, + messageId: string, + ): void { + emitAssistantCompleted(aggregator, messageId, { + finish: "unknown", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }); + } + + async function registerPromptAttempt(api: FakeBotApi, chatId = 42): Promise { + const { registerPromptRetry } = await import("../../../src/bot/handlers/prompt.js"); + registerPromptRetry("session-1", { + bot: { api } as unknown as Bot, + chatId, + promptOptions: { + sessionID: "session-1", + directory: "D:/repo", + parts: [{ type: "text", text: "Review README" }], + agent: "build", + }, + promptText: "Review README", + responseMode: "text_only", + }); + } + + function getRetryTaskOptions(): RetryTaskOptions { + const calls = mocked.safeBackgroundTask.mock.calls as unknown as [[RetryTaskOptions]]; + const options = calls.find(([entry]) => entry.taskName === "session.promptAsync.retry")?.[0]; + if (!options) { + throw new Error("retry background task was not captured"); + } + return options; + } + + function countFooters(api: FakeBotApi): number { + return api.sendMessage.mock.calls.filter(([, text]) => + String(text).includes("test-provider/test-model"), + ).length; + } + + async function flushRealDispatch(): Promise { + for (let attempt = 0; attempt < 5; attempt++) { + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + + it("replays a provably zero-work empty completion exactly once", async () => { + const { api, summaryAggregator } = await setupService(false); + await registerPromptAttempt(api); + + emitZeroWorkEmptyCompletion(summaryAggregator, "message-1"); + emitSessionIdle(summaryAggregator); + + await vi.waitFor(() => { + expect( + api.sendMessage.mock.calls.some(([, text]) => + String(text).includes("Retrying once"), + ), + ).toBe(true); + }); + await vi.waitFor(() => { + expect(mocked.safeBackgroundTask).toHaveBeenCalledWith( + expect.objectContaining({ taskName: "session.promptAsync.retry" }), + ); + }); + + // The idle that closed the original attempt was consumed by the guard. + expect(countFooters(api)).toBe(0); + expect(mocked.dispatchNextQueuedPrompt).not.toHaveBeenCalled(); + }); + + it("emits exactly one normal footer after a successful retry", async () => { + const { api, summaryAggregator } = await setupService(false); + await registerPromptAttempt(api); + + emitZeroWorkEmptyCompletion(summaryAggregator, "message-1"); + emitSessionIdle(summaryAggregator); + + await vi.waitFor(() => { + expect(mocked.safeBackgroundTask).toHaveBeenCalledWith( + expect.objectContaining({ taskName: "session.promptAsync.retry" }), + ); + }); + + const retryOptions = getRetryTaskOptions(); + await retryOptions.task(); + retryOptions.onSuccess?.({ error: null }); + + emitAssistantText(summaryAggregator, "Final answer", "message-2"); + emitAssistantCompleted(summaryAggregator, "message-2"); + emitSessionIdle(summaryAggregator); + + await vi.waitFor(() => { + expect( + api.sendMessage.mock.calls.some(([, text]) => String(text) === "Final answer"), + ).toBe(true); + }); + await vi.waitFor( + () => { + expect(countFooters(api)).toBe(1); + }, + { timeout: 5000 }, + ); + expect(mocked.safeBackgroundTask).toHaveBeenCalledTimes(1); + }); + + it("does not retry or emit a footer when the retry itself comes back empty", async () => { + const { api, summaryAggregator } = await setupService(false); + await registerPromptAttempt(api); + + emitZeroWorkEmptyCompletion(summaryAggregator, "message-1"); + emitSessionIdle(summaryAggregator); + + await vi.waitFor(() => { + expect(mocked.safeBackgroundTask).toHaveBeenCalledTimes(1); + }); + + emitZeroWorkEmptyCompletion(summaryAggregator, "message-2"); + emitSessionIdle(summaryAggregator); + + await vi.waitFor(() => { + expect( + api.sendMessage.mock.calls.some(([, text]) => + String(text).includes("No further retry was attempted"), + ), + ).toBe(true); + }); + expect(mocked.safeBackgroundTask).toHaveBeenCalledTimes(1); + expect(countFooters(api)).toBe(0); + }); + + it("never replays the task when an earlier turn used a tool", async () => { + const { api, summaryAggregator } = await setupService(false); + await registerPromptAttempt(api); + + emitBashTool(summaryAggregator, "completed"); + emitZeroWorkEmptyCompletion(summaryAggregator, "message-1"); + emitSessionIdle(summaryAggregator); + + await vi.waitFor(() => { + expect( + api.sendMessage.mock.calls.some(([, text]) => + String(text).includes("No automatic retry was attempted"), + ), + ).toBe(true); + }); + expect(mocked.safeBackgroundTask).not.toHaveBeenCalled(); + }); + + it("never replays the task when an earlier turn reasoned", async () => { + const { api, summaryAggregator } = await setupService(false); + await registerPromptAttempt(api); + + emitThinkingPart(summaryAggregator, "Careful thought"); + emitZeroWorkEmptyCompletion(summaryAggregator, "message-1"); + emitSessionIdle(summaryAggregator); + + await vi.waitFor(() => { + expect( + api.sendMessage.mock.calls.some(([, text]) => + String(text).includes("No automatic retry was attempted"), + ), + ).toBe(true); + }); + expect(mocked.safeBackgroundTask).not.toHaveBeenCalled(); + }); + + it("ignores stale duplicate idle events while the retry is in flight", async () => { + const { api, summaryAggregator } = await setupService(false); + await registerPromptAttempt(api); + + emitZeroWorkEmptyCompletion(summaryAggregator, "message-1"); + emitSessionIdle(summaryAggregator); + + await vi.waitFor(() => { + expect(mocked.safeBackgroundTask).toHaveBeenCalledTimes(1); + }); + + emitSessionIdle(summaryAggregator); + emitSessionIdle(summaryAggregator); + await flushRealDispatch(); + + expect(countFooters(api)).toBe(0); + expect(mocked.dispatchNextQueuedPrompt).not.toHaveBeenCalled(); + + const retryOptions = getRetryTaskOptions(); + await retryOptions.task(); + retryOptions.onSuccess?.({ error: null }); + + emitAssistantText(summaryAggregator, "Recovered", "message-2"); + emitAssistantCompleted(summaryAggregator, "message-2"); + emitSessionIdle(summaryAggregator); + + await vi.waitFor(() => { + expect(countFooters(api)).toBe(1); + }); + expect(mocked.safeBackgroundTask).toHaveBeenCalledTimes(1); + }); + + it("restores idle state and releases the queue when the retry API call fails", async () => { + const { api, summaryAggregator } = await setupService(false); + const promptModule = await import("../../../src/bot/handlers/prompt.js"); + const { foregroundSessionState } = await import( + "../../../src/app/managers/foreground-session-state-manager.js" + ); + await registerPromptAttempt(api); + + emitZeroWorkEmptyCompletion(summaryAggregator, "message-1"); + emitSessionIdle(summaryAggregator); + + await vi.waitFor(() => { + expect(mocked.safeBackgroundTask).toHaveBeenCalledTimes(1); + }); + + const retryOptions = getRetryTaskOptions(); + await retryOptions.task(); + retryOptions.onSuccess?.({ error: new Error("retry rejected") }); + + await vi.waitFor(() => { + expect(mocked.dispatchNextQueuedPrompt).toHaveBeenCalled(); + }); + expect(foregroundSessionState.isBusy()).toBe(false); + expect(promptModule.hasPromptRetryAttempted("session-1")).toBe(false); + expect( + api.sendMessage.mock.calls.some(([, text]) => + String(text).includes("Failed to send request to OpenCode."), + ), + ).toBe(true); + }); + + it("invalidates the retry lifecycle on runtime cleanup", async () => { + const { service } = await setupService(false); + const promptModule = await import("../../../src/bot/handlers/prompt.js"); + await registerPromptAttempt({ sendMessage: vi.fn().mockResolvedValue(undefined) } as never); + + service.clearRuntimeState("test_cleanup"); + expect(promptModule.hasPromptRetryAttempted("session-1")).toBe(false); + }); + + it("invalidates the retry lifecycle on session errors", async () => { + const { api, summaryAggregator } = await setupService(false); + const promptModule = await import("../../../src/bot/handlers/prompt.js"); + await registerPromptAttempt(api); + + summaryAggregator.processEvent({ + type: "session.error", + properties: { sessionID: "session-1", error: "boom" }, + } as unknown as Event); + await flushRealDispatch(); + + expect(promptModule.hasPromptRetryAttempted("session-1")).toBe(false); + + emitZeroWorkEmptyCompletion(summaryAggregator, "message-2"); + emitSessionIdle(summaryAggregator); + await flushRealDispatch(); + + expect(mocked.safeBackgroundTask).not.toHaveBeenCalled(); + expect( + api.sendMessage.mock.calls.some(([, text]) => String(text).includes("empty response")), + ).toBe(false); + }); + + it("invalidates the retry lifecycle when the completing session is no longer current", async () => { + const { api, summaryAggregator } = await setupService(false); + const promptModule = await import("../../../src/bot/handlers/prompt.js"); + const sessionService = await import("../../../src/app/services/session-service.js"); + await registerPromptAttempt(api); + + sessionService.setCurrentSession({ + id: "session-2", + title: "Other session", + directory: "D:/repo", + }); + emitZeroWorkEmptyCompletion(summaryAggregator, "message-1"); + emitSessionIdle(summaryAggregator); + await flushRealDispatch(); + + expect(promptModule.hasPromptRetryAttempted("session-1")).toBe(false); + expect(mocked.safeBackgroundTask).not.toHaveBeenCalled(); + }); + + it("does not export an intermediate long response and keeps the last good one", async () => { + const { api, summaryAggregator } = await setupService(false, { startAssistantRun: true }); + const exportService = await import( + "../../../src/bot/services/assistant-response-export-service.js" + ); + + emitAssistantText(summaryAggregator, "Good answer", "message-1"); + emitAssistantCompleted(summaryAggregator, "message-1"); + emitSessionIdle(summaryAggregator); + await vi.waitFor(() => { + expect(exportService.getRememberedAssistantResponse(42, "session-1")).toBe("Good answer"); + }); + + await registerPromptAttempt(api); + emitAssistantText(summaryAggregator, "x".repeat(6000), "message-2"); + emitAssistantCompleted(summaryAggregator, "message-2"); + emitZeroWorkEmptyCompletion(summaryAggregator, "message-3"); + emitSessionIdle(summaryAggregator); + + await vi.waitFor(() => { + expect( + api.sendMessage.mock.calls.some(([, text]) => + String(text).includes("No automatic retry was attempted"), + ), + ).toBe(true); + }); + expect(exportService.getRememberedAssistantResponse(42, "session-1")).toBe("Good answer"); + expect(api.sendDocument).not.toHaveBeenCalled(); + }); + + it("exports a long final response as a supplemental Markdown document", async () => { + const { api, summaryAggregator } = await setupService(false, { startAssistantRun: true }); + const exportService = await import( + "../../../src/bot/services/assistant-response-export-service.js" + ); + + const longText = `# Heading\n\n${"x".repeat(6000)}`; + emitAssistantText(summaryAggregator, longText, "message-1"); + emitAssistantCompleted(summaryAggregator, "message-1"); + emitSessionIdle(summaryAggregator); + + await vi.waitFor(() => { + expect(api.sendDocument).toHaveBeenCalledTimes(1); + }); + expect(exportService.getRememberedAssistantResponse(42, "session-1")).toBe(longText); + }); + + it("does not export a short final response", async () => { + const { api, summaryAggregator } = await setupService(false, { startAssistantRun: true }); + const exportService = await import( + "../../../src/bot/services/assistant-response-export-service.js" + ); + + emitAssistantText(summaryAggregator, "Short answer", "message-1"); + emitAssistantCompleted(summaryAggregator, "message-1"); + emitSessionIdle(summaryAggregator); + + await vi.waitFor(() => { + expect(exportService.getRememberedAssistantResponse(42, "session-1")).toBe("Short answer"); + }); + expect(api.sendDocument).not.toHaveBeenCalled(); + }); + + it("scopes retry and export state by chat and session", async () => { + const { api, summaryAggregator } = await setupService(false, { startAssistantRun: true }); + const promptModule = await import("../../../src/bot/handlers/prompt.js"); + const exportService = await import( + "../../../src/bot/services/assistant-response-export-service.js" + ); + + emitAssistantText(summaryAggregator, "For session one", "message-1"); + emitAssistantCompleted(summaryAggregator, "message-1"); + emitSessionIdle(summaryAggregator); + await vi.waitFor(() => { + expect(exportService.getRememberedAssistantResponse(42, "session-1")).toBe( + "For session one", + ); + }); + expect(exportService.getRememberedAssistantResponse(43, "session-1")).toBeNull(); + expect(exportService.getRememberedAssistantResponse(42, "session-2")).toBeNull(); + + await registerPromptAttempt(api); + expect(promptModule.hasPromptRetryAttempted("session-1")).toBe(false); + expect(promptModule.handleEmptyCompletion("session-2")).toBe("ignored"); + promptModule.clearPromptRetry("session-1"); + }); + + describe("final response terminality", () => { + async function startFreshRun(): Promise { + const { assistantRunState } = + await import("../../../src/app/managers/assistant-run-state-manager.js"); + assistantRunState.startRun("session-1", { + startedAt: Date.now(), + configuredAgent: "test-agent", + configuredProviderID: "test-provider", + configuredModelID: "test-model", + }); + } + + it("never reports an intermediate commentary run as complete when tool work follows", async () => { + const { api, summaryAggregator } = await setupService(false, { startAssistantRun: true }); + const exportService = + await import("../../../src/bot/services/assistant-response-export-service.js"); + + emitAssistantText(summaryAggregator, "Good answer", "message-0"); + emitAssistantCompleted(summaryAggregator, "message-0"); + emitSessionIdle(summaryAggregator); + await vi.waitFor(() => { + expect(exportService.getRememberedAssistantResponse(42, "session-1")).toBe("Good answer"); + }); + const footersBefore = countFooters(api); + + await startFreshRun(); + + emitAssistantText(summaryAggregator, "Let me check the files", "message-1"); + emitAssistantCompleted(summaryAggregator, "message-1"); + + emitAssistantStarted(summaryAggregator, "message-2"); + emitAssistantText(summaryAggregator, "Running the tests", "message-2"); + emitToolPart(summaryAggregator, "message-2", "call-tests"); + emitAssistantCompleted(summaryAggregator, "message-2"); + emitSessionIdle(summaryAggregator); + + await flushRealDispatch(); + await vi.waitFor(() => { + expect(exportService.getRememberedAssistantResponse(42, "session-1")).toBe("Good answer"); + }); + expect(countFooters(api)).toBe(footersBefore); + expect(api.sendDocument).not.toHaveBeenCalled(); + expect(exportService.getRememberedAssistantResponse(42, "session-1")).toBe("Good answer"); + }); + + it("does not announce a truncated/errored completion as a completed task", async () => { + const { api, summaryAggregator } = await setupService(false, { startAssistantRun: true }); + const exportService = + await import("../../../src/bot/services/assistant-response-export-service.js"); + + emitAssistantText(summaryAggregator, "Good answer", "message-0"); + emitAssistantCompleted(summaryAggregator, "message-0"); + emitSessionIdle(summaryAggregator); + await vi.waitFor(() => { + expect(exportService.getRememberedAssistantResponse(42, "session-1")).toBe("Good answer"); + }); + const footersBefore = countFooters(api); + + await startFreshRun(); + + emitAssistantText(summaryAggregator, "Partial answer that got cut off", "message-1"); + emitAssistantCompleted(summaryAggregator, "message-1", { + error: { name: "MessageAbortedError", data: { message: "aborted" } }, + }); + emitSessionIdle(summaryAggregator); + + await flushRealDispatch(); + await vi.waitFor(() => { + expect(exportService.getRememberedAssistantResponse(42, "session-1")).toBe("Good answer"); + }); + expect(countFooters(api)).toBe(footersBefore); + expect(api.sendDocument).not.toHaveBeenCalled(); + expect(exportService.getRememberedAssistantResponse(42, "session-1")).toBe("Good answer"); + }); + + it("emits exactly one footer and updates /lastfile for a normal short terminal response", async () => { + const { api, summaryAggregator } = await setupService(false, { startAssistantRun: true }); + const exportService = + await import("../../../src/bot/services/assistant-response-export-service.js"); + + emitAssistantText(summaryAggregator, "Short answer", "message-1"); + emitAssistantCompleted(summaryAggregator, "message-1"); + emitSessionIdle(summaryAggregator); + + await vi.waitFor(() => { + expect(exportService.getRememberedAssistantResponse(42, "session-1")).toBe( + "Short answer", + ); + }); + expect(countFooters(api)).toBe(1); + expect(api.sendDocument).not.toHaveBeenCalled(); + }); + + it("emits one footer, updates /lastfile, and attaches one document for a long terminal response", async () => { + const { api, summaryAggregator } = await setupService(false, { startAssistantRun: true }); + const exportService = + await import("../../../src/bot/services/assistant-response-export-service.js"); + + const longText = `# Heading\n\n${"x".repeat(6000)}`; + emitAssistantText(summaryAggregator, longText, "message-1"); + emitAssistantCompleted(summaryAggregator, "message-1"); + emitSessionIdle(summaryAggregator); + + await vi.waitFor(() => { + expect(api.sendDocument).toHaveBeenCalledTimes(1); + }); + expect(exportService.getRememberedAssistantResponse(42, "session-1")).toBe(longText); + expect(countFooters(api)).toBe(1); + }); + + it("keeps a stale original idle inert after the retry response and finalizes exactly once", async () => { + const { api, summaryAggregator } = await setupService(false, { startAssistantRun: true }); + const exportService = + await import("../../../src/bot/services/assistant-response-export-service.js"); + const { foregroundSessionState } = + await import("../../../src/app/managers/foreground-session-state-manager.js"); + await registerPromptAttempt(api); + + emitZeroWorkEmptyCompletion(summaryAggregator, "message-1"); + emitSessionIdle(summaryAggregator); + + await vi.waitFor(() => { + expect(mocked.safeBackgroundTask).toHaveBeenCalledWith( + expect.objectContaining({ taskName: "session.promptAsync.retry" }), + ); + }); + + const retryOptions = getRetryTaskOptions(); + await retryOptions.task(); + retryOptions.onSuccess?.({ error: null }); + + emitAssistantText(summaryAggregator, "Recovered answer", "message-2"); + emitAssistantCompleted(summaryAggregator, "message-2"); + + // Stale duplicate idle of the ORIGINAL attempt arrives AFTER the retry + // produced its response, while the authoritative session status says the + // retry is still busy. It must be fully inert. + mocked.sessionStatusMock.mockResolvedValueOnce({ + data: { "session-1": { type: "busy" } }, + error: null, + }); + emitSessionIdle(summaryAggregator); + await flushRealDispatch(); + expect(countFooters(api)).toBe(0); + expect(exportService.getRememberedAssistantResponse(42, "session-1")).toBeNull(); + expect(api.sendDocument).not.toHaveBeenCalled(); + expect(foregroundSessionState.isBusy()).toBe(true); + expect(mocked.dispatchNextQueuedPrompt).not.toHaveBeenCalled(); + + // The real retry idle, with the authoritative status now genuinely idle, + // finalizes exactly once. + mocked.sessionStatusMock.mockResolvedValueOnce({ data: {}, error: null }); + emitSessionIdle(summaryAggregator); + await vi.waitFor( + () => { + expect(countFooters(api)).toBe(1); + }, + { timeout: 5000 }, + ); + expect(exportService.getRememberedAssistantResponse(42, "session-1")).toBe( + "Recovered answer", + ); + expect(foregroundSessionState.isBusy()).toBe(false); + expect(countFooters(api)).toBe(1); + expect(mocked.safeBackgroundTask).toHaveBeenCalledTimes(1); + }); + + it("binds the final response, export, and footer to the originating chat", async () => { + const { api, summaryAggregator, service } = await setupService(false, { + startAssistantRun: true, + }); + const exportService = + await import("../../../src/bot/services/assistant-response-export-service.js"); + const newBot = createFakeBot(); + await registerPromptAttempt(api, 42); + + // The mutable service-wide chat context moves to another chat mid-run. + service.setTelegramContext(newBot.bot, 43); + + const longText = `# Heading\n\n${"x".repeat(6000)}`; + emitAssistantText(summaryAggregator, longText, "message-1"); + emitAssistantCompleted(summaryAggregator, "message-1"); + emitSessionIdle(summaryAggregator); + + await vi.waitFor(() => { + expect(newBot.api.sendDocument).toHaveBeenCalledWith(42, expect.anything()); + }); + expect(exportService.getRememberedAssistantResponse(42, "session-1")).toBe(longText); + expect(exportService.getRememberedAssistantResponse(43, "session-1")).toBeNull(); + + const footerCalls = newBot.api.sendMessage.mock.calls.filter(([, text]) => + String(text).includes("test-provider/test-model"), + ); + expect(footerCalls).toHaveLength(1); + expect(footerCalls[0][0]).toBe(42); + }); + + it("fails closed for a non-empty response with a missing or unknown finish reason", async () => { + const { api, summaryAggregator } = await setupService(false, { startAssistantRun: true }); + const exportService = + await import("../../../src/bot/services/assistant-response-export-service.js"); + const { assistantRunState } = await import( + "../../../src/app/managers/assistant-run-state-manager.js" + ); + + emitAssistantText(summaryAggregator, "Good answer", "message-0"); + emitAssistantCompleted(summaryAggregator, "message-0"); + emitSessionIdle(summaryAggregator); + await vi.waitFor(() => { + expect(exportService.getRememberedAssistantResponse(42, "session-1")).toBe("Good answer"); + }); + + for (const finish of [undefined, "unknown"]) { + assistantRunState.startRun("session-1", { + startedAt: Date.now(), + configuredAgent: "test-agent", + configuredProviderID: "test-provider", + configuredModelID: "test-model", + }); + const footersBefore = countFooters(api); + const messageId = `message-${finish === undefined ? "missing" : "unknown"}`; + + // Partial/truncated-looking response: non-empty, no explicit error, + // finish missing or unknown. Must never be reported as complete. + emitAssistantText(summaryAggregator, "Partial answer that looks truncated", messageId); + emitAssistantCompleted(summaryAggregator, messageId, { finish }); + emitSessionIdle(summaryAggregator); + + await flushRealDispatch(); + await vi.waitFor(() => { + expect(exportService.getRememberedAssistantResponse(42, "session-1")).toBe( + "Good answer", + ); + }); + expect(countFooters(api)).toBe(footersBefore); + expect(api.sendDocument).not.toHaveBeenCalled(); + expect(exportService.getRememberedAssistantResponse(42, "session-1")).toBe("Good answer"); + } + }); + + it("does not finalize the retry on a stale idle even after a long delay while the session is busy", async () => { + const { api, summaryAggregator } = await setupService(false, { startAssistantRun: true }); + const exportService = + await import("../../../src/bot/services/assistant-response-export-service.js"); + const { foregroundSessionState } = await import( + "../../../src/app/managers/foreground-session-state-manager.js" + ); + await registerPromptAttempt(api); + + emitZeroWorkEmptyCompletion(summaryAggregator, "message-1"); + emitSessionIdle(summaryAggregator); + + await vi.waitFor(() => { + expect(mocked.safeBackgroundTask).toHaveBeenCalledWith( + expect.objectContaining({ taskName: "session.promptAsync.retry" }), + ); + }); + + const retryOptions = getRetryTaskOptions(); + await retryOptions.task(); + retryOptions.onSuccess?.({ error: null }); + + const longText = `# Heading\n\n${"x".repeat(6000)}`; + emitAssistantText(summaryAggregator, longText, "message-2"); + emitAssistantCompleted(summaryAggregator, "message-2"); + + // Stale original idle while the authoritative status says the retry is + // still busy. It must be fully inert. + mocked.sessionStatusMock.mockResolvedValueOnce({ + data: { "session-1": { type: "busy" } }, + error: null, + }); + emitSessionIdle(summaryAggregator); + await flushRealDispatch(); + expect(countFooters(api)).toBe(0); + expect(exportService.getRememberedAssistantResponse(42, "session-1")).toBeNull(); + expect(api.sendDocument).not.toHaveBeenCalled(); + expect(foregroundSessionState.isBusy()).toBe(true); + expect(mocked.dispatchNextQueuedPrompt).not.toHaveBeenCalled(); + + // Wait LONGER than the old 300ms timer window: nothing may finalize. + await new Promise((resolve) => setTimeout(resolve, 600)); + await flushRealDispatch(); + expect(countFooters(api)).toBe(0); + expect(exportService.getRememberedAssistantResponse(42, "session-1")).toBeNull(); + expect(api.sendDocument).not.toHaveBeenCalled(); + expect(foregroundSessionState.isBusy()).toBe(true); + expect(mocked.dispatchNextQueuedPrompt).not.toHaveBeenCalled(); + + // The genuine retry idle, with authoritative idle status, finalizes + // exactly once and exports the long response. + mocked.sessionStatusMock.mockResolvedValueOnce({ data: {}, error: null }); + emitSessionIdle(summaryAggregator); + await vi.waitFor( + () => { + expect(api.sendDocument).toHaveBeenCalledTimes(1); + }, + { timeout: 5000 }, + ); + expect(exportService.getRememberedAssistantResponse(42, "session-1")).toBe(longText); + expect(foregroundSessionState.isBusy()).toBe(false); + expect(countFooters(api)).toBe(1); + expect(mocked.dispatchNextQueuedPrompt).toHaveBeenCalledTimes(1); + expect(mocked.safeBackgroundTask).toHaveBeenCalledTimes(1); + }); + + it("fails closed when the authoritative session status lookup fails during retry finalization", async () => { + const { api, summaryAggregator } = await setupService(false, { startAssistantRun: true }); + const exportService = + await import("../../../src/bot/services/assistant-response-export-service.js"); + const { foregroundSessionState } = await import( + "../../../src/app/managers/foreground-session-state-manager.js" + ); + await registerPromptAttempt(api); + + emitZeroWorkEmptyCompletion(summaryAggregator, "message-1"); + emitSessionIdle(summaryAggregator); + + await vi.waitFor(() => { + expect(mocked.safeBackgroundTask).toHaveBeenCalledWith( + expect.objectContaining({ taskName: "session.promptAsync.retry" }), + ); + }); + + const retryOptions = getRetryTaskOptions(); + await retryOptions.task(); + retryOptions.onSuccess?.({ error: null }); + + emitAssistantText(summaryAggregator, "Recovered answer", "message-2"); + emitAssistantCompleted(summaryAggregator, "message-2"); + + for (const failingStatus of [ + { data: null, error: new Error("status lookup failed") }, + { data: { "session-1": { type: "unexpected" } }, error: null }, + ]) { + mocked.sessionStatusMock.mockResolvedValueOnce(failingStatus); + emitSessionIdle(summaryAggregator); + await flushRealDispatch(); + expect(countFooters(api)).toBe(0); + expect(exportService.getRememberedAssistantResponse(42, "session-1")).toBeNull(); + expect(api.sendDocument).not.toHaveBeenCalled(); + expect(foregroundSessionState.isBusy()).toBe(true); + expect(mocked.dispatchNextQueuedPrompt).not.toHaveBeenCalled(); + } + + // A later genuine idle with authoritative idle status still finalizes + // exactly once. + mocked.sessionStatusMock.mockResolvedValueOnce({ data: {}, error: null }); + emitSessionIdle(summaryAggregator); + await vi.waitFor( + () => { + expect(countFooters(api)).toBe(1); + }, + { timeout: 5000 }, + ); + expect(exportService.getRememberedAssistantResponse(42, "session-1")).toBe( + "Recovered answer", + ); + expect(foregroundSessionState.isBusy()).toBe(false); + expect(countFooters(api)).toBe(1); + }); + }); + }); });