@otto-assistant/otto 0.7.15 → 0.7.16

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.
@@ -429,6 +429,40 @@ describe('/model', () => {
429
429
  Select a provider:"
430
430
  `);
431
431
  });
432
+ test('shows provider list error details', async () => {
433
+ const textChannel = { id: 'channel-1', type: ChannelType.GuildText };
434
+ const { command } = createCommand({ channel: textChannel });
435
+ mockGetOttoMetadata.mockResolvedValue({ projectDirectory: '/repo' });
436
+ mockInitializeOpencodeForDirectory.mockResolvedValue(() => {
437
+ return {
438
+ provider: {
439
+ list: vi.fn(async () => {
440
+ return {
441
+ error: {
442
+ name: 'UnknownError',
443
+ data: {
444
+ message: 'Cursor token refresh failed: {"code":"internal","message":"Error"}',
445
+ },
446
+ },
447
+ };
448
+ }),
449
+ },
450
+ };
451
+ });
452
+ await handleModelCommand({
453
+ interaction: command,
454
+ appId: 'app-1',
455
+ });
456
+ expect(command.editReply.mock.calls).toMatchInlineSnapshot(`
457
+ [
458
+ [
459
+ {
460
+ "content": "Failed to fetch providers: Cursor token refresh failed: {"code":"internal","message":"Error"}",
461
+ },
462
+ ],
463
+ ]
464
+ `);
465
+ });
432
466
  });
433
467
  describe('/agent', () => {
434
468
  test('shows selectable agents and current channel override', async () => {
@@ -13,6 +13,7 @@ import crypto from 'node:crypto';
13
13
  import { initializeOpencodeForDirectory, getOpencodeServerPort, } from '../opencode.js';
14
14
  import { resolveTextChannel, getOttoMetadata } from '../discord-utils.js';
15
15
  import { createLogger, LogPrefix } from '../logger.js';
16
+ import { formatOpenCodeResponseError } from '../errors.js';
16
17
  import { buildPaginatedOptions, parsePaginationValue } from './paginated-select.js';
17
18
  const loginLogger = createLogger(LogPrefix.LOGIN);
18
19
  // ── Context store ───────────────────────────────────────────────
@@ -141,7 +142,9 @@ export async function handleLoginCommand({ interaction, }) {
141
142
  directory: projectDirectory,
142
143
  });
143
144
  if (!providersResponse.data) {
144
- await interaction.editReply({ content: 'Failed to fetch providers' });
145
+ await interaction.editReply({
146
+ content: `Failed to fetch providers: ${formatOpenCodeResponseError(providersResponse.error)}`,
147
+ });
145
148
  return;
146
149
  }
147
150
  const { all: allProviders, connected } = providersResponse.data;
@@ -272,7 +275,10 @@ async function handleProviderStep(interaction, ctx, hash, providerId) {
272
275
  }
273
276
  const providersResponse = await getClient().provider.list({ directory: ctx.dir });
274
277
  if (!providersResponse.data) {
275
- await interaction.editReply({ content: 'Failed to fetch providers', components: [] });
278
+ await interaction.editReply({
279
+ content: `Failed to fetch providers: ${formatOpenCodeResponseError(providersResponse.error)}`,
280
+ components: [],
281
+ });
276
282
  return;
277
283
  }
278
284
  const { all: allProviders, connected } = providersResponse.data;
@@ -14,6 +14,7 @@ import { getCurrentModelInfo, ensureSessionPreferencesSnapshot, } from './model.
14
14
  import { getRuntime } from '../session-handler/thread-session-runtime.js';
15
15
  import { getThinkingValuesForModel } from '../thinking-utils.js';
16
16
  import { createLogger, LogPrefix } from '../logger.js';
17
+ import { formatOpenCodeResponseError } from '../errors.js';
17
18
  const logger = createLogger(LogPrefix.MODEL);
18
19
  const pendingVariantContexts = new Map();
19
20
  /** 10 minute TTL for pending contexts to prevent unbounded map growth */
@@ -121,7 +122,9 @@ export async function handleModelVariantCommand({ interaction, appId, }) {
121
122
  return;
122
123
  }
123
124
  if (!providersResponse.data) {
124
- await interaction.editReply({ content: 'Failed to fetch providers' });
125
+ await interaction.editReply({
126
+ content: `Failed to fetch providers: ${formatOpenCodeResponseError(providersResponse.error)}`,
127
+ });
125
128
  return;
126
129
  }
127
130
  const { providerID, modelID, model: fullModelId } = currentModelInfo;
@@ -8,6 +8,7 @@ import { getDefaultModel } from '../session-handler/model-utils.js';
8
8
  import { getRuntime } from '../session-handler/thread-session-runtime.js';
9
9
  import { getThinkingValuesForModel } from '../thinking-utils.js';
10
10
  import { createLogger, LogPrefix } from '../logger.js';
11
+ import { formatOpenCodeResponseError } from '../errors.js';
11
12
  import * as errore from 'errore';
12
13
  import { buildPaginatedOptions, parsePaginationValue } from './paginated-select.js';
13
14
  const modelLogger = createLogger(LogPrefix.MODEL);
@@ -270,7 +271,7 @@ export async function handleModelCommand({ interaction, appId, }) {
270
271
  ]);
271
272
  if (!providersResponse.data) {
272
273
  await interaction.editReply({
273
- content: 'Failed to fetch providers',
274
+ content: `Failed to fetch providers: ${formatOpenCodeResponseError(providersResponse.error)}`,
274
275
  });
275
276
  return;
276
277
  }
@@ -393,7 +394,10 @@ export async function handleProviderSelectMenu(interaction) {
393
394
  }
394
395
  const providersResponse = await getClient().provider.list({ directory: context.dir });
395
396
  if (!providersResponse.data) {
396
- await interaction.editReply({ content: 'Failed to fetch providers', components: [] });
397
+ await interaction.editReply({
398
+ content: `Failed to fetch providers: ${formatOpenCodeResponseError(providersResponse.error)}`,
399
+ components: [],
400
+ });
397
401
  return;
398
402
  }
399
403
  const { all: allProviders, connected } = providersResponse.data;
@@ -434,7 +438,7 @@ export async function handleProviderSelectMenu(interaction) {
434
438
  });
435
439
  if (!providersResponse.data) {
436
440
  await interaction.editReply({
437
- content: 'Failed to fetch providers',
441
+ content: `Failed to fetch providers: ${formatOpenCodeResponseError(providersResponse.error)}`,
438
442
  components: [],
439
443
  });
440
444
  return;
@@ -43,11 +43,11 @@ import * as errore from "errore";
43
43
  import { createLogger, formatErrorWithStack, LogPrefix } from "./logger.js";
44
44
  import { writeHeapSnapshot, startHeapMonitor } from "./heap-monitor.js";
45
45
  import { startTaskRunner } from "./task-runner.js";
46
+ import { setGlobalDispatcher, Agent } from "undici";
46
47
  // Increase connection pool to prevent deadlock when multiple sessions have open SSE streams.
47
48
  // Each session's event.subscribe() holds a connection; without enough connections,
48
49
  // regular HTTP requests (question.reply, session.prompt) get blocked → deadlock.
49
- // undici is a transitive dep from discord.js — not listed in our package.json.
50
- // Types are declared in src/undici.d.ts.
50
+ setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0, connections: 500 }));
51
51
  const discordLogger = createLogger(LogPrefix.DISCORD);
52
52
  const voiceLogger = createLogger(LogPrefix.VOICE);
53
53
  // Well-known WebSocket and Discord Gateway close codes for diagnostic logging.
package/dist/errors.js CHANGED
@@ -155,3 +155,25 @@ export class GitCommandError extends createTaggedError({
155
155
  message: 'Git command failed: $command',
156
156
  }) {
157
157
  }
158
+ export function formatOpenCodeResponseError(error) {
159
+ if (error && typeof error === 'object') {
160
+ if ('data' in error &&
161
+ error.data &&
162
+ typeof error.data === 'object' &&
163
+ 'message' in error.data) {
164
+ return String(error.data.message);
165
+ }
166
+ if ('errors' in error &&
167
+ Array.isArray(error.errors) &&
168
+ error.errors.length > 0) {
169
+ return JSON.stringify(error.errors);
170
+ }
171
+ if ('message' in error && typeof error.message === 'string') {
172
+ return error.message;
173
+ }
174
+ if ('name' in error && typeof error.name === 'string') {
175
+ return error.name;
176
+ }
177
+ }
178
+ return 'Unknown OpenCode API error';
179
+ }
package/dist/opencode.js CHANGED
@@ -39,7 +39,7 @@ const STARTUP_STDERR_TAIL_LIMIT = 30;
39
39
  const STARTUP_STDERR_LINE_MAX_LENGTH = 120;
40
40
  const STARTUP_ERROR_REASON_MAX_LENGTH = 1500;
41
41
  const ANSI_ESCAPE_REGEX = /[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g;
42
- async function requestHealthcheck({ url, }) {
42
+ async function requestHealthcheck({ url, timeoutMs = 2000, }) {
43
43
  return new Promise((resolve, reject) => {
44
44
  const req = http.request(url, {
45
45
  method: 'GET',
@@ -58,6 +58,13 @@ async function requestHealthcheck({ url, }) {
58
58
  });
59
59
  });
60
60
  });
61
+ // Without a timeout, a stalled connection (TCP accepted during opencode
62
+ // startup before the HTTP layer is ready) hangs forever, wedging
63
+ // waitForServer on its first iteration and blocking the entire single
64
+ // server startup promise. Abort and let the poll loop retry instead.
65
+ req.setTimeout(timeoutMs, () => {
66
+ req.destroy(new Error(`healthcheck timed out after ${timeoutMs}ms`));
67
+ });
61
68
  req.on('error', reject);
62
69
  req.end();
63
70
  });
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "module": "index.ts",
9
9
  "type": "module",
10
- "version": "0.7.15",
10
+ "version": "0.7.16",
11
11
  "scripts": {
12
12
  "dev": "tsx src/bin.ts",
13
13
  "prepublishOnly": "pnpm build",
@@ -52,8 +52,7 @@
52
52
  "opencode-cached-provider": "workspace:^",
53
53
  "opencode-deterministic-provider": "workspace:^",
54
54
  "prisma": "7.4.2",
55
- "tsx": "^4.20.5",
56
- "undici": "^8.0.2"
55
+ "tsx": "^4.20.5"
57
56
  },
58
57
  "dependencies": {
59
58
  "@ai-sdk/google": "^3.0.53",
@@ -88,6 +87,7 @@
88
87
  "proper-lockfile": "^4.1.2",
89
88
  "string-dedent": "^3.0.2",
90
89
  "traforo": "^0.4.0",
90
+ "undici": "^8.0.2",
91
91
  "ws": "^8.19.0",
92
92
  "xdg-basedir": "^5.1.0",
93
93
  "yaml": "^2.8.3",
@@ -512,6 +512,44 @@ describe('/model', () => {
512
512
  `,
513
513
  )
514
514
  })
515
+
516
+ test('shows provider list error details', async () => {
517
+ const textChannel = { id: 'channel-1', type: ChannelType.GuildText }
518
+ const { command } = createCommand({ channel: textChannel })
519
+
520
+ mockGetOttoMetadata.mockResolvedValue({ projectDirectory: '/repo' })
521
+ mockInitializeOpencodeForDirectory.mockResolvedValue(() => {
522
+ return {
523
+ provider: {
524
+ list: vi.fn(async () => {
525
+ return {
526
+ error: {
527
+ name: 'UnknownError',
528
+ data: {
529
+ message: 'Cursor token refresh failed: {"code":"internal","message":"Error"}',
530
+ },
531
+ },
532
+ }
533
+ }),
534
+ },
535
+ }
536
+ })
537
+
538
+ await handleModelCommand({
539
+ interaction: command as never,
540
+ appId: 'app-1',
541
+ })
542
+
543
+ expect(command.editReply.mock.calls).toMatchInlineSnapshot(`
544
+ [
545
+ [
546
+ {
547
+ "content": "Failed to fetch providers: Cursor token refresh failed: {"code":"internal","message":"Error"}",
548
+ },
549
+ ],
550
+ ]
551
+ `)
552
+ })
515
553
  })
516
554
 
517
555
  describe('/agent', () => {
@@ -34,6 +34,7 @@ import {
34
34
  } from '../opencode.js'
35
35
  import { resolveTextChannel, getOttoMetadata } from '../discord-utils.js'
36
36
  import { createLogger, LogPrefix } from '../logger.js'
37
+ import { formatOpenCodeResponseError } from '../errors.js'
37
38
  import { buildPaginatedOptions, parsePaginationValue } from './paginated-select.js'
38
39
 
39
40
  const loginLogger = createLogger(LogPrefix.LOGIN)
@@ -266,7 +267,9 @@ export async function handleLoginCommand({
266
267
  })
267
268
 
268
269
  if (!providersResponse.data) {
269
- await interaction.editReply({ content: 'Failed to fetch providers' })
270
+ await interaction.editReply({
271
+ content: `Failed to fetch providers: ${formatOpenCodeResponseError(providersResponse.error)}`,
272
+ })
270
273
  return
271
274
  }
272
275
 
@@ -416,7 +419,10 @@ async function handleProviderStep(
416
419
  }
417
420
  const providersResponse = await getClient().provider.list({ directory: ctx.dir })
418
421
  if (!providersResponse.data) {
419
- await interaction.editReply({ content: 'Failed to fetch providers', components: [] })
422
+ await interaction.editReply({
423
+ content: `Failed to fetch providers: ${formatOpenCodeResponseError(providersResponse.error)}`,
424
+ components: [],
425
+ })
420
426
  return
421
427
  }
422
428
  const { all: allProviders, connected } = providersResponse.data
@@ -34,6 +34,7 @@ import {
34
34
  import { getRuntime } from '../session-handler/thread-session-runtime.js'
35
35
  import { getThinkingValuesForModel } from '../thinking-utils.js'
36
36
  import { createLogger, LogPrefix } from '../logger.js'
37
+ import { formatOpenCodeResponseError } from '../errors.js'
37
38
 
38
39
  const logger = createLogger(LogPrefix.MODEL)
39
40
 
@@ -184,7 +185,9 @@ export async function handleModelVariantCommand({
184
185
  }
185
186
 
186
187
  if (!providersResponse.data) {
187
- await interaction.editReply({ content: 'Failed to fetch providers' })
188
+ await interaction.editReply({
189
+ content: `Failed to fetch providers: ${formatOpenCodeResponseError(providersResponse.error)}`,
190
+ })
188
191
  return
189
192
  }
190
193
 
@@ -30,6 +30,7 @@ import { getDefaultModel } from '../session-handler/model-utils.js'
30
30
  import { getRuntime } from '../session-handler/thread-session-runtime.js'
31
31
  import { getThinkingValuesForModel } from '../thinking-utils.js'
32
32
  import { createLogger, LogPrefix } from '../logger.js'
33
+ import { formatOpenCodeResponseError } from '../errors.js'
33
34
  import * as errore from 'errore'
34
35
  import { buildPaginatedOptions, parsePaginationValue } from './paginated-select.js'
35
36
 
@@ -447,7 +448,7 @@ export async function handleModelCommand({
447
448
 
448
449
  if (!providersResponse.data) {
449
450
  await interaction.editReply({
450
- content: 'Failed to fetch providers',
451
+ content: `Failed to fetch providers: ${formatOpenCodeResponseError(providersResponse.error)}`,
451
452
  })
452
453
  return
453
454
  }
@@ -596,7 +597,10 @@ export async function handleProviderSelectMenu(
596
597
  }
597
598
  const providersResponse = await getClient().provider.list({ directory: context.dir })
598
599
  if (!providersResponse.data) {
599
- await interaction.editReply({ content: 'Failed to fetch providers', components: [] })
600
+ await interaction.editReply({
601
+ content: `Failed to fetch providers: ${formatOpenCodeResponseError(providersResponse.error)}`,
602
+ components: [],
603
+ })
600
604
  return
601
605
  }
602
606
  const { all: allProviders, connected } = providersResponse.data
@@ -640,7 +644,7 @@ export async function handleProviderSelectMenu(
640
644
 
641
645
  if (!providersResponse.data) {
642
646
  await interaction.editReply({
643
- content: 'Failed to fetch providers',
647
+ content: `Failed to fetch providers: ${formatOpenCodeResponseError(providersResponse.error)}`,
644
648
  components: [],
645
649
  })
646
650
  return
@@ -130,11 +130,13 @@ import * as errore from "errore";
130
130
  import { createLogger, formatErrorWithStack, LogPrefix } from "./logger.js";
131
131
  import { writeHeapSnapshot, startHeapMonitor } from "./heap-monitor.js";
132
132
  import { startTaskRunner } from "./task-runner.js";
133
+ import { setGlobalDispatcher, Agent } from "undici";
133
134
  // Increase connection pool to prevent deadlock when multiple sessions have open SSE streams.
134
135
  // Each session's event.subscribe() holds a connection; without enough connections,
135
136
  // regular HTTP requests (question.reply, session.prompt) get blocked → deadlock.
136
- // undici is a transitive dep from discord.js — not listed in our package.json.
137
- // Types are declared in src/undici.d.ts.
137
+ setGlobalDispatcher(
138
+ new Agent({ headersTimeout: 0, bodyTimeout: 0, connections: 500 }),
139
+ );
138
140
 
139
141
  const discordLogger = createLogger(LogPrefix.DISCORD);
140
142
  const voiceLogger = createLogger(LogPrefix.VOICE);
package/src/errors.ts CHANGED
@@ -168,6 +168,37 @@ export class GitCommandError extends createTaggedError({
168
168
  message: 'Git command failed: $command',
169
169
  }) {}
170
170
 
171
+ export function formatOpenCodeResponseError(error: unknown): string {
172
+ if (error && typeof error === 'object') {
173
+ if (
174
+ 'data' in error &&
175
+ error.data &&
176
+ typeof error.data === 'object' &&
177
+ 'message' in error.data
178
+ ) {
179
+ return String(error.data.message)
180
+ }
181
+
182
+ if (
183
+ 'errors' in error &&
184
+ Array.isArray(error.errors) &&
185
+ error.errors.length > 0
186
+ ) {
187
+ return JSON.stringify(error.errors)
188
+ }
189
+
190
+ if ('message' in error && typeof error.message === 'string') {
191
+ return error.message
192
+ }
193
+
194
+ if ('name' in error && typeof error.name === 'string') {
195
+ return error.name
196
+ }
197
+ }
198
+
199
+ return 'Unknown OpenCode API error'
200
+ }
201
+
171
202
  // ═══════════════════════════════════════════════════════════════════════════
172
203
  // UNION TYPES - For function signatures
173
204
  // ═══════════════════════════════════════════════════════════════════════════
package/src/opencode.ts CHANGED
@@ -82,8 +82,10 @@ const ANSI_ESCAPE_REGEX =
82
82
 
83
83
  async function requestHealthcheck({
84
84
  url,
85
+ timeoutMs = 2000,
85
86
  }: {
86
87
  url: string
88
+ timeoutMs?: number
87
89
  }): Promise<{ status: number; body: string }> {
88
90
  return new Promise((resolve, reject) => {
89
91
  const req = http.request(
@@ -107,6 +109,13 @@ async function requestHealthcheck({
107
109
  })
108
110
  },
109
111
  )
112
+ // Without a timeout, a stalled connection (TCP accepted during opencode
113
+ // startup before the HTTP layer is ready) hangs forever, wedging
114
+ // waitForServer on its first iteration and blocking the entire single
115
+ // server startup promise. Abort and let the poll loop retry instead.
116
+ req.setTimeout(timeoutMs, () => {
117
+ req.destroy(new Error(`healthcheck timed out after ${timeoutMs}ms`))
118
+ })
110
119
  req.on('error', reject)
111
120
  req.end()
112
121
  })