@grinev/opencode-telegram-bot 0.20.3 → 0.20.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app/start-bot-app.js +8 -2
- package/dist/bot/commands/abort.js +14 -2
- package/dist/bot/commands/opencode-start.js +46 -14
- package/dist/bot/handlers/question.js +91 -18
- package/dist/bot/handlers/voice.js +2 -2
- package/dist/bot/index.js +7 -0
- package/dist/bot/middleware/interaction-guard.js +6 -1
- package/dist/bot/utils/abort-error-suppression.js +32 -0
- package/dist/bot/utils/busy-guard.js +26 -0
- package/dist/bot/utils/file-download.js +3 -8
- package/dist/bot/utils/telegram-file-url.js +11 -0
- package/dist/opencode/auto-restart.js +24 -1
- package/dist/opencode/events.js +106 -33
- package/dist/scheduled-task/executor.js +25 -3
- package/package.json +1 -1
|
@@ -12,6 +12,7 @@ import { getRuntimePaths } from "../runtime/paths.js";
|
|
|
12
12
|
import { clearServiceStateFile } from "../service/manager.js";
|
|
13
13
|
import { getServiceStateFilePathFromEnv, isServiceChildProcess } from "../service/runtime.js";
|
|
14
14
|
import { getLogFilePath, initializeLogger, logger } from "../utils/logger.js";
|
|
15
|
+
import { safeBackgroundTask } from "../utils/safe-background-task.js";
|
|
15
16
|
const SHUTDOWN_TIMEOUT_MS = 5000;
|
|
16
17
|
async function getBotVersion() {
|
|
17
18
|
try {
|
|
@@ -43,8 +44,13 @@ export async function startBotApp() {
|
|
|
43
44
|
registerOpenCodeReadyRefreshHandler();
|
|
44
45
|
const bot = createBot();
|
|
45
46
|
await scheduledTaskRuntime.initialize(bot);
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
safeBackgroundTask({
|
|
48
|
+
taskName: "app.opencodeStartup",
|
|
49
|
+
task: async () => {
|
|
50
|
+
await opencodeAutoRestartService.start();
|
|
51
|
+
await notifyOpencodeReadyIfHealthy("startup");
|
|
52
|
+
},
|
|
53
|
+
});
|
|
48
54
|
let shutdownStarted = false;
|
|
49
55
|
let serviceStateCleared = false;
|
|
50
56
|
let shutdownTimeout = null;
|
|
@@ -5,10 +5,19 @@ import { logger } from "../../utils/logger.js";
|
|
|
5
5
|
import { t } from "../../i18n/index.js";
|
|
6
6
|
import { foregroundSessionState } from "../../scheduled-task/foreground-state.js";
|
|
7
7
|
import { assistantRunState } from "../assistant-run-state.js";
|
|
8
|
+
import { markAttachedSessionIdle } from "../../attach/service.js";
|
|
9
|
+
import { clearPromptResponseMode } from "../handlers/prompt.js";
|
|
10
|
+
import { markUserAbortRequested } from "../utils/abort-error-suppression.js";
|
|
8
11
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
9
12
|
function abortLocalStreaming() {
|
|
10
13
|
clearAllInteractionState("abort_command");
|
|
11
14
|
}
|
|
15
|
+
async function releaseAbortBusyState(sessionId, reason) {
|
|
16
|
+
foregroundSessionState.markIdle(sessionId);
|
|
17
|
+
assistantRunState.clearRun(sessionId, reason);
|
|
18
|
+
await markAttachedSessionIdle(sessionId);
|
|
19
|
+
clearPromptResponseMode(sessionId);
|
|
20
|
+
}
|
|
12
21
|
async function pollSessionStatus(sessionId, directory, maxWaitMs = 5000) {
|
|
13
22
|
const startedAt = Date.now();
|
|
14
23
|
const pollIntervalMs = 500;
|
|
@@ -61,6 +70,7 @@ export async function abortCurrentOperation(ctx, options = {}) {
|
|
|
61
70
|
}
|
|
62
71
|
const controller = new AbortController();
|
|
63
72
|
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
|
73
|
+
markUserAbortRequested(currentSession.id);
|
|
64
74
|
try {
|
|
65
75
|
const { data: abortResult, error: abortError } = await opencodeClient.session.abort({
|
|
66
76
|
sessionID: currentSession.id,
|
|
@@ -69,12 +79,14 @@ export async function abortCurrentOperation(ctx, options = {}) {
|
|
|
69
79
|
clearTimeout(timeoutId);
|
|
70
80
|
if (abortError) {
|
|
71
81
|
logger.warn("[Abort] Abort request failed:", abortError);
|
|
82
|
+
await releaseAbortBusyState(currentSession.id, "abort_unconfirmed");
|
|
72
83
|
if (notifyUser && chatId !== null && waitingMessageId !== null) {
|
|
73
84
|
await ctx.api.editMessageText(chatId, waitingMessageId, t("stop.warn_unconfirmed"));
|
|
74
85
|
}
|
|
75
86
|
return;
|
|
76
87
|
}
|
|
77
88
|
if (abortResult !== true) {
|
|
89
|
+
await releaseAbortBusyState(currentSession.id, "abort_maybe_finished");
|
|
78
90
|
if (notifyUser && chatId !== null && waitingMessageId !== null) {
|
|
79
91
|
await ctx.api.editMessageText(chatId, waitingMessageId, t("stop.warn_maybe_finished"));
|
|
80
92
|
}
|
|
@@ -82,8 +94,7 @@ export async function abortCurrentOperation(ctx, options = {}) {
|
|
|
82
94
|
}
|
|
83
95
|
const finalStatus = await pollSessionStatus(currentSession.id, currentSession.directory, 5000);
|
|
84
96
|
if (finalStatus === "idle" || finalStatus === "not-found") {
|
|
85
|
-
|
|
86
|
-
assistantRunState.clearRun(currentSession.id, "abort_confirmed");
|
|
97
|
+
await releaseAbortBusyState(currentSession.id, "abort_confirmed");
|
|
87
98
|
if (notifyUser && chatId !== null && waitingMessageId !== null) {
|
|
88
99
|
await ctx.api.editMessageText(chatId, waitingMessageId, t("stop.success"));
|
|
89
100
|
}
|
|
@@ -96,6 +107,7 @@ export async function abortCurrentOperation(ctx, options = {}) {
|
|
|
96
107
|
}
|
|
97
108
|
catch (error) {
|
|
98
109
|
clearTimeout(timeoutId);
|
|
110
|
+
await releaseAbortBusyState(currentSession.id, "abort_error");
|
|
99
111
|
if (error instanceof Error && error.name === "AbortError") {
|
|
100
112
|
if (notifyUser && chatId !== null && waitingMessageId !== null) {
|
|
101
113
|
await ctx.api.editMessageText(chatId, waitingMessageId, t("stop.warn_timeout"));
|
|
@@ -5,6 +5,43 @@ import { opencodeReadyLifecycle } from "../../opencode/ready-lifecycle.js";
|
|
|
5
5
|
import { logger } from "../../utils/logger.js";
|
|
6
6
|
import { t } from "../../i18n/index.js";
|
|
7
7
|
import { editBotText } from "../utils/telegram-text.js";
|
|
8
|
+
const SERVER_READY_TIMEOUT_MS = 10_000;
|
|
9
|
+
const SERVER_READY_POLL_INTERVAL_MS = 500;
|
|
10
|
+
const HEALTH_CHECK_TIMEOUT_MS = 3_000;
|
|
11
|
+
const HEALTH_CHECK_TIMED_OUT = Symbol("health-check-timed-out");
|
|
12
|
+
async function healthWithTimeout(timeoutMs = HEALTH_CHECK_TIMEOUT_MS) {
|
|
13
|
+
const controller = new AbortController();
|
|
14
|
+
let timeout;
|
|
15
|
+
try {
|
|
16
|
+
return await Promise.race([
|
|
17
|
+
opencodeClient.global.health({ signal: controller.signal }),
|
|
18
|
+
new Promise((resolve) => {
|
|
19
|
+
timeout = setTimeout(() => {
|
|
20
|
+
controller.abort();
|
|
21
|
+
resolve(HEALTH_CHECK_TIMED_OUT);
|
|
22
|
+
}, timeoutMs);
|
|
23
|
+
}),
|
|
24
|
+
]);
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
if (timeout) {
|
|
28
|
+
clearTimeout(timeout);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async function getHealthIfAvailable() {
|
|
33
|
+
try {
|
|
34
|
+
const result = await healthWithTimeout();
|
|
35
|
+
if (result === HEALTH_CHECK_TIMED_OUT) {
|
|
36
|
+
logger.warn(`[Bot] OpenCode health check timed out after ${HEALTH_CHECK_TIMEOUT_MS}ms`);
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
8
45
|
/**
|
|
9
46
|
* Wait for OpenCode server to become ready by polling health endpoint
|
|
10
47
|
* @param maxWaitMs Maximum time to wait in milliseconds
|
|
@@ -12,18 +49,12 @@ import { editBotText } from "../utils/telegram-text.js";
|
|
|
12
49
|
*/
|
|
13
50
|
async function waitForServerReady(maxWaitMs = 10000) {
|
|
14
51
|
const startTime = Date.now();
|
|
15
|
-
const pollInterval = 500;
|
|
16
52
|
while (Date.now() - startTime < maxWaitMs) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
return true;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
catch {
|
|
24
|
-
// Server not ready yet
|
|
53
|
+
const health = await getHealthIfAvailable();
|
|
54
|
+
if (health?.data?.healthy) {
|
|
55
|
+
return true;
|
|
25
56
|
}
|
|
26
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
57
|
+
await new Promise((resolve) => setTimeout(resolve, SERVER_READY_POLL_INTERVAL_MS));
|
|
27
58
|
}
|
|
28
59
|
return false;
|
|
29
60
|
}
|
|
@@ -40,8 +71,9 @@ export async function opencodeStartCommand(ctx) {
|
|
|
40
71
|
}
|
|
41
72
|
// Check if server is already accessible.
|
|
42
73
|
try {
|
|
43
|
-
const
|
|
44
|
-
|
|
74
|
+
const health = await getHealthIfAvailable();
|
|
75
|
+
const data = health?.data;
|
|
76
|
+
if (data?.healthy) {
|
|
45
77
|
await ctx.reply(t("opencode_start.already_running", { version: data.version || t("common.unknown") }));
|
|
46
78
|
await opencodeReadyLifecycle.notifyReady("opencode_start_already_running");
|
|
47
79
|
return;
|
|
@@ -67,7 +99,7 @@ export async function opencodeStartCommand(ctx) {
|
|
|
67
99
|
}
|
|
68
100
|
childProcess.unref();
|
|
69
101
|
logger.info("[Bot] Waiting for OpenCode server to become ready...");
|
|
70
|
-
const ready = await waitForServerReady(
|
|
102
|
+
const ready = await waitForServerReady(SERVER_READY_TIMEOUT_MS);
|
|
71
103
|
if (!ready) {
|
|
72
104
|
await editBotText({
|
|
73
105
|
api: ctx.api,
|
|
@@ -79,7 +111,7 @@ export async function opencodeStartCommand(ctx) {
|
|
|
79
111
|
});
|
|
80
112
|
return;
|
|
81
113
|
}
|
|
82
|
-
const
|
|
114
|
+
const health = (await getHealthIfAvailable())?.data;
|
|
83
115
|
await editBotText({
|
|
84
116
|
api: ctx.api,
|
|
85
117
|
chatId: ctx.chat.id,
|
|
@@ -8,7 +8,11 @@ import { interactionManager } from "../../interaction/manager.js";
|
|
|
8
8
|
import { logger } from "../../utils/logger.js";
|
|
9
9
|
import { safeBackgroundTask } from "../../utils/safe-background-task.js";
|
|
10
10
|
import { t } from "../../i18n/index.js";
|
|
11
|
+
import { editRenderedBotPart, sendRenderedBotPart } from "../utils/telegram-text.js";
|
|
11
12
|
const MAX_BUTTON_LENGTH = 60;
|
|
13
|
+
const TELEGRAM_MESSAGE_LIMIT = 4096;
|
|
14
|
+
const TRUNCATION_SUFFIX = "…";
|
|
15
|
+
const QUESTION_EMOJI = "❓";
|
|
12
16
|
function getCallbackMessageId(ctx) {
|
|
13
17
|
const message = ctx.callbackQuery?.message;
|
|
14
18
|
if (!message || !("message_id" in message)) {
|
|
@@ -186,12 +190,26 @@ async function updateQuestionMessage(ctx) {
|
|
|
186
190
|
logger.debug("[QuestionHandler] updateQuestionMessage: no current question");
|
|
187
191
|
return;
|
|
188
192
|
}
|
|
189
|
-
const
|
|
193
|
+
const part = formatQuestionDetailsPart(question);
|
|
190
194
|
const keyboard = buildQuestionKeyboard(question, questionManager.getSelectedOptions(questionManager.getCurrentIndex()));
|
|
191
195
|
logger.debug("[QuestionHandler] Updating question message");
|
|
192
196
|
try {
|
|
193
|
-
|
|
194
|
-
|
|
197
|
+
const chatId = ctx.chat?.id;
|
|
198
|
+
const messageId = getCallbackMessageId(ctx);
|
|
199
|
+
if (!chatId || messageId === null) {
|
|
200
|
+
await ctx.editMessageText(part.fallbackText, {
|
|
201
|
+
reply_markup: keyboard,
|
|
202
|
+
});
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
await editRenderedBotPart({
|
|
206
|
+
api: ctx.api,
|
|
207
|
+
chatId,
|
|
208
|
+
messageId,
|
|
209
|
+
part,
|
|
210
|
+
options: {
|
|
211
|
+
reply_markup: keyboard,
|
|
212
|
+
},
|
|
195
213
|
});
|
|
196
214
|
}
|
|
197
215
|
catch (err) {
|
|
@@ -205,16 +223,21 @@ export async function showCurrentQuestion(bot, chatId) {
|
|
|
205
223
|
return;
|
|
206
224
|
}
|
|
207
225
|
logger.debug(`[QuestionHandler] Showing question: ${question.header} - ${question.question}`);
|
|
208
|
-
const
|
|
226
|
+
const part = formatQuestionDetailsPart(question);
|
|
209
227
|
const keyboard = buildQuestionKeyboard(question, questionManager.getSelectedOptions(questionManager.getCurrentIndex()));
|
|
210
228
|
logger.debug(`[QuestionHandler] Sending message with keyboard, chatId=${chatId}`);
|
|
211
229
|
try {
|
|
212
|
-
const
|
|
213
|
-
|
|
230
|
+
const { messageId } = await sendRenderedBotPart({
|
|
231
|
+
api: bot,
|
|
232
|
+
chatId,
|
|
233
|
+
part,
|
|
234
|
+
options: {
|
|
235
|
+
reply_markup: keyboard,
|
|
236
|
+
},
|
|
214
237
|
});
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
questionManager.setActiveMessageId(
|
|
238
|
+
questionManager.addMessageId(messageId);
|
|
239
|
+
logger.debug(`[QuestionHandler] Message sent, messageId=${messageId}`);
|
|
240
|
+
questionManager.setActiveMessageId(messageId);
|
|
218
241
|
syncQuestionInteractionState("callback", questionManager.getCurrentIndex(), questionManager.getActiveMessageId());
|
|
219
242
|
summaryAggregator.stopTypingIndicator();
|
|
220
243
|
}
|
|
@@ -335,14 +358,67 @@ async function sendAllAnswersToAgent(bot, chatId) {
|
|
|
335
358
|
},
|
|
336
359
|
});
|
|
337
360
|
}
|
|
338
|
-
function
|
|
361
|
+
function formatQuestionDetailsPart(question) {
|
|
339
362
|
const currentIndex = questionManager.getCurrentIndex();
|
|
340
363
|
const totalQuestions = questionManager.getTotalQuestions();
|
|
341
364
|
const progressText = totalQuestions > 0 ? `${currentIndex + 1}/${totalQuestions}` : "";
|
|
342
|
-
const headerTitle = [progressText, question.header].filter(Boolean).join(" ");
|
|
343
|
-
const
|
|
365
|
+
const headerTitle = [QUESTION_EMOJI, progressText, question.header].filter(Boolean).join(" ");
|
|
366
|
+
const textParts = [];
|
|
367
|
+
const entities = [];
|
|
368
|
+
if (headerTitle) {
|
|
369
|
+
textParts.push(headerTitle);
|
|
370
|
+
entities.push({ type: "bold", offset: 0, length: headerTitle.length });
|
|
371
|
+
}
|
|
344
372
|
const multiple = question.multiple ? t("question.multi_hint") : "";
|
|
345
|
-
|
|
373
|
+
const questionText = `${question.question}${multiple}`;
|
|
374
|
+
if (questionText) {
|
|
375
|
+
textParts.push(questionText);
|
|
376
|
+
}
|
|
377
|
+
for (const option of question.options) {
|
|
378
|
+
const optionText = formatOptionDetails(option);
|
|
379
|
+
const offset = textParts.join("\n\n").length + (textParts.length > 0 ? 2 : 0);
|
|
380
|
+
if (option.label) {
|
|
381
|
+
entities.push({ type: "bold", offset, length: option.label.length });
|
|
382
|
+
}
|
|
383
|
+
textParts.push(optionText);
|
|
384
|
+
}
|
|
385
|
+
const text = textParts.filter(Boolean).join("\n\n");
|
|
386
|
+
const truncated = truncateQuestionPart(text, entities);
|
|
387
|
+
return {
|
|
388
|
+
text: truncated.text,
|
|
389
|
+
entities: truncated.entities.length > 0 ? truncated.entities : undefined,
|
|
390
|
+
fallbackText: truncated.text,
|
|
391
|
+
source: truncated.entities.length > 0 ? "entities" : "plain",
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
function truncateQuestionPart(text, entities) {
|
|
395
|
+
if (text.length <= TELEGRAM_MESSAGE_LIMIT) {
|
|
396
|
+
return { text, entities };
|
|
397
|
+
}
|
|
398
|
+
const maxBaseLength = TELEGRAM_MESSAGE_LIMIT - TRUNCATION_SUFFIX.length;
|
|
399
|
+
let endIndex = maxBaseLength;
|
|
400
|
+
if (endIndex > 0 && isHighSurrogate(text.charCodeAt(endIndex - 1))) {
|
|
401
|
+
endIndex -= 1;
|
|
402
|
+
}
|
|
403
|
+
const truncatedText = `${text.slice(0, endIndex)}${TRUNCATION_SUFFIX}`;
|
|
404
|
+
const truncatedEntities = entities
|
|
405
|
+
.filter((entity) => entity.offset < endIndex)
|
|
406
|
+
.map((entity) => ({
|
|
407
|
+
...entity,
|
|
408
|
+
length: Math.min(entity.length, endIndex - entity.offset),
|
|
409
|
+
}))
|
|
410
|
+
.filter((entity) => entity.length > 0);
|
|
411
|
+
return { text: truncatedText, entities: truncatedEntities };
|
|
412
|
+
}
|
|
413
|
+
function isHighSurrogate(codeUnit) {
|
|
414
|
+
return codeUnit >= 0xd800 && codeUnit <= 0xdbff;
|
|
415
|
+
}
|
|
416
|
+
function formatOptionDetails(option) {
|
|
417
|
+
const optionTitle = option.label;
|
|
418
|
+
if (!option.description) {
|
|
419
|
+
return optionTitle;
|
|
420
|
+
}
|
|
421
|
+
return `${optionTitle} — ${option.description}`;
|
|
346
422
|
}
|
|
347
423
|
function buildQuestionKeyboard(question, selectedOptions) {
|
|
348
424
|
const keyboard = new InlineKeyboard();
|
|
@@ -351,7 +427,7 @@ function buildQuestionKeyboard(question, selectedOptions) {
|
|
|
351
427
|
question.options.forEach((option, index) => {
|
|
352
428
|
const isSelected = selectedOptions.has(index);
|
|
353
429
|
const icon = isSelected ? "✅ " : "";
|
|
354
|
-
const buttonText = formatButtonText(option.label,
|
|
430
|
+
const buttonText = formatButtonText(option.label, icon);
|
|
355
431
|
const callbackData = `question:select:${questionIndex}:${index}`;
|
|
356
432
|
logger.debug(`[QuestionHandler] Button ${index}: "${buttonText}" -> "${callbackData}"`);
|
|
357
433
|
keyboard.text(buttonText, callbackData).row();
|
|
@@ -367,11 +443,8 @@ function buildQuestionKeyboard(question, selectedOptions) {
|
|
|
367
443
|
logger.debug(`[QuestionHandler] Final keyboard: ${JSON.stringify(keyboard.inline_keyboard)}`);
|
|
368
444
|
return keyboard;
|
|
369
445
|
}
|
|
370
|
-
function formatButtonText(label,
|
|
446
|
+
function formatButtonText(label, icon) {
|
|
371
447
|
let text = `${icon}${label}`;
|
|
372
|
-
if (description && icon === "") {
|
|
373
|
-
text += ` - ${description}`;
|
|
374
|
-
}
|
|
375
448
|
if (text.length > MAX_BUTTON_LENGTH) {
|
|
376
449
|
text = text.substring(0, MAX_BUTTON_LENGTH - 3) + "...";
|
|
377
450
|
}
|
|
@@ -8,6 +8,7 @@ import { isSttConfigured, transcribeAudio } from "../../stt/client.js";
|
|
|
8
8
|
import { processUserPrompt } from "./prompt.js";
|
|
9
9
|
import { logger } from "../../utils/logger.js";
|
|
10
10
|
import { t } from "../../i18n/index.js";
|
|
11
|
+
import { buildTelegramFileUrl } from "../utils/telegram-file-url.js";
|
|
11
12
|
const TELEGRAM_DOWNLOAD_TIMEOUT_MS = 30_000;
|
|
12
13
|
const TELEGRAM_DOWNLOAD_MAX_REDIRECTS = 3;
|
|
13
14
|
let telegramDownloadAgent;
|
|
@@ -80,8 +81,7 @@ async function downloadTelegramFile(ctx, fileId) {
|
|
|
80
81
|
logger.error("[Voice] Telegram getFile returned no file_path");
|
|
81
82
|
return null;
|
|
82
83
|
}
|
|
83
|
-
const
|
|
84
|
-
const fileUrl = `${apiRoot}/file/bot${ctx.api.token}/${file.file_path}`;
|
|
84
|
+
const fileUrl = buildTelegramFileUrl(file.file_path);
|
|
85
85
|
logger.debug(`[Voice] Downloading file: ${file.file_path} (${file.file_size ?? "?"} bytes)`);
|
|
86
86
|
const buffer = await downloadTelegramFileByUrl(fileUrl);
|
|
87
87
|
// Extract filename from file_path (e.g., "voice/file_123.oga" -> "file_123.oga")
|
package/dist/bot/index.js
CHANGED
|
@@ -62,6 +62,7 @@ import { reconcileBusyState } from "./utils/busy-reconciliation.js";
|
|
|
62
62
|
import { finalizeAssistantResponse } from "./utils/finalize-assistant-response.js";
|
|
63
63
|
import { sendTtsResponseForSession } from "./utils/send-tts-response.js";
|
|
64
64
|
import { deliverThinkingMessage } from "./utils/thinking-message.js";
|
|
65
|
+
import { shouldSuppressUserAbortSessionError } from "./utils/abort-error-suppression.js";
|
|
65
66
|
import { editRenderedBotPart, getTelegramRenderedPartSignature, sendRenderedBotPart, } from "./utils/telegram-text.js";
|
|
66
67
|
import { formatAssistantRunFooter } from "./utils/assistant-run-footer.js";
|
|
67
68
|
import { getModelCapabilities, supportsInput } from "../model/capabilities.js";
|
|
@@ -745,6 +746,12 @@ async function ensureEventSubscription(directory) {
|
|
|
745
746
|
toolCallStreamer.flushSession(sessionId, "session_error"),
|
|
746
747
|
]);
|
|
747
748
|
const normalizedMessage = message.trim() || t("common.unknown_error");
|
|
749
|
+
if (shouldSuppressUserAbortSessionError(sessionId, normalizedMessage)) {
|
|
750
|
+
logger.debug(`[Bot] Suppressed user-initiated abort error: session=${sessionId}`);
|
|
751
|
+
foregroundSessionState.markIdle(sessionId);
|
|
752
|
+
await scheduledTaskRuntime.flushDeferredDeliveries();
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
748
755
|
const truncatedMessage = normalizedMessage.length > 3500
|
|
749
756
|
? `${normalizedMessage.slice(0, 3497)}...`
|
|
750
757
|
: normalizedMessage;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { resolveInteractionGuardDecision } from "../../interaction/guard.js";
|
|
2
|
+
import { reconcileForegroundBusyState } from "../utils/busy-guard.js";
|
|
2
3
|
import { logger } from "../../utils/logger.js";
|
|
3
4
|
import { t } from "../../i18n/index.js";
|
|
4
5
|
function getInteractionBlockedMessage(reason, interactionKind) {
|
|
@@ -72,7 +73,11 @@ function getInteractionBlockedMessage(reason, interactionKind) {
|
|
|
72
73
|
}
|
|
73
74
|
}
|
|
74
75
|
export async function interactionGuardMiddleware(ctx, next) {
|
|
75
|
-
|
|
76
|
+
let decision = resolveInteractionGuardDecision(ctx);
|
|
77
|
+
if (!decision.allow && decision.busy) {
|
|
78
|
+
await reconcileForegroundBusyState();
|
|
79
|
+
decision = resolveInteractionGuardDecision(ctx);
|
|
80
|
+
}
|
|
76
81
|
if (decision.allow) {
|
|
77
82
|
await next();
|
|
78
83
|
return;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Covers abort request timeout, post-abort status polling, and delayed SSE reconnect delivery.
|
|
2
|
+
const USER_ABORT_SUPPRESSION_WINDOW_MS = 90_000;
|
|
3
|
+
const userAbortRequestedAtBySession = new Map();
|
|
4
|
+
function deleteExpiredAbortRequests(now = Date.now()) {
|
|
5
|
+
for (const [sessionId, requestedAt] of userAbortRequestedAtBySession) {
|
|
6
|
+
if (now - requestedAt > USER_ABORT_SUPPRESSION_WINDOW_MS) {
|
|
7
|
+
userAbortRequestedAtBySession.delete(sessionId);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function markUserAbortRequested(sessionId) {
|
|
12
|
+
const now = Date.now();
|
|
13
|
+
deleteExpiredAbortRequests(now);
|
|
14
|
+
userAbortRequestedAtBySession.set(sessionId, now);
|
|
15
|
+
}
|
|
16
|
+
export function shouldSuppressUserAbortSessionError(sessionId, message) {
|
|
17
|
+
if (message.trim().toLowerCase() !== "aborted") {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
const requestedAt = userAbortRequestedAtBySession.get(sessionId);
|
|
21
|
+
if (requestedAt === undefined) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
userAbortRequestedAtBySession.delete(sessionId);
|
|
25
|
+
return Date.now() - requestedAt <= USER_ABORT_SUPPRESSION_WINDOW_MS;
|
|
26
|
+
}
|
|
27
|
+
export function __resetUserAbortErrorSuppressionForTests() {
|
|
28
|
+
userAbortRequestedAtBySession.clear();
|
|
29
|
+
}
|
|
30
|
+
export function __getUserAbortErrorSuppressionSizeForTests() {
|
|
31
|
+
return userAbortRequestedAtBySession.size;
|
|
32
|
+
}
|
|
@@ -1,9 +1,35 @@
|
|
|
1
1
|
import { foregroundSessionState } from "../../scheduled-task/foreground-state.js";
|
|
2
2
|
import { attachManager } from "../../attach/manager.js";
|
|
3
|
+
import { reconcileBusyStateNow } from "./busy-reconciliation.js";
|
|
3
4
|
import { t } from "../../i18n/index.js";
|
|
5
|
+
import { logger } from "../../utils/logger.js";
|
|
4
6
|
export function isForegroundBusy() {
|
|
5
7
|
return foregroundSessionState.isBusy() || attachManager.isBusy();
|
|
6
8
|
}
|
|
9
|
+
function getBusyDirectories() {
|
|
10
|
+
const directories = new Set();
|
|
11
|
+
for (const session of foregroundSessionState.getBusySessions()) {
|
|
12
|
+
directories.add(session.directory);
|
|
13
|
+
}
|
|
14
|
+
const attached = attachManager.getSnapshot();
|
|
15
|
+
if (attached?.busy) {
|
|
16
|
+
directories.add(attached.directory);
|
|
17
|
+
}
|
|
18
|
+
return [...directories];
|
|
19
|
+
}
|
|
20
|
+
export async function reconcileForegroundBusyState() {
|
|
21
|
+
if (!isForegroundBusy()) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
for (const directory of getBusyDirectories()) {
|
|
25
|
+
try {
|
|
26
|
+
await reconcileBusyStateNow(directory);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
logger.warn("[BusyGuard] Failed to reconcile foreground busy state", error);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
7
33
|
export async function replyBusyBlocked(ctx) {
|
|
8
34
|
const message = t("bot.session_busy");
|
|
9
35
|
if (ctx.callbackQuery) {
|
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
// @ts-expect-error — node-fetch v2 ships no TS types and we avoid adding @types/node-fetch
|
|
2
2
|
import nodeFetch from "node-fetch";
|
|
3
3
|
import { Agent as HttpsAgent } from "https";
|
|
4
|
-
import { config } from "../../config.js";
|
|
5
4
|
import { logger } from "../../utils/logger.js";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
return config.telegram.apiRoot
|
|
9
|
-
? `${config.telegram.apiRoot.replace(/\/$/, "")}/file/bot`
|
|
10
|
-
: DEFAULT_TELEGRAM_FILE_URL_BASE;
|
|
11
|
-
}
|
|
5
|
+
import { config } from "../../config.js";
|
|
6
|
+
import { buildTelegramFileUrl } from "./telegram-file-url.js";
|
|
12
7
|
const MAX_FILE_SIZE_BYTES = 20 * 1024 * 1024; // 20MB Telegram limit
|
|
13
8
|
/**
|
|
14
9
|
* Download a photo from Telegram servers
|
|
@@ -26,7 +21,7 @@ export async function downloadTelegramFile(api, fileId) {
|
|
|
26
21
|
const sizeMb = (file.file_size / (1024 * 1024)).toFixed(2);
|
|
27
22
|
throw new Error(`File too large: ${sizeMb}MB (max 20MB)`);
|
|
28
23
|
}
|
|
29
|
-
const fileUrl =
|
|
24
|
+
const fileUrl = buildTelegramFileUrl(file.file_path);
|
|
30
25
|
logger.debug(`[FileDownload] Downloading from ${fileUrl.replace(config.telegram.token, "***")}`);
|
|
31
26
|
const fetchOptions = {};
|
|
32
27
|
// Use proxy if configured
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { config } from "../../config.js";
|
|
2
|
+
const DEFAULT_TELEGRAM_API_ROOT = "https://api.telegram.org";
|
|
3
|
+
export function telegramFileUrlBase() {
|
|
4
|
+
const apiRoot = config.telegram.apiRoot
|
|
5
|
+
? config.telegram.apiRoot.replace(/\/+$/, "")
|
|
6
|
+
: DEFAULT_TELEGRAM_API_ROOT;
|
|
7
|
+
return `${apiRoot}/file/bot`;
|
|
8
|
+
}
|
|
9
|
+
export function buildTelegramFileUrl(filePath) {
|
|
10
|
+
return `${telegramFileUrlBase()}${config.telegram.token}/${filePath}`;
|
|
11
|
+
}
|
|
@@ -5,12 +5,35 @@ import { opencodeReadyLifecycle } from "./ready-lifecycle.js";
|
|
|
5
5
|
import { resolveLocalOpencodeTarget, startLocalOpencodeServer, } from "./process.js";
|
|
6
6
|
const SERVER_READY_TIMEOUT_MS = 10000;
|
|
7
7
|
const SERVER_READY_POLL_INTERVAL_MS = 500;
|
|
8
|
+
const HEALTH_CHECK_TIMEOUT_MS = 3000;
|
|
9
|
+
const HEALTH_CHECK_TIMED_OUT = Symbol("health-check-timed-out");
|
|
8
10
|
function sleep(ms) {
|
|
9
11
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
12
|
}
|
|
13
|
+
async function withTimeout(promise, timeoutMs) {
|
|
14
|
+
let timeout;
|
|
15
|
+
try {
|
|
16
|
+
return await Promise.race([
|
|
17
|
+
promise,
|
|
18
|
+
new Promise((resolve) => {
|
|
19
|
+
timeout = setTimeout(() => resolve(HEALTH_CHECK_TIMED_OUT), timeoutMs);
|
|
20
|
+
}),
|
|
21
|
+
]);
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
if (timeout) {
|
|
25
|
+
clearTimeout(timeout);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
11
29
|
async function isOpencodeServerHealthy() {
|
|
12
30
|
try {
|
|
13
|
-
const
|
|
31
|
+
const result = await withTimeout(opencodeClient.global.health(), HEALTH_CHECK_TIMEOUT_MS);
|
|
32
|
+
if (result === HEALTH_CHECK_TIMED_OUT) {
|
|
33
|
+
logger.warn(`[OpenCodeAutoRestart] Health-check timed out after ${HEALTH_CHECK_TIMEOUT_MS}ms`);
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
const { data, error } = result;
|
|
14
37
|
return !error && data?.healthy === true;
|
|
15
38
|
}
|
|
16
39
|
catch {
|
package/dist/opencode/events.js
CHANGED
|
@@ -3,7 +3,9 @@ import { logger } from "../utils/logger.js";
|
|
|
3
3
|
import { isExpectedOpencodeUnavailableError } from "../utils/opencode-error.js";
|
|
4
4
|
const RECONNECT_BASE_DELAY_MS = 1000;
|
|
5
5
|
const RECONNECT_MAX_DELAY_MS = 15000;
|
|
6
|
+
let sseIdleTimeoutMs = 30_000;
|
|
6
7
|
const FATAL_NO_STREAM_ERROR = "No stream returned from event subscription";
|
|
8
|
+
const SSE_IDLE_TIMEOUT_ERROR = "SSE stream idle timeout";
|
|
7
9
|
let eventStream = null;
|
|
8
10
|
let eventCallback = null;
|
|
9
11
|
let isListening = false;
|
|
@@ -32,6 +34,44 @@ function waitWithAbort(ms, signal) {
|
|
|
32
34
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
33
35
|
});
|
|
34
36
|
}
|
|
37
|
+
function createAttemptAbortController(parentSignal) {
|
|
38
|
+
const controller = new AbortController();
|
|
39
|
+
if (parentSignal.aborted) {
|
|
40
|
+
controller.abort();
|
|
41
|
+
return { controller, cleanup: () => { } };
|
|
42
|
+
}
|
|
43
|
+
const onAbort = () => controller.abort();
|
|
44
|
+
parentSignal.addEventListener("abort", onAbort, { once: true });
|
|
45
|
+
return {
|
|
46
|
+
controller,
|
|
47
|
+
cleanup: () => parentSignal.removeEventListener("abort", onAbort),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function readStreamWithIdleTimeout(stream, signal) {
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
let settled = false;
|
|
53
|
+
const finish = (result) => {
|
|
54
|
+
if (settled) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
settled = true;
|
|
58
|
+
clearTimeout(timeout);
|
|
59
|
+
signal.removeEventListener("abort", onAbort);
|
|
60
|
+
resolve(result);
|
|
61
|
+
};
|
|
62
|
+
const onAbort = () => finish({ type: "aborted" });
|
|
63
|
+
const timeout = setTimeout(() => finish({ type: "timeout" }), sseIdleTimeoutMs);
|
|
64
|
+
if (signal.aborted) {
|
|
65
|
+
finish({ type: "aborted" });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
69
|
+
stream.next().then((result) => finish({ type: "next", result }), (error) => finish({ type: "error", error }));
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
function isEventStreamIdleTimeoutError(error) {
|
|
73
|
+
return error instanceof Error && error.message === SSE_IDLE_TIMEOUT_ERROR;
|
|
74
|
+
}
|
|
35
75
|
function isRecord(value) {
|
|
36
76
|
return typeof value === "object" && value !== null;
|
|
37
77
|
}
|
|
@@ -114,15 +154,17 @@ export async function subscribeToEvents(directory, callback) {
|
|
|
114
154
|
let reconnectAttempt = 0;
|
|
115
155
|
let useLegacyEventsOnce = false;
|
|
116
156
|
while (isListening && activeDirectory === directory && !controller.signal.aborted) {
|
|
157
|
+
let attemptAbort = null;
|
|
117
158
|
try {
|
|
118
159
|
let subscription;
|
|
160
|
+
attemptAbort = createAttemptAbortController(controller.signal);
|
|
119
161
|
if (useLegacyEventsOnce) {
|
|
120
162
|
useLegacyEventsOnce = false;
|
|
121
|
-
subscription = await subscribeToLegacyEventStream(directory, controller.signal);
|
|
163
|
+
subscription = await subscribeToLegacyEventStream(directory, attemptAbort.controller.signal);
|
|
122
164
|
}
|
|
123
165
|
else {
|
|
124
166
|
try {
|
|
125
|
-
subscription = await subscribeToGlobalEventStream(controller.signal);
|
|
167
|
+
subscription = await subscribeToGlobalEventStream(attemptAbort.controller.signal);
|
|
126
168
|
logger.debug(`Using global OpenCode event stream for ${directory}`);
|
|
127
169
|
}
|
|
128
170
|
catch (error) {
|
|
@@ -133,43 +175,67 @@ export async function subscribeToEvents(directory, callback) {
|
|
|
133
175
|
throw error;
|
|
134
176
|
}
|
|
135
177
|
logger.warn(`Global event stream unavailable for ${directory}, falling back to project event stream`, error);
|
|
136
|
-
subscription = await subscribeToLegacyEventStream(directory, controller.signal);
|
|
178
|
+
subscription = await subscribeToLegacyEventStream(directory, attemptAbort.controller.signal);
|
|
137
179
|
}
|
|
138
180
|
}
|
|
139
181
|
reconnectAttempt = 0;
|
|
140
182
|
eventStream = subscription.stream;
|
|
141
183
|
let usefulEventCount = 0;
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
184
|
+
try {
|
|
185
|
+
while (isListening && activeDirectory === directory && !controller.signal.aborted) {
|
|
186
|
+
const readResult = await readStreamWithIdleTimeout(eventStream, attemptAbort.controller.signal);
|
|
187
|
+
if (readResult.type === "aborted") {
|
|
188
|
+
logger.debug(`Event listener stopped or changed directory, breaking loop`);
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
if (readResult.type === "timeout") {
|
|
192
|
+
attemptAbort.controller.abort();
|
|
193
|
+
const closeStream = eventStream.return?.(undefined);
|
|
194
|
+
void closeStream?.catch(() => undefined);
|
|
195
|
+
throw new Error(SSE_IDLE_TIMEOUT_ERROR);
|
|
196
|
+
}
|
|
197
|
+
if (readResult.type === "error") {
|
|
198
|
+
throw readResult.error;
|
|
199
|
+
}
|
|
200
|
+
if (readResult.result.done) {
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
const event = readResult.result.value;
|
|
204
|
+
// CRITICAL: Explicitly yield to the event loop BEFORE processing the event
|
|
205
|
+
// This allows grammY to handle getUpdates between SSE events
|
|
206
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
207
|
+
const normalizedEvent = normalizeEvent(event, subscription.source, directory);
|
|
208
|
+
if (!normalizedEvent) {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (normalizedEvent.type !== "server.connected") {
|
|
212
|
+
usefulEventCount++;
|
|
213
|
+
}
|
|
214
|
+
if (eventCallback) {
|
|
215
|
+
// Use setImmediate to avoid blocking the event loop
|
|
216
|
+
// and let grammY process incoming Telegram updates
|
|
217
|
+
const callbackSnapshot = eventCallback;
|
|
218
|
+
setImmediate(() => {
|
|
219
|
+
if (streamAbortController !== controller ||
|
|
220
|
+
controller.signal.aborted ||
|
|
221
|
+
!isListening ||
|
|
222
|
+
activeDirectory !== directory ||
|
|
223
|
+
listenerGeneration !== generation) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
callbackSnapshot(normalizedEvent);
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
logger.error("[Events] Callback failed:", error);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
}
|
|
171
234
|
}
|
|
172
235
|
}
|
|
236
|
+
finally {
|
|
237
|
+
attemptAbort.cleanup();
|
|
238
|
+
}
|
|
173
239
|
eventStream = null;
|
|
174
240
|
if (!isListening || activeDirectory !== directory || controller.signal.aborted) {
|
|
175
241
|
break;
|
|
@@ -188,6 +254,7 @@ export async function subscribeToEvents(directory, callback) {
|
|
|
188
254
|
}
|
|
189
255
|
}
|
|
190
256
|
catch (error) {
|
|
257
|
+
attemptAbort?.cleanup();
|
|
191
258
|
eventStream = null;
|
|
192
259
|
if (controller.signal.aborted || !isListening || activeDirectory !== directory) {
|
|
193
260
|
logger.info("Event listener aborted");
|
|
@@ -199,7 +266,10 @@ export async function subscribeToEvents(directory, callback) {
|
|
|
199
266
|
}
|
|
200
267
|
reconnectAttempt++;
|
|
201
268
|
const reconnectDelay = getReconnectDelayMs(reconnectAttempt);
|
|
202
|
-
if (
|
|
269
|
+
if (isEventStreamIdleTimeoutError(error)) {
|
|
270
|
+
logger.warn(`Event stream idle timeout for ${directory}, reconnecting in ${reconnectDelay}ms (attempt=${reconnectAttempt})`);
|
|
271
|
+
}
|
|
272
|
+
else if (isExpectedOpencodeUnavailableError(error)) {
|
|
203
273
|
logger.warn(`Event stream unavailable for ${directory}, reconnecting in ${reconnectDelay}ms (attempt=${reconnectAttempt})`);
|
|
204
274
|
}
|
|
205
275
|
else {
|
|
@@ -251,3 +321,6 @@ export function stopEventListening() {
|
|
|
251
321
|
activeDirectory = null;
|
|
252
322
|
logger.info("Event listener stopped");
|
|
253
323
|
}
|
|
324
|
+
export function __setSseIdleTimeoutForTests(timeoutMs) {
|
|
325
|
+
sseIdleTimeoutMs = timeoutMs;
|
|
326
|
+
}
|
|
@@ -94,9 +94,26 @@ function findLatestAssistantMessage(messages) {
|
|
|
94
94
|
}
|
|
95
95
|
return null;
|
|
96
96
|
}
|
|
97
|
+
function getAssistantFinishReason(message) {
|
|
98
|
+
for (let index = message.parts.length - 1; index >= 0; index -= 1) {
|
|
99
|
+
const part = message.parts[index];
|
|
100
|
+
if (part?.type === "step-finish" && typeof part.reason === "string" && part.reason.trim()) {
|
|
101
|
+
return part.reason.trim();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (typeof message.info.finish === "string" && message.info.finish.trim()) {
|
|
105
|
+
return message.info.finish.trim();
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
97
109
|
function extractAssistantResult(message) {
|
|
98
110
|
if (!message) {
|
|
99
|
-
return {
|
|
111
|
+
return {
|
|
112
|
+
resultText: null,
|
|
113
|
+
errorMessage: null,
|
|
114
|
+
completed: false,
|
|
115
|
+
message: null,
|
|
116
|
+
};
|
|
100
117
|
}
|
|
101
118
|
const errorMessage = extractErrorMessage(message.info.error);
|
|
102
119
|
if (errorMessage) {
|
|
@@ -108,10 +125,13 @@ function extractAssistantResult(message) {
|
|
|
108
125
|
};
|
|
109
126
|
}
|
|
110
127
|
const resultText = collectResponseText(message.parts);
|
|
128
|
+
const completed = Boolean(message.info.time?.completed);
|
|
129
|
+
const finishReason = getAssistantFinishReason(message);
|
|
130
|
+
const awaitingToolCalls = completed && finishReason === "tool-calls";
|
|
111
131
|
return {
|
|
112
|
-
resultText,
|
|
132
|
+
resultText: awaitingToolCalls ? null : resultText,
|
|
113
133
|
errorMessage: null,
|
|
114
|
-
completed:
|
|
134
|
+
completed: completed && !awaitingToolCalls,
|
|
115
135
|
message,
|
|
116
136
|
};
|
|
117
137
|
}
|
|
@@ -120,6 +140,7 @@ function summarizeAssistantParts(parts) {
|
|
|
120
140
|
id: part.id,
|
|
121
141
|
type: part.type,
|
|
122
142
|
ignored: part.ignored,
|
|
143
|
+
reason: part.reason,
|
|
123
144
|
...(typeof part.text === "string" ? { textLength: part.text.length } : {}),
|
|
124
145
|
...(part.tool ? { tool: part.tool } : {}),
|
|
125
146
|
...(part.state?.status ? { status: part.state.status } : {}),
|
|
@@ -136,6 +157,7 @@ function logEmptyAssistantResponseDiagnostics(taskId, sessionId, directory, mess
|
|
|
136
157
|
id: message.info.id,
|
|
137
158
|
completed: Boolean(message.info.time?.completed),
|
|
138
159
|
summary: Boolean(message.info.summary),
|
|
160
|
+
finish: getAssistantFinishReason(message),
|
|
139
161
|
errorMessage: extractErrorMessage(message.info.error),
|
|
140
162
|
parts: summarizeAssistantParts(message.parts),
|
|
141
163
|
}
|