@grinev/opencode-telegram-bot 0.20.4 → 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.
@@ -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
- foregroundSessionState.markIdle(currentSession.id);
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
- try {
18
- const { data, error } = await opencodeClient.global.health();
19
- if (!error && data?.healthy) {
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, pollInterval));
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 { data, error } = await opencodeClient.global.health();
44
- if (!error && data?.healthy) {
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(10000);
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 { data: health } = await opencodeClient.global.health();
114
+ const health = (await getHealthIfAvailable())?.data;
83
115
  await editBotText({
84
116
  api: ctx.api,
85
117
  chatId: ctx.chat.id,
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
- const decision = resolveInteractionGuardDecision(ctx);
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) {
@@ -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
- for await (const event of eventStream) {
143
- if (!isListening || activeDirectory !== directory || controller.signal.aborted) {
144
- logger.debug(`Event listener stopped or changed directory, breaking loop`);
145
- break;
146
- }
147
- // CRITICAL: Explicitly yield to the event loop BEFORE processing the event
148
- // This allows grammY to handle getUpdates between SSE events
149
- await new Promise((resolve) => setImmediate(resolve));
150
- const normalizedEvent = normalizeEvent(event, subscription.source, directory);
151
- if (!normalizedEvent) {
152
- continue;
153
- }
154
- if (normalizedEvent.type !== "server.connected") {
155
- usefulEventCount++;
156
- }
157
- if (eventCallback) {
158
- // Use setImmediate to avoid blocking the event loop
159
- // and let grammY process incoming Telegram updates
160
- const callbackSnapshot = eventCallback;
161
- setImmediate(() => {
162
- if (streamAbortController !== controller ||
163
- controller.signal.aborted ||
164
- !isListening ||
165
- activeDirectory !== directory ||
166
- listenerGeneration !== generation) {
167
- return;
168
- }
169
- callbackSnapshot(normalizedEvent);
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 (isExpectedOpencodeUnavailableError(error)) {
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 { resultText: null, errorMessage: null, completed: false, message: null };
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: Boolean(message.info.time?.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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grinev/opencode-telegram-bot",
3
- "version": "0.20.4",
3
+ "version": "0.20.5",
4
4
  "description": "Telegram bot client for OpenCode to run and monitor coding tasks from chat.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",