Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 | `{}` |
Expand Down
106 changes: 100 additions & 6 deletions src/app/managers/summary-aggregation-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand All @@ -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 {
Expand Down Expand Up @@ -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<string, ThinkingSection>;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -324,6 +347,8 @@ class SummaryAggregator {
private typingIndicatorEnabled = true;
private partHashes: Map<string, Set<string>> = new Map();
private trackedSessionParents: Map<string, string | null> = new Map();
private messageActivityStates: Map<string, MessageActivityState> = new Map();
private pendingEmptyCompletions: Map<string, PendingEmptyCompletion> = new Map();
private subagentStates: Map<string, SubagentState> = new Map();
private subagentOrder: string[] = [];
private subagentCardIdBySessionId: Map<string, string> = new Map();
Expand All @@ -341,6 +366,10 @@ class SummaryAggregator {
this.onCompleteCallback = callback;
}

setOnAssistantMessageStarted(callback: AssistantMessageStartedCallback): void {
this.onAssistantMessageStartedCallback = callback;
}

setOnPartial(callback: MessagePartialCallback): void {
this.onPartialCallback = callback;
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -1153,13 +1184,19 @@ 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);

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);
Expand Down Expand Up @@ -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}`,
Expand All @@ -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,
});
}

Expand Down Expand Up @@ -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
Expand All @@ -1303,6 +1370,7 @@ class SummaryAggregator {
}

if (part.type === "reasoning") {
activity.hasReasoningActivity = true;
this.registerThinkingPart(
messageID,
part.id,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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();

Expand Down
3 changes: 2 additions & 1 deletion src/bot/commands/abort-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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(
Expand Down
1 change: 1 addition & 0 deletions src/bot/commands/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
3 changes: 2 additions & 1 deletion src/bot/commands/detach-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -27,6 +27,7 @@ export async function detachCommand(ctx: CommandContext<Context>): Promise<void>

detachAttachedSession("detach_command");
clearPromptResponseMode(currentSession.id);
clearPromptRetry(currentSession.id);
foregroundSessionState.markIdle(currentSession.id);
assistantRunState.clearRun(currentSession.id, "detach_command");
clearAllInteractionState("detach_command");
Expand Down
28 changes: 28 additions & 0 deletions src/bot/commands/lastfile-command.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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"));
}
}
Loading