Skip to content

Commit 89302ac

Browse files
committed
fix(bot): show buffered session retry errors and dedupe repeated warnings
1 parent 249af55 commit 89302ac

10 files changed

Lines changed: 171 additions & 22 deletions

File tree

src/bot/index.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ let chatIdInstance: number | null = null;
5858
let commandsInitialized = false;
5959

6060
const TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH = 1024;
61+
const SESSION_RETRY_PREFIX = "🔁";
6162
const __filename = fileURLToPath(import.meta.url);
6263
const __dirname = path.dirname(__filename);
6364
const TEMP_DIR = path.join(__dirname, "..", ".tmp");
@@ -393,6 +394,26 @@ async function ensureEventSubscription(directory: string): Promise<void> {
393394
});
394395
});
395396

397+
summaryAggregator.setOnSessionRetry(async ({ sessionId, message }) => {
398+
if (!botInstance || !chatIdInstance) {
399+
return;
400+
}
401+
402+
const currentSession = getCurrentSession();
403+
if (!currentSession || currentSession.id !== sessionId) {
404+
return;
405+
}
406+
407+
const normalizedMessage = message.trim() || t("common.unknown_error");
408+
const truncatedMessage =
409+
normalizedMessage.length > 3500
410+
? `${normalizedMessage.slice(0, 3497)}...`
411+
: normalizedMessage;
412+
413+
const retryMessage = t("bot.session_retry", { message: truncatedMessage });
414+
toolMessageBatcher.enqueueUniqueByPrefix(sessionId, retryMessage, SESSION_RETRY_PREFIX);
415+
});
416+
396417
summaryAggregator.setOnSessionDiff(async (_sessionId, diffs) => {
397418
if (!pinnedMessageManager.isInitialized()) {
398419
return;

src/i18n/de.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ export const de: I18nDictionary = {
6666
"⚠️ 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.",
6767
"bot.prompt_send_error": "Anfrage konnte nicht an OpenCode gesendet werden.",
6868
"bot.session_error": "🔴 OpenCode meldete einen Fehler: {message}",
69+
"bot.session_retry":
70+
"🔁 {message}\n\nDer Provider liefert bei wiederholten Versuchen immer wieder denselben Fehler. Mit /stop abbrechen.",
6971
"bot.unknown_command":
7072
"⚠️ Unbekannter Befehl: {command}. Nutze /help, um verfügbare Befehle zu sehen.",
7173
"bot.photo_downloading": "⏳ Lade Foto herunter...",

src/i18n/en.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ export const en = {
6363
"⚠️ Active session does not match the selected project, so it was reset. Use /sessions to pick one or /new to create a new session.",
6464
"bot.prompt_send_error": "Failed to send request to OpenCode.",
6565
"bot.session_error": "🔴 OpenCode returned an error: {message}",
66+
"bot.session_retry":
67+
"🔁 {message}\n\nProvider keeps returning the same error on repeated retries. Use /stop to abort.",
6668
"bot.unknown_command": "⚠️ Unknown command: {command}. Use /help to see available commands.",
6769
"bot.photo_downloading": "⏳ Downloading photo...",
6870
"bot.photo_too_large": "⚠️ Photo is too large (max {maxSizeMb}MB)",

src/i18n/es.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ export const es: I18nDictionary = {
6666
"⚠️ 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.",
6767
"bot.prompt_send_error": "No se pudo enviar la solicitud a OpenCode.",
6868
"bot.session_error": "🔴 OpenCode devolvió un error: {message}",
69+
"bot.session_retry":
70+
"🔁 {message}\n\nEl proveedor devuelve el mismo error en intentos repetidos. Usa /stop para detenerlo.",
6971
"bot.unknown_command":
7072
"⚠️ Comando desconocido: {command}. Usa /help para ver los comandos disponibles.",
7173
"bot.photo_downloading": "⏳ Descargando foto...",

src/i18n/ru.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ export const ru: I18nDictionary = {
6262
"⚠️ Активная сессия не соответствует выбранному проекту, поэтому была сброшена. Используйте /sessions для выбора или /new для создания новой сессии.",
6363
"bot.prompt_send_error": "Не удалось отправить запрос в OpenCode.",
6464
"bot.session_error": "🔴 OpenCode вернул ошибку: {message}",
65+
"bot.session_retry":
66+
"🔁 {message}\n\nПровайдер возвращает одну и ту же ошибку при повторных запросах. Используйте /stop для остановки.",
6567
"bot.unknown_command": "⚠️ Неизвестная команда: {command}. Используйте /help для списка команд.",
6668
"bot.photo_downloading": "⏳ Скачиваю фото...",
6769
"bot.photo_too_large": "⚠️ Фото слишком большое (макс. {maxSizeMb}МБ)",

src/i18n/zh.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export const zh: I18nDictionary = {
5555
"⚠️ 活动会话与所选项目不匹配,因此已重置。使用 /sessions 选择一个会话,或 /new 创建新会话。",
5656
"bot.prompt_send_error": "向 OpenCode 发送请求失败。",
5757
"bot.session_error": "🔴 OpenCode 返回错误:{message}",
58+
"bot.session_retry": "🔁 {message}\n\n提供方在重复重试时持续返回同一错误。使用 /stop 可停止。",
5859
"bot.unknown_command": "⚠️ 未知命令:{command}。使用 /help 查看可用命令。",
5960
"bot.photo_downloading": "⏳ 正在下载照片...",
6061
"bot.photo_too_large": "⚠️ 照片过大(最大 {maxSizeMb}MB)",

src/summary/aggregator.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,15 @@ type SessionCompactedCallback = (sessionId: string, directory: string) => void;
5858

5959
type SessionErrorCallback = (sessionId: string, message: string) => void;
6060

61+
export interface SessionRetryInfo {
62+
sessionId: string;
63+
attempt?: number;
64+
message: string;
65+
next?: number;
66+
}
67+
68+
type SessionRetryCallback = (retryInfo: SessionRetryInfo) => void;
69+
6170
type PermissionCallback = (request: PermissionRequest) => void;
6271

6372
type SessionDiffCallback = (sessionId: string, diffs: FileChange[]) => void;
@@ -115,6 +124,7 @@ class SummaryAggregator {
115124
private onTokensCallback: TokensCallback | null = null;
116125
private onSessionCompactedCallback: SessionCompactedCallback | null = null;
117126
private onSessionErrorCallback: SessionErrorCallback | null = null;
127+
private onSessionRetryCallback: SessionRetryCallback | null = null;
118128
private onPermissionCallback: PermissionCallback | null = null;
119129
private onSessionDiffCallback: SessionDiffCallback | null = null;
120130
private onFileChangeCallback: FileChangeCallback | null = null;
@@ -167,6 +177,10 @@ class SummaryAggregator {
167177
this.onSessionErrorCallback = callback;
168178
}
169179

180+
setOnSessionRetry(callback: SessionRetryCallback): void {
181+
this.onSessionRetryCallback = callback;
182+
}
183+
170184
setOnPermission(callback: PermissionCallback): void {
171185
this.onPermissionCallback = callback;
172186
}
@@ -649,11 +663,39 @@ class SummaryAggregator {
649663
type: "session.status";
650664
},
651665
): void {
652-
const { sessionID } = event.properties;
666+
const { sessionID, status } = event.properties as {
667+
sessionID: string;
668+
status?: {
669+
type?: string;
670+
attempt?: number;
671+
message?: string;
672+
next?: number;
673+
};
674+
};
653675

654676
if (sessionID !== this.currentSessionId) {
655677
return;
656678
}
679+
680+
if (status?.type !== "retry" || !this.onSessionRetryCallback) {
681+
return;
682+
}
683+
684+
const callback = this.onSessionRetryCallback;
685+
const message = status.message?.trim() || "Unknown retry error";
686+
687+
logger.warn(
688+
`[Aggregator] Session retry: session=${sessionID}, attempt=${status.attempt ?? "n/a"}, message=${message}`,
689+
);
690+
691+
setImmediate(() => {
692+
callback({
693+
sessionId: sessionID,
694+
attempt: status.attempt,
695+
message,
696+
next: status.next,
697+
});
698+
});
657699
}
658700

659701
private handleSessionIdle(

src/summary/tool-message-batcher.ts

Lines changed: 48 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -86,28 +86,11 @@ export class ToolMessageBatcher {
8686
}
8787

8888
enqueue(sessionId: string, message: string): void {
89-
const normalizedMessage = message.trim();
90-
if (!sessionId || normalizedMessage.length === 0) {
91-
return;
92-
}
93-
94-
if (this.intervalSeconds === 0) {
95-
const expectedGeneration = this.generation;
96-
logger.debug(`[ToolBatcher] Sending immediate text message: session=${sessionId}`);
97-
void this.enqueueTask(sessionId, () =>
98-
this.sendTextSafe(sessionId, normalizedMessage, "immediate", expectedGeneration),
99-
);
100-
return;
101-
}
102-
103-
const queue = this.queues.get(sessionId) ?? [];
104-
queue.push({ kind: "text", text: normalizedMessage });
105-
this.queues.set(sessionId, queue);
106-
logger.debug(
107-
`[ToolBatcher] Queued text message: session=${sessionId}, queueSize=${queue.length}, interval=${this.intervalSeconds}s`,
108-
);
89+
this.enqueueTextInternal(sessionId, message);
90+
}
10991

110-
this.ensureTimer(sessionId);
92+
enqueueUniqueByPrefix(sessionId: string, message: string, prefix: string): void {
93+
this.enqueueTextInternal(sessionId, message, prefix);
11194
}
11295

11396
enqueueFile(sessionId: string, fileData: CodeFileData): void {
@@ -220,6 +203,50 @@ export class ToolMessageBatcher {
220203
return nextTask;
221204
}
222205

206+
private enqueueTextInternal(sessionId: string, message: string, uniquePrefix?: string): void {
207+
const normalizedMessage = message.trim();
208+
if (!sessionId || normalizedMessage.length === 0) {
209+
return;
210+
}
211+
212+
if (this.intervalSeconds === 0) {
213+
const expectedGeneration = this.generation;
214+
logger.debug(`[ToolBatcher] Sending immediate text message: session=${sessionId}`);
215+
void this.enqueueTask(sessionId, () =>
216+
this.sendTextSafe(sessionId, normalizedMessage, "immediate", expectedGeneration),
217+
);
218+
return;
219+
}
220+
221+
const normalizedPrefix = uniquePrefix?.trim();
222+
const queue = this.queues.get(sessionId) ?? [];
223+
224+
if (normalizedPrefix) {
225+
const existingUniqueMessage = queue.find(
226+
(item): item is Extract<QueueItem, { kind: "text" }> =>
227+
item.kind === "text" && item.text.startsWith(normalizedPrefix),
228+
);
229+
230+
if (existingUniqueMessage) {
231+
existingUniqueMessage.text = normalizedMessage;
232+
this.queues.set(sessionId, queue);
233+
logger.debug(
234+
`[ToolBatcher] Updated queued unique text message: session=${sessionId}, prefix=${normalizedPrefix}, interval=${this.intervalSeconds}s`,
235+
);
236+
this.ensureTimer(sessionId);
237+
return;
238+
}
239+
}
240+
241+
queue.push({ kind: "text", text: normalizedMessage });
242+
this.queues.set(sessionId, queue);
243+
logger.debug(
244+
`[ToolBatcher] Queued text message: session=${sessionId}, queueSize=${queue.length}, interval=${this.intervalSeconds}s`,
245+
);
246+
247+
this.ensureTimer(sessionId);
248+
}
249+
223250
private async flushSessionInternal(sessionId: string, reason: string): Promise<void> {
224251
const expectedGeneration = this.generation;
225252
this.clearTimer(sessionId);

tests/summary/aggregator.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ describe("summary/aggregator", () => {
2727
summaryAggregator.setOnToolFile(() => {});
2828
summaryAggregator.setOnThinking(() => {});
2929
summaryAggregator.setOnSessionError(() => {});
30+
summaryAggregator.setOnSessionRetry(() => {});
3031
});
3132

3233
it("invokes onCleared callback when aggregator is cleared", () => {
@@ -273,6 +274,34 @@ describe("summary/aggregator", () => {
273274
expect(onSessionError).toHaveBeenCalledWith("session-1", "Model not found: opencode/foo.");
274275
});
275276

277+
it("reports session.status retry through callback", async () => {
278+
const onSessionRetry = vi.fn();
279+
summaryAggregator.setOnSessionRetry(onSessionRetry);
280+
summaryAggregator.setSession("session-1");
281+
282+
summaryAggregator.processEvent({
283+
type: "session.status",
284+
properties: {
285+
sessionID: "session-1",
286+
status: {
287+
type: "retry",
288+
attempt: 2,
289+
message: "Your current subscription plan does not yet include access to GLM-5",
290+
next: 1772203141283,
291+
},
292+
},
293+
} as unknown as Event);
294+
295+
await new Promise<void>((resolve) => setImmediate(resolve));
296+
297+
expect(onSessionRetry).toHaveBeenCalledWith({
298+
sessionId: "session-1",
299+
attempt: 2,
300+
message: "Your current subscription plan does not yet include access to GLM-5",
301+
next: 1772203141283,
302+
});
303+
});
304+
276305
it("sends apply_patch payload as tool file", () => {
277306
const onToolFile = vi.fn();
278307
summaryAggregator.setOnToolFile(onToolFile);

tests/summary/tool-message-batcher.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,27 @@ describe("summary/tool-message-batcher", () => {
7474
expect(sendFile).not.toHaveBeenCalled();
7575
});
7676

77+
it("keeps only one queued retry message by prefix and updates it", async () => {
78+
vi.useFakeTimers();
79+
80+
const sendText = vi.fn().mockResolvedValue(undefined);
81+
const sendFile = vi.fn().mockResolvedValue(undefined);
82+
const batcher = new ToolMessageBatcher({
83+
intervalSeconds: 5,
84+
sendText,
85+
sendFile,
86+
});
87+
88+
batcher.enqueueUniqueByPrefix("s1", "🔁 Retry attempt 1", "🔁");
89+
batcher.enqueueUniqueByPrefix("s1", "🔁 Retry attempt 2", "🔁");
90+
91+
await vi.advanceTimersByTimeAsync(5000);
92+
93+
expect(sendText).toHaveBeenCalledTimes(1);
94+
expect(sendText).toHaveBeenCalledWith("s1", "🔁 Retry attempt 2");
95+
expect(sendFile).not.toHaveBeenCalled();
96+
});
97+
7798
it("flushes session queue immediately and cancels timer", async () => {
7899
vi.useFakeTimers();
79100

0 commit comments

Comments
 (0)