@otto-assistant/otto 0.7.15 → 0.7.17
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/commands/discord-commands-group-a.test.js +34 -0
- package/dist/commands/login.js +8 -2
- package/dist/commands/model-variant.js +59 -15
- package/dist/commands/model.js +62 -18
- package/dist/commands/unset-model.js +48 -9
- package/dist/discord-bot.js +2 -2
- package/dist/errors.js +22 -0
- package/dist/interaction-handler.js +15 -3
- package/dist/opencode.js +8 -1
- package/package.json +3 -3
- package/src/commands/discord-commands-group-a.test.ts +38 -0
- package/src/commands/login.ts +8 -2
- package/src/commands/model-variant.ts +72 -14
- package/src/commands/model.ts +75 -17
- package/src/commands/unset-model.ts +60 -8
- package/src/discord-bot.ts +4 -2
- package/src/errors.ts +31 -0
- package/src/interaction-handler.ts +21 -1
- package/src/opencode.ts +9 -0
|
@@ -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 () => {
|
package/dist/commands/login.js
CHANGED
|
@@ -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({
|
|
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({
|
|
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;
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// Cross-menu state: Discord doesn't expose already-selected values on sibling
|
|
6
6
|
// select menus in the same message. We track partial selections in the context
|
|
7
7
|
// Map. Whichever menu fires second sees the first selection stored and applies.
|
|
8
|
-
import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, MessageFlags, } from 'discord.js';
|
|
8
|
+
import { ChatInputCommandInteraction, StringSelectMenuInteraction, ButtonInteraction, StringSelectMenuBuilder, ButtonBuilder, ButtonStyle, ActionRowBuilder, ChannelType, MessageFlags, } from 'discord.js';
|
|
9
9
|
import crypto from 'node:crypto';
|
|
10
10
|
import { setChannelModel, setSessionModel, getThreadSession, setGlobalModel, getVariantCascade, } from '../database.js';
|
|
11
11
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
@@ -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({
|
|
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;
|
|
@@ -310,21 +313,20 @@ async function applyVariant({ interaction, context, variant, scope, contextHash,
|
|
|
310
313
|
variant,
|
|
311
314
|
});
|
|
312
315
|
logger.log(`Set variant ${variant ?? 'none'} for session ${context.sessionId} (model ${modelId})`);
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
const
|
|
321
|
-
? '\n_Restarting current request with new variant..._'
|
|
322
|
-
: '';
|
|
316
|
+
// Show a button so the user can choose to resend the last message.
|
|
317
|
+
// Do not auto-retry — the user may want to add context in a new message instead.
|
|
318
|
+
// Context is kept alive (TTL handles cleanup) so the button can access it.
|
|
319
|
+
const retryButton = new ButtonBuilder()
|
|
320
|
+
.setCustomId(`variant_retry:${contextHash}`)
|
|
321
|
+
.setLabel('Resend last message')
|
|
322
|
+
.setStyle(ButtonStyle.Secondary);
|
|
323
|
+
const actionRow = new ActionRowBuilder().addComponents(retryButton);
|
|
323
324
|
await interaction.editReply({
|
|
324
|
-
content: `Variant set for this session:\n**${context.providerName}** / **${context.modelName}**${variantSuffix}\n\`${modelId}\`${
|
|
325
|
+
content: `Variant set for this session:\n**${context.providerName}** / **${context.modelName}**${variantSuffix}\n\`${modelId}\`${agentTip}`,
|
|
325
326
|
flags: MessageFlags.SuppressEmbeds,
|
|
326
|
-
components: [],
|
|
327
|
+
components: [actionRow],
|
|
327
328
|
});
|
|
329
|
+
return;
|
|
328
330
|
}
|
|
329
331
|
else if (scope === 'global') {
|
|
330
332
|
await setGlobalModel({ appId: context.appId, modelId, variant });
|
|
@@ -354,7 +356,11 @@ async function applyVariant({ interaction, context, variant, scope, contextHash,
|
|
|
354
356
|
components: [],
|
|
355
357
|
});
|
|
356
358
|
}
|
|
357
|
-
|
|
359
|
+
// Clean up the context from memory, except for session scope where the
|
|
360
|
+
// retry button still needs it. TTL handles cleanup in that case.
|
|
361
|
+
if (scope !== 'session') {
|
|
362
|
+
pendingVariantContexts.delete(contextHash);
|
|
363
|
+
}
|
|
358
364
|
}
|
|
359
365
|
catch (error) {
|
|
360
366
|
logger.error('Error applying variant:', error);
|
|
@@ -364,3 +370,41 @@ async function applyVariant({ interaction, context, variant, scope, contextHash,
|
|
|
364
370
|
});
|
|
365
371
|
}
|
|
366
372
|
}
|
|
373
|
+
/**
|
|
374
|
+
* Handle the "Resend last message" button shown after a session-scope variant change.
|
|
375
|
+
* Aborts any active run and resends the last user prompt with the new variant.
|
|
376
|
+
*/
|
|
377
|
+
export async function handleVariantRetryButton(interaction) {
|
|
378
|
+
const customId = interaction.customId;
|
|
379
|
+
if (!customId.startsWith('variant_retry:')) {
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
await interaction.deferUpdate();
|
|
383
|
+
const contextHash = customId.replace('variant_retry:', '');
|
|
384
|
+
const context = pendingVariantContexts.get(contextHash);
|
|
385
|
+
// Context may have expired (TTL) — still allow the action via runtime alone.
|
|
386
|
+
const threadId = context?.thread?.id;
|
|
387
|
+
if (!threadId) {
|
|
388
|
+
await interaction.editReply({
|
|
389
|
+
content: 'Could not find the session thread. Please resend your message manually.',
|
|
390
|
+
components: [],
|
|
391
|
+
});
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const runtime = getRuntime(threadId);
|
|
395
|
+
if (!runtime) {
|
|
396
|
+
await interaction.editReply({
|
|
397
|
+
content: 'No active session found. Please resend your message manually.',
|
|
398
|
+
components: [],
|
|
399
|
+
});
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const retried = await runtime.retryLastUserPrompt();
|
|
403
|
+
pendingVariantContexts.delete(contextHash);
|
|
404
|
+
await interaction.editReply({
|
|
405
|
+
content: retried
|
|
406
|
+
? `Restarting with new variant...`
|
|
407
|
+
: `Variant changed. Send a new message to continue.`,
|
|
408
|
+
components: [],
|
|
409
|
+
});
|
|
410
|
+
}
|
package/dist/commands/model.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// /model command - Set the preferred model for this channel or session.
|
|
2
|
-
import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, MessageFlags, } from 'discord.js';
|
|
2
|
+
import { ChatInputCommandInteraction, StringSelectMenuInteraction, ButtonInteraction, StringSelectMenuBuilder, ButtonBuilder, ButtonStyle, ActionRowBuilder, ChannelType, MessageFlags, } from 'discord.js';
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
4
|
import { setChannelModel, setSessionModel, setSessionAgent, getChannelModel, getSessionModel, getSessionAgent, getChannelAgent, getThreadSession, getGlobalModel, setGlobalModel, getVariantCascade, } from '../database.js';
|
|
5
5
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
@@ -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:
|
|
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({
|
|
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:
|
|
441
|
+
content: `Failed to fetch providers: ${formatOpenCodeResponseError(providersResponse.error)}`,
|
|
438
442
|
components: [],
|
|
439
443
|
});
|
|
440
444
|
return;
|
|
@@ -737,21 +741,20 @@ export async function handleModelScopeSelectMenu(interaction) {
|
|
|
737
741
|
}
|
|
738
742
|
await setSessionModel({ sessionId: context.sessionId, modelId, variant });
|
|
739
743
|
modelLogger.log(`Set model ${modelId}${variantSuffix} for session ${context.sessionId}`);
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
const
|
|
748
|
-
? '\n_Restarting current request with new model..._'
|
|
749
|
-
: '';
|
|
744
|
+
// Show a button so the user can choose to resend the last message.
|
|
745
|
+
// Do not auto-retry — the user may want to add context in a new message instead.
|
|
746
|
+
// Context is kept alive (TTL handles cleanup) so the button can access it.
|
|
747
|
+
const retryButton = new ButtonBuilder()
|
|
748
|
+
.setCustomId(`model_retry:${contextHash}`)
|
|
749
|
+
.setLabel('Resend last message')
|
|
750
|
+
.setStyle(ButtonStyle.Secondary);
|
|
751
|
+
const actionRow = new ActionRowBuilder().addComponents(retryButton);
|
|
750
752
|
await interaction.editReply({
|
|
751
|
-
content: `Model set for this session:\n**${context.providerName}** / **${modelDisplay}**${variantSuffix}\n\`${modelId}\`${
|
|
753
|
+
content: `Model set for this session:\n**${context.providerName}** / **${modelDisplay}**${variantSuffix}\n\`${modelId}\`${agentTip}`,
|
|
752
754
|
flags: MessageFlags.SuppressEmbeds,
|
|
753
|
-
components: [],
|
|
755
|
+
components: [actionRow],
|
|
754
756
|
});
|
|
757
|
+
return;
|
|
755
758
|
}
|
|
756
759
|
else if (selectedScope === 'global') {
|
|
757
760
|
if (!context.appId) {
|
|
@@ -781,8 +784,11 @@ export async function handleModelScopeSelectMenu(interaction) {
|
|
|
781
784
|
components: [],
|
|
782
785
|
});
|
|
783
786
|
}
|
|
784
|
-
// Clean up the context from memory
|
|
785
|
-
|
|
787
|
+
// Clean up the context from memory, except for session scope where the
|
|
788
|
+
// retry button still needs it. TTL handles cleanup in that case.
|
|
789
|
+
if (selectedScope !== 'session') {
|
|
790
|
+
deleteModelContext(contextHash);
|
|
791
|
+
}
|
|
786
792
|
}
|
|
787
793
|
catch (error) {
|
|
788
794
|
modelLogger.error('Error saving model preference:', error);
|
|
@@ -792,3 +798,41 @@ export async function handleModelScopeSelectMenu(interaction) {
|
|
|
792
798
|
});
|
|
793
799
|
}
|
|
794
800
|
}
|
|
801
|
+
/**
|
|
802
|
+
* Handle the "Resend last message" button shown after a session-scope model change.
|
|
803
|
+
* Aborts any active run and resends the last user prompt with the new model.
|
|
804
|
+
*/
|
|
805
|
+
export async function handleModelRetryButton(interaction) {
|
|
806
|
+
const customId = interaction.customId;
|
|
807
|
+
if (!customId.startsWith('model_retry:')) {
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
await interaction.deferUpdate();
|
|
811
|
+
const contextHash = customId.replace('model_retry:', '');
|
|
812
|
+
const context = pendingModelContexts.get(contextHash);
|
|
813
|
+
// Context may have expired (10 min TTL) — still allow the action via runtime alone.
|
|
814
|
+
const threadId = context?.thread?.id;
|
|
815
|
+
if (!threadId) {
|
|
816
|
+
await interaction.editReply({
|
|
817
|
+
content: 'Could not find the session thread. Please resend your message manually.',
|
|
818
|
+
components: [],
|
|
819
|
+
});
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
const runtime = getRuntime(threadId);
|
|
823
|
+
if (!runtime) {
|
|
824
|
+
await interaction.editReply({
|
|
825
|
+
content: 'No active session found. Please resend your message manually.',
|
|
826
|
+
components: [],
|
|
827
|
+
});
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
const retried = await runtime.retryLastUserPrompt();
|
|
831
|
+
deleteModelContext(contextHash);
|
|
832
|
+
await interaction.editReply({
|
|
833
|
+
content: retried
|
|
834
|
+
? `Restarting with new model...`
|
|
835
|
+
: `Model changed. Send a new message to continue.`,
|
|
836
|
+
components: [],
|
|
837
|
+
});
|
|
838
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// /unset-model-override command - Remove model overrides and use default instead.
|
|
2
|
-
import { ChatInputCommandInteraction, ChannelType, MessageFlags, } from 'discord.js';
|
|
2
|
+
import { ChatInputCommandInteraction, ButtonInteraction, ButtonBuilder, ButtonStyle, ActionRowBuilder, ChannelType, MessageFlags, } from 'discord.js';
|
|
3
3
|
import { getChannelModel, getSessionModel, getThreadSession, clearSessionModel, } from '../database.js';
|
|
4
4
|
import { getPrisma } from '../db.js';
|
|
5
5
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
@@ -121,19 +121,58 @@ export async function handleUnsetModelCommand({ interaction, appId, }) {
|
|
|
121
121
|
? 'none'
|
|
122
122
|
: `\`${newModelInfo.model}\` (${formatModelSource(newModelInfo.type, 'agentName' in newModelInfo ? newModelInfo.agentName : undefined)})`;
|
|
123
123
|
}
|
|
124
|
-
|
|
125
|
-
|
|
124
|
+
const clearedTypeText = clearedType === 'session' ? 'Session' : 'Channel';
|
|
125
|
+
// Show a button so the user can choose to resend the last message (session scope only).
|
|
126
|
+
// Do not auto-retry — the user may want to add context in a new message instead.
|
|
126
127
|
if (isThread && clearedType === 'session' && sessionId) {
|
|
127
128
|
const runtime = getRuntime(channel.id);
|
|
128
129
|
if (runtime) {
|
|
129
|
-
|
|
130
|
+
const retryButton = new ButtonBuilder()
|
|
131
|
+
.setCustomId(`unset_model_retry:${channel.id}`)
|
|
132
|
+
.setLabel('Resend last message')
|
|
133
|
+
.setStyle(ButtonStyle.Secondary);
|
|
134
|
+
const actionRow = new ActionRowBuilder().addComponents(retryButton);
|
|
135
|
+
await interaction.editReply({
|
|
136
|
+
content: `${clearedTypeText} model override removed.\n**Was:** \`${clearedModel}\`\n**Now using:** ${newModelText}`,
|
|
137
|
+
components: [actionRow],
|
|
138
|
+
});
|
|
139
|
+
return;
|
|
130
140
|
}
|
|
131
141
|
}
|
|
132
|
-
const clearedTypeText = clearedType === 'session' ? 'Session' : 'Channel';
|
|
133
|
-
const retriedText = retried
|
|
134
|
-
? '\n_Restarting current request with new model..._'
|
|
135
|
-
: '';
|
|
136
142
|
await interaction.editReply({
|
|
137
|
-
content: `${clearedTypeText} model override removed.\n**Was:** \`${clearedModel}\`\n**Now using:** ${newModelText}
|
|
143
|
+
content: `${clearedTypeText} model override removed.\n**Was:** \`${clearedModel}\`\n**Now using:** ${newModelText}`,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Handle the "Resend last message" button after clearing a session model override.
|
|
148
|
+
*/
|
|
149
|
+
export async function handleUnsetModelRetryButton(interaction) {
|
|
150
|
+
const customId = interaction.customId;
|
|
151
|
+
if (!customId.startsWith('unset_model_retry:')) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
await interaction.deferUpdate();
|
|
155
|
+
const threadId = customId.replace('unset_model_retry:', '');
|
|
156
|
+
if (!threadId) {
|
|
157
|
+
await interaction.editReply({
|
|
158
|
+
content: 'Could not find the session. Please resend your message manually.',
|
|
159
|
+
components: [],
|
|
160
|
+
});
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const runtime = getRuntime(threadId);
|
|
164
|
+
if (!runtime) {
|
|
165
|
+
await interaction.editReply({
|
|
166
|
+
content: 'No active session found. Please resend your message manually.',
|
|
167
|
+
components: [],
|
|
168
|
+
});
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const retried = await runtime.retryLastUserPrompt();
|
|
172
|
+
await interaction.editReply({
|
|
173
|
+
content: retried
|
|
174
|
+
? 'Restarting with new model...'
|
|
175
|
+
: 'Model changed. Send a new message to continue.',
|
|
176
|
+
components: [],
|
|
138
177
|
});
|
|
139
178
|
}
|
package/dist/discord-bot.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
|
@@ -21,8 +21,8 @@ import { handleDiffCommand } from './commands/diff.js';
|
|
|
21
21
|
import { handleForkCommand, handleForkSelectMenu, } from './commands/fork.js';
|
|
22
22
|
import { handleForkSubagentCommand, handleForkSubagentSelectMenu, } from './commands/fork-subagent.js';
|
|
23
23
|
import { handleBtwCommand } from './commands/btw.js';
|
|
24
|
-
import { handleModelCommand, handleProviderSelectMenu, handleModelSelectMenu, handleModelScopeSelectMenu, } from './commands/model.js';
|
|
25
|
-
import { handleUnsetModelCommand } from './commands/unset-model.js';
|
|
24
|
+
import { handleModelCommand, handleProviderSelectMenu, handleModelSelectMenu, handleModelScopeSelectMenu, handleModelRetryButton, } from './commands/model.js';
|
|
25
|
+
import { handleUnsetModelCommand, handleUnsetModelRetryButton, } from './commands/unset-model.js';
|
|
26
26
|
import { handleLoginCommand, handleLoginSelect, handleLoginTextButton, handleLoginTextModalSubmit, handleLoginApiKeyButton, handleOAuthCodeButton, handleOAuthCodeModalSubmit, handleApiKeyModalSubmit, } from './commands/login.js';
|
|
27
27
|
import { handleTranscriptionApiKeyButton, handleTranscriptionApiKeyCommand, handleTranscriptionApiKeyModalSubmit, } from './commands/gemini-apikey.js';
|
|
28
28
|
import { handleAgentCommand, handleAgentSelectMenu, handleQuickAgentCommand, } from './commands/agent.js';
|
|
@@ -44,7 +44,7 @@ import { handleMcpCommand, handleMcpSelectMenu } from './commands/mcp.js';
|
|
|
44
44
|
import { handleScreenshareCommand, handleScreenshareStopCommand, } from './commands/screenshare.js';
|
|
45
45
|
import { handleVscodeCommand } from './commands/vscode.js';
|
|
46
46
|
import { handleModelVariantSelectMenu } from './commands/model.js';
|
|
47
|
-
import { handleModelVariantCommand, handleVariantQuickSelectMenu, handleVariantScopeSelectMenu, } from './commands/model-variant.js';
|
|
47
|
+
import { handleModelVariantCommand, handleVariantQuickSelectMenu, handleVariantScopeSelectMenu, handleVariantRetryButton, } from './commands/model-variant.js';
|
|
48
48
|
import { hasOttoBotPermission } from './discord-utils.js';
|
|
49
49
|
import { createLogger, LogPrefix } from './logger.js';
|
|
50
50
|
import { notifyError } from './sentry.js';
|
|
@@ -302,6 +302,18 @@ export function registerInteractionHandler({ discordClient, appId, }) {
|
|
|
302
302
|
await handleHtmlActionButton(interaction);
|
|
303
303
|
return;
|
|
304
304
|
}
|
|
305
|
+
if (customId.startsWith('model_retry:')) {
|
|
306
|
+
await handleModelRetryButton(interaction);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (customId.startsWith('variant_retry:')) {
|
|
310
|
+
await handleVariantRetryButton(interaction);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (customId.startsWith('unset_model_retry:')) {
|
|
314
|
+
await handleUnsetModelRetryButton(interaction);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
305
317
|
return;
|
|
306
318
|
}
|
|
307
319
|
if (interaction.isStringSelectMenu()) {
|
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.
|
|
10
|
+
"version": "0.7.17",
|
|
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', () => {
|
package/src/commands/login.ts
CHANGED
|
@@ -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({
|
|
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({
|
|
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
|
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
import {
|
|
10
10
|
ChatInputCommandInteraction,
|
|
11
11
|
StringSelectMenuInteraction,
|
|
12
|
+
ButtonInteraction,
|
|
12
13
|
StringSelectMenuBuilder,
|
|
14
|
+
ButtonBuilder,
|
|
15
|
+
ButtonStyle,
|
|
13
16
|
ActionRowBuilder,
|
|
14
17
|
ChannelType,
|
|
15
18
|
type ThreadChannel,
|
|
@@ -34,6 +37,7 @@ import {
|
|
|
34
37
|
import { getRuntime } from '../session-handler/thread-session-runtime.js'
|
|
35
38
|
import { getThinkingValuesForModel } from '../thinking-utils.js'
|
|
36
39
|
import { createLogger, LogPrefix } from '../logger.js'
|
|
40
|
+
import { formatOpenCodeResponseError } from '../errors.js'
|
|
37
41
|
|
|
38
42
|
const logger = createLogger(LogPrefix.MODEL)
|
|
39
43
|
|
|
@@ -184,7 +188,9 @@ export async function handleModelVariantCommand({
|
|
|
184
188
|
}
|
|
185
189
|
|
|
186
190
|
if (!providersResponse.data) {
|
|
187
|
-
await interaction.editReply({
|
|
191
|
+
await interaction.editReply({
|
|
192
|
+
content: `Failed to fetch providers: ${formatOpenCodeResponseError(providersResponse.error)}`,
|
|
193
|
+
})
|
|
188
194
|
return
|
|
189
195
|
}
|
|
190
196
|
|
|
@@ -424,22 +430,22 @@ async function applyVariant({
|
|
|
424
430
|
`Set variant ${variant ?? 'none'} for session ${context.sessionId} (model ${modelId})`,
|
|
425
431
|
)
|
|
426
432
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
433
|
+
// Show a button so the user can choose to resend the last message.
|
|
434
|
+
// Do not auto-retry — the user may want to add context in a new message instead.
|
|
435
|
+
// Context is kept alive (TTL handles cleanup) so the button can access it.
|
|
436
|
+
const retryButton = new ButtonBuilder()
|
|
437
|
+
.setCustomId(`variant_retry:${contextHash}`)
|
|
438
|
+
.setLabel('Resend last message')
|
|
439
|
+
.setStyle(ButtonStyle.Secondary)
|
|
440
|
+
|
|
441
|
+
const actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents(retryButton)
|
|
434
442
|
|
|
435
|
-
const retryNote = retried
|
|
436
|
-
? '\n_Restarting current request with new variant..._'
|
|
437
|
-
: ''
|
|
438
443
|
await interaction.editReply({
|
|
439
|
-
content: `Variant set for this session:\n**${context.providerName}** / **${context.modelName}**${variantSuffix}\n\`${modelId}\`${
|
|
444
|
+
content: `Variant set for this session:\n**${context.providerName}** / **${context.modelName}**${variantSuffix}\n\`${modelId}\`${agentTip}`,
|
|
440
445
|
flags: MessageFlags.SuppressEmbeds,
|
|
441
|
-
components: [],
|
|
446
|
+
components: [actionRow],
|
|
442
447
|
})
|
|
448
|
+
return
|
|
443
449
|
} else if (scope === 'global') {
|
|
444
450
|
await setGlobalModel({ appId: context.appId, modelId, variant })
|
|
445
451
|
await setChannelModel({
|
|
@@ -474,7 +480,11 @@ async function applyVariant({
|
|
|
474
480
|
})
|
|
475
481
|
}
|
|
476
482
|
|
|
477
|
-
|
|
483
|
+
// Clean up the context from memory, except for session scope where the
|
|
484
|
+
// retry button still needs it. TTL handles cleanup in that case.
|
|
485
|
+
if (scope !== 'session') {
|
|
486
|
+
pendingVariantContexts.delete(contextHash)
|
|
487
|
+
}
|
|
478
488
|
} catch (error) {
|
|
479
489
|
logger.error('Error applying variant:', error)
|
|
480
490
|
await interaction.editReply({
|
|
@@ -483,3 +493,51 @@ async function applyVariant({
|
|
|
483
493
|
})
|
|
484
494
|
}
|
|
485
495
|
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Handle the "Resend last message" button shown after a session-scope variant change.
|
|
499
|
+
* Aborts any active run and resends the last user prompt with the new variant.
|
|
500
|
+
*/
|
|
501
|
+
export async function handleVariantRetryButton(
|
|
502
|
+
interaction: ButtonInteraction,
|
|
503
|
+
): Promise<void> {
|
|
504
|
+
const customId = interaction.customId
|
|
505
|
+
if (!customId.startsWith('variant_retry:')) {
|
|
506
|
+
return
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
await interaction.deferUpdate()
|
|
510
|
+
|
|
511
|
+
const contextHash = customId.replace('variant_retry:', '')
|
|
512
|
+
const context = pendingVariantContexts.get(contextHash)
|
|
513
|
+
|
|
514
|
+
// Context may have expired (TTL) — still allow the action via runtime alone.
|
|
515
|
+
const threadId = context?.thread?.id
|
|
516
|
+
|
|
517
|
+
if (!threadId) {
|
|
518
|
+
await interaction.editReply({
|
|
519
|
+
content: 'Could not find the session thread. Please resend your message manually.',
|
|
520
|
+
components: [],
|
|
521
|
+
})
|
|
522
|
+
return
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const runtime = getRuntime(threadId)
|
|
526
|
+
if (!runtime) {
|
|
527
|
+
await interaction.editReply({
|
|
528
|
+
content: 'No active session found. Please resend your message manually.',
|
|
529
|
+
components: [],
|
|
530
|
+
})
|
|
531
|
+
return
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const retried = await runtime.retryLastUserPrompt()
|
|
535
|
+
pendingVariantContexts.delete(contextHash)
|
|
536
|
+
|
|
537
|
+
await interaction.editReply({
|
|
538
|
+
content: retried
|
|
539
|
+
? `Restarting with new variant...`
|
|
540
|
+
: `Variant changed. Send a new message to continue.`,
|
|
541
|
+
components: [],
|
|
542
|
+
})
|
|
543
|
+
}
|
package/src/commands/model.ts
CHANGED
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
import {
|
|
4
4
|
ChatInputCommandInteraction,
|
|
5
5
|
StringSelectMenuInteraction,
|
|
6
|
+
ButtonInteraction,
|
|
6
7
|
StringSelectMenuBuilder,
|
|
8
|
+
ButtonBuilder,
|
|
9
|
+
ButtonStyle,
|
|
7
10
|
ActionRowBuilder,
|
|
8
11
|
ChannelType,
|
|
9
12
|
type ThreadChannel,
|
|
@@ -30,6 +33,7 @@ import { getDefaultModel } from '../session-handler/model-utils.js'
|
|
|
30
33
|
import { getRuntime } from '../session-handler/thread-session-runtime.js'
|
|
31
34
|
import { getThinkingValuesForModel } from '../thinking-utils.js'
|
|
32
35
|
import { createLogger, LogPrefix } from '../logger.js'
|
|
36
|
+
import { formatOpenCodeResponseError } from '../errors.js'
|
|
33
37
|
import * as errore from 'errore'
|
|
34
38
|
import { buildPaginatedOptions, parsePaginationValue } from './paginated-select.js'
|
|
35
39
|
|
|
@@ -447,7 +451,7 @@ export async function handleModelCommand({
|
|
|
447
451
|
|
|
448
452
|
if (!providersResponse.data) {
|
|
449
453
|
await interaction.editReply({
|
|
450
|
-
content:
|
|
454
|
+
content: `Failed to fetch providers: ${formatOpenCodeResponseError(providersResponse.error)}`,
|
|
451
455
|
})
|
|
452
456
|
return
|
|
453
457
|
}
|
|
@@ -596,7 +600,10 @@ export async function handleProviderSelectMenu(
|
|
|
596
600
|
}
|
|
597
601
|
const providersResponse = await getClient().provider.list({ directory: context.dir })
|
|
598
602
|
if (!providersResponse.data) {
|
|
599
|
-
await interaction.editReply({
|
|
603
|
+
await interaction.editReply({
|
|
604
|
+
content: `Failed to fetch providers: ${formatOpenCodeResponseError(providersResponse.error)}`,
|
|
605
|
+
components: [],
|
|
606
|
+
})
|
|
600
607
|
return
|
|
601
608
|
}
|
|
602
609
|
const { all: allProviders, connected } = providersResponse.data
|
|
@@ -640,7 +647,7 @@ export async function handleProviderSelectMenu(
|
|
|
640
647
|
|
|
641
648
|
if (!providersResponse.data) {
|
|
642
649
|
await interaction.editReply({
|
|
643
|
-
content:
|
|
650
|
+
content: `Failed to fetch providers: ${formatOpenCodeResponseError(providersResponse.error)}`,
|
|
644
651
|
components: [],
|
|
645
652
|
})
|
|
646
653
|
return
|
|
@@ -1016,22 +1023,22 @@ export async function handleModelScopeSelectMenu(
|
|
|
1016
1023
|
`Set model ${modelId}${variantSuffix} for session ${context.sessionId}`,
|
|
1017
1024
|
)
|
|
1018
1025
|
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
+
// Show a button so the user can choose to resend the last message.
|
|
1027
|
+
// Do not auto-retry — the user may want to add context in a new message instead.
|
|
1028
|
+
// Context is kept alive (TTL handles cleanup) so the button can access it.
|
|
1029
|
+
const retryButton = new ButtonBuilder()
|
|
1030
|
+
.setCustomId(`model_retry:${contextHash}`)
|
|
1031
|
+
.setLabel('Resend last message')
|
|
1032
|
+
.setStyle(ButtonStyle.Secondary)
|
|
1033
|
+
|
|
1034
|
+
const actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents(retryButton)
|
|
1026
1035
|
|
|
1027
|
-
const retryNote = retried
|
|
1028
|
-
? '\n_Restarting current request with new model..._'
|
|
1029
|
-
: ''
|
|
1030
1036
|
await interaction.editReply({
|
|
1031
|
-
content: `Model set for this session:\n**${context.providerName}** / **${modelDisplay}**${variantSuffix}\n\`${modelId}\`${
|
|
1037
|
+
content: `Model set for this session:\n**${context.providerName}** / **${modelDisplay}**${variantSuffix}\n\`${modelId}\`${agentTip}`,
|
|
1032
1038
|
flags: MessageFlags.SuppressEmbeds,
|
|
1033
|
-
components: [],
|
|
1039
|
+
components: [actionRow],
|
|
1034
1040
|
})
|
|
1041
|
+
return
|
|
1035
1042
|
} else if (selectedScope === 'global') {
|
|
1036
1043
|
if (!context.appId) {
|
|
1037
1044
|
deleteModelContext(contextHash)
|
|
@@ -1066,8 +1073,11 @@ export async function handleModelScopeSelectMenu(
|
|
|
1066
1073
|
})
|
|
1067
1074
|
}
|
|
1068
1075
|
|
|
1069
|
-
// Clean up the context from memory
|
|
1070
|
-
|
|
1076
|
+
// Clean up the context from memory, except for session scope where the
|
|
1077
|
+
// retry button still needs it. TTL handles cleanup in that case.
|
|
1078
|
+
if (selectedScope !== 'session') {
|
|
1079
|
+
deleteModelContext(contextHash)
|
|
1080
|
+
}
|
|
1071
1081
|
} catch (error) {
|
|
1072
1082
|
modelLogger.error('Error saving model preference:', error)
|
|
1073
1083
|
await interaction.editReply({
|
|
@@ -1076,3 +1086,51 @@ export async function handleModelScopeSelectMenu(
|
|
|
1076
1086
|
})
|
|
1077
1087
|
}
|
|
1078
1088
|
}
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* Handle the "Resend last message" button shown after a session-scope model change.
|
|
1092
|
+
* Aborts any active run and resends the last user prompt with the new model.
|
|
1093
|
+
*/
|
|
1094
|
+
export async function handleModelRetryButton(
|
|
1095
|
+
interaction: ButtonInteraction,
|
|
1096
|
+
): Promise<void> {
|
|
1097
|
+
const customId = interaction.customId
|
|
1098
|
+
if (!customId.startsWith('model_retry:')) {
|
|
1099
|
+
return
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
await interaction.deferUpdate()
|
|
1103
|
+
|
|
1104
|
+
const contextHash = customId.replace('model_retry:', '')
|
|
1105
|
+
const context = pendingModelContexts.get(contextHash)
|
|
1106
|
+
|
|
1107
|
+
// Context may have expired (10 min TTL) — still allow the action via runtime alone.
|
|
1108
|
+
const threadId = context?.thread?.id
|
|
1109
|
+
|
|
1110
|
+
if (!threadId) {
|
|
1111
|
+
await interaction.editReply({
|
|
1112
|
+
content: 'Could not find the session thread. Please resend your message manually.',
|
|
1113
|
+
components: [],
|
|
1114
|
+
})
|
|
1115
|
+
return
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
const runtime = getRuntime(threadId)
|
|
1119
|
+
if (!runtime) {
|
|
1120
|
+
await interaction.editReply({
|
|
1121
|
+
content: 'No active session found. Please resend your message manually.',
|
|
1122
|
+
components: [],
|
|
1123
|
+
})
|
|
1124
|
+
return
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
const retried = await runtime.retryLastUserPrompt()
|
|
1128
|
+
deleteModelContext(contextHash)
|
|
1129
|
+
|
|
1130
|
+
await interaction.editReply({
|
|
1131
|
+
content: retried
|
|
1132
|
+
? `Restarting with new model...`
|
|
1133
|
+
: `Model changed. Send a new message to continue.`,
|
|
1134
|
+
components: [],
|
|
1135
|
+
})
|
|
1136
|
+
}
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
ChatInputCommandInteraction,
|
|
5
|
+
ButtonInteraction,
|
|
6
|
+
ButtonBuilder,
|
|
7
|
+
ButtonStyle,
|
|
8
|
+
ActionRowBuilder,
|
|
5
9
|
ChannelType,
|
|
6
10
|
type ThreadChannel,
|
|
7
11
|
type TextChannel,
|
|
@@ -154,21 +158,69 @@ export async function handleUnsetModelCommand({
|
|
|
154
158
|
: `\`${newModelInfo.model}\` (${formatModelSource(newModelInfo.type, 'agentName' in newModelInfo ? newModelInfo.agentName : undefined)})`
|
|
155
159
|
}
|
|
156
160
|
|
|
157
|
-
|
|
158
|
-
|
|
161
|
+
const clearedTypeText = clearedType === 'session' ? 'Session' : 'Channel'
|
|
162
|
+
// Show a button so the user can choose to resend the last message (session scope only).
|
|
163
|
+
// Do not auto-retry — the user may want to add context in a new message instead.
|
|
159
164
|
if (isThread && clearedType === 'session' && sessionId) {
|
|
160
165
|
const runtime = getRuntime(channel.id)
|
|
161
166
|
if (runtime) {
|
|
162
|
-
|
|
167
|
+
const retryButton = new ButtonBuilder()
|
|
168
|
+
.setCustomId(`unset_model_retry:${channel.id}`)
|
|
169
|
+
.setLabel('Resend last message')
|
|
170
|
+
.setStyle(ButtonStyle.Secondary)
|
|
171
|
+
|
|
172
|
+
const actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents(retryButton)
|
|
173
|
+
|
|
174
|
+
await interaction.editReply({
|
|
175
|
+
content: `${clearedTypeText} model override removed.\n**Was:** \`${clearedModel}\`\n**Now using:** ${newModelText}`,
|
|
176
|
+
components: [actionRow],
|
|
177
|
+
})
|
|
178
|
+
return
|
|
163
179
|
}
|
|
164
180
|
}
|
|
165
181
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
182
|
+
await interaction.editReply({
|
|
183
|
+
content: `${clearedTypeText} model override removed.\n**Was:** \`${clearedModel}\`\n**Now using:** ${newModelText}`,
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Handle the "Resend last message" button after clearing a session model override.
|
|
189
|
+
*/
|
|
190
|
+
export async function handleUnsetModelRetryButton(
|
|
191
|
+
interaction: ButtonInteraction,
|
|
192
|
+
): Promise<void> {
|
|
193
|
+
const customId = interaction.customId
|
|
194
|
+
if (!customId.startsWith('unset_model_retry:')) {
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
await interaction.deferUpdate()
|
|
199
|
+
|
|
200
|
+
const threadId = customId.replace('unset_model_retry:', '')
|
|
201
|
+
if (!threadId) {
|
|
202
|
+
await interaction.editReply({
|
|
203
|
+
content: 'Could not find the session. Please resend your message manually.',
|
|
204
|
+
components: [],
|
|
205
|
+
})
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const runtime = getRuntime(threadId)
|
|
210
|
+
if (!runtime) {
|
|
211
|
+
await interaction.editReply({
|
|
212
|
+
content: 'No active session found. Please resend your message manually.',
|
|
213
|
+
components: [],
|
|
214
|
+
})
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const retried = await runtime.retryLastUserPrompt()
|
|
170
219
|
|
|
171
220
|
await interaction.editReply({
|
|
172
|
-
content:
|
|
221
|
+
content: retried
|
|
222
|
+
? 'Restarting with new model...'
|
|
223
|
+
: 'Model changed. Send a new message to continue.',
|
|
224
|
+
components: [],
|
|
173
225
|
})
|
|
174
226
|
}
|
package/src/discord-bot.ts
CHANGED
|
@@ -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
|
-
|
|
137
|
-
|
|
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
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -57,8 +57,12 @@ import {
|
|
|
57
57
|
handleProviderSelectMenu,
|
|
58
58
|
handleModelSelectMenu,
|
|
59
59
|
handleModelScopeSelectMenu,
|
|
60
|
+
handleModelRetryButton,
|
|
60
61
|
} from './commands/model.js'
|
|
61
|
-
import {
|
|
62
|
+
import {
|
|
63
|
+
handleUnsetModelCommand,
|
|
64
|
+
handleUnsetModelRetryButton,
|
|
65
|
+
} from './commands/unset-model.js'
|
|
62
66
|
import {
|
|
63
67
|
handleLoginCommand,
|
|
64
68
|
handleLoginSelect,
|
|
@@ -116,6 +120,7 @@ import {
|
|
|
116
120
|
handleModelVariantCommand,
|
|
117
121
|
handleVariantQuickSelectMenu,
|
|
118
122
|
handleVariantScopeSelectMenu,
|
|
123
|
+
handleVariantRetryButton,
|
|
119
124
|
} from './commands/model-variant.js'
|
|
120
125
|
import { hasOttoBotPermission } from './discord-utils.js'
|
|
121
126
|
import { createLogger, LogPrefix } from './logger.js'
|
|
@@ -463,6 +468,21 @@ export function registerInteractionHandler({
|
|
|
463
468
|
return
|
|
464
469
|
}
|
|
465
470
|
|
|
471
|
+
if (customId.startsWith('model_retry:')) {
|
|
472
|
+
await handleModelRetryButton(interaction)
|
|
473
|
+
return
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (customId.startsWith('variant_retry:')) {
|
|
477
|
+
await handleVariantRetryButton(interaction)
|
|
478
|
+
return
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (customId.startsWith('unset_model_retry:')) {
|
|
482
|
+
await handleUnsetModelRetryButton(interaction)
|
|
483
|
+
return
|
|
484
|
+
}
|
|
485
|
+
|
|
466
486
|
return
|
|
467
487
|
}
|
|
468
488
|
|
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
|
})
|