@otto-assistant/bridge 0.4.102 → 0.4.103

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.
Files changed (70) hide show
  1. package/dist/agent-model.e2e.test.js +1 -0
  2. package/dist/anthropic-auth-plugin.js +22 -1
  3. package/dist/anthropic-auth-state.js +31 -0
  4. package/dist/btw-prefix-detection.js +17 -0
  5. package/dist/btw-prefix-detection.test.js +63 -0
  6. package/dist/cli.js +101 -15
  7. package/dist/commands/agent.js +21 -2
  8. package/dist/commands/ask-question.js +50 -4
  9. package/dist/commands/ask-question.test.js +92 -0
  10. package/dist/commands/btw.js +71 -66
  11. package/dist/commands/new-worktree.js +92 -35
  12. package/dist/commands/queue.js +17 -0
  13. package/dist/commands/worktrees.js +196 -139
  14. package/dist/context-awareness-plugin.js +16 -8
  15. package/dist/context-awareness-plugin.test.js +4 -2
  16. package/dist/discord-bot.js +35 -2
  17. package/dist/discord-command-registration.js +9 -2
  18. package/dist/memory-overview-plugin.js +3 -1
  19. package/dist/opencode.js +9 -0
  20. package/dist/queue-question-select-drain.e2e.test.js +135 -10
  21. package/dist/session-handler/thread-runtime-state.js +27 -0
  22. package/dist/session-handler/thread-session-runtime.js +58 -28
  23. package/dist/session-title-rename.test.js +12 -0
  24. package/dist/skill-filter.js +31 -0
  25. package/dist/skill-filter.test.js +65 -0
  26. package/dist/store.js +2 -0
  27. package/dist/system-message.js +12 -3
  28. package/dist/system-message.test.js +10 -6
  29. package/dist/thread-message-queue.e2e.test.js +109 -0
  30. package/dist/worktree-lifecycle.e2e.test.js +4 -1
  31. package/dist/worktrees.js +106 -12
  32. package/dist/worktrees.test.js +232 -6
  33. package/package.json +2 -2
  34. package/skills/goke/SKILL.md +13 -619
  35. package/skills/new-skill/SKILL.md +34 -10
  36. package/skills/npm-package/SKILL.md +336 -2
  37. package/skills/profano/SKILL.md +24 -0
  38. package/skills/zele/SKILL.md +50 -21
  39. package/src/agent-model.e2e.test.ts +1 -0
  40. package/src/anthropic-auth-plugin.ts +24 -4
  41. package/src/anthropic-auth-state.ts +45 -0
  42. package/src/btw-prefix-detection.test.ts +73 -0
  43. package/src/btw-prefix-detection.ts +23 -0
  44. package/src/cli.ts +138 -46
  45. package/src/commands/agent.ts +24 -2
  46. package/src/commands/ask-question.test.ts +111 -0
  47. package/src/commands/ask-question.ts +69 -4
  48. package/src/commands/btw.ts +105 -85
  49. package/src/commands/new-worktree.ts +107 -40
  50. package/src/commands/queue.ts +22 -0
  51. package/src/commands/worktrees.ts +246 -154
  52. package/src/context-awareness-plugin.test.ts +4 -2
  53. package/src/context-awareness-plugin.ts +16 -8
  54. package/src/discord-bot.ts +40 -2
  55. package/src/discord-command-registration.ts +12 -2
  56. package/src/memory-overview-plugin.ts +3 -1
  57. package/src/opencode.ts +9 -0
  58. package/src/queue-question-select-drain.e2e.test.ts +174 -10
  59. package/src/session-handler/thread-runtime-state.ts +36 -1
  60. package/src/session-handler/thread-session-runtime.ts +72 -32
  61. package/src/session-title-rename.test.ts +18 -0
  62. package/src/skill-filter.test.ts +83 -0
  63. package/src/skill-filter.ts +42 -0
  64. package/src/store.ts +17 -0
  65. package/src/system-message.test.ts +10 -6
  66. package/src/system-message.ts +12 -3
  67. package/src/thread-message-queue.e2e.test.ts +126 -0
  68. package/src/worktree-lifecycle.e2e.test.ts +6 -1
  69. package/src/worktrees.test.ts +274 -9
  70. package/src/worktrees.ts +144 -23
@@ -742,6 +742,7 @@ describe('agent model resolution', () => {
742
742
  ⬥ ok
743
743
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
744
744
  Switched to **plan** agent for this session (was **test-agent**)
745
+ Model: *deterministic-provider/plan-model-v2*
745
746
  The agent will change on the next message.
746
747
  --- from: user (agent-model-tester)
747
748
  Reply with exactly: after-switch-msg
@@ -460,6 +460,20 @@ function buildAuthorizeHandler(mode) {
460
460
  function toClaudeCodeToolName(name) {
461
461
  return OPENCODE_TO_CLAUDE_CODE_TOOL_NAME[name.toLowerCase()] ?? name;
462
462
  }
463
+ /**
464
+ * Strips the OpenCode identity block (from "You are OpenCode…" up to the
465
+ * Anthropic prompt marker "Skills provide specialized instructions") and
466
+ * re-injects essential environment context as a small XML tag.
467
+ *
468
+ * The original OpenCode prompt between those markers contains the current
469
+ * working directory and other runtime context. Stripping it wholesale loses
470
+ * that info, so we add back what the model needs (cwd) in a compact form.
471
+ *
472
+ * Original OpenCode Anthropic prompt structure (for reference):
473
+ * "You are OpenCode, the best coding agent on the planet."
474
+ * + environment block (cwd, OS, shell, date, etc.)
475
+ * + "Skills provide specialized instructions …"
476
+ */
463
477
  function sanitizeAnthropicSystemText(text, onError) {
464
478
  const startIdx = text.indexOf(OPENCODE_IDENTITY);
465
479
  if (startIdx === -1)
@@ -470,7 +484,14 @@ function sanitizeAnthropicSystemText(text, onError) {
470
484
  onError?.("sanitizeAnthropicSystemText: could not find Anthropic prompt marker after OpenCode identity");
471
485
  return text;
472
486
  }
473
- return (text.slice(0, startIdx) + text.slice(endIdx)).replaceAll("opencode", "openc0de");
487
+ // Re-inject the process working directory that was inside the stripped block.
488
+ const envContext = `\n<environment>\n<cwd>${process.cwd()}</cwd>\n</environment>\n`;
489
+ // Replace all case-insensitive whole-word occurrences of "opencode" with "openc0de"
490
+ const result = text.slice(0, startIdx) +
491
+ envContext +
492
+ text.slice(endIdx);
493
+ // Use a regex with global, case-insensitive, and word boundary flags
494
+ return result.replace(/\bopencode\b/gi, "openc0de");
474
495
  }
475
496
  function mapSystemTextPart(part, onError) {
476
497
  if (typeof part === "string") {
@@ -186,6 +186,37 @@ async function writeAnthropicAuthFile(auth) {
186
186
  }
187
187
  await writeJson(file, data);
188
188
  }
189
+ function isOAuthStored(value) {
190
+ if (!value || typeof value !== 'object') {
191
+ return false;
192
+ }
193
+ const record = value;
194
+ return (record.type === 'oauth' &&
195
+ typeof record.refresh === 'string' &&
196
+ typeof record.access === 'string' &&
197
+ typeof record.expires === 'number');
198
+ }
199
+ export async function getCurrentAnthropicAccount() {
200
+ const authJson = await readJson(authFilePath(), {});
201
+ const auth = authJson.anthropic;
202
+ if (!isOAuthStored(auth)) {
203
+ return null;
204
+ }
205
+ const store = await loadAccountStore();
206
+ const index = findCurrentAccountIndex(store, auth);
207
+ const account = store.accounts[index];
208
+ if (!account) {
209
+ return { auth };
210
+ }
211
+ if (account.refresh !== auth.refresh && account.access !== auth.access) {
212
+ return { auth };
213
+ }
214
+ return {
215
+ auth,
216
+ account,
217
+ index,
218
+ };
219
+ }
189
220
  export async function setAnthropicAuth(auth, client) {
190
221
  await writeAnthropicAuthFile(auth);
191
222
  await client.auth.set({ path: { id: 'anthropic' }, body: auth });
@@ -0,0 +1,17 @@
1
+ // Detects the raw `btw ` Discord message shortcut used to fork a side-question
2
+ // thread without invoking the /btw slash command UI.
3
+ export function extractBtwPrefix(content) {
4
+ if (!content) {
5
+ return null;
6
+ }
7
+ // Match "btw" followed by whitespace or punctuation (. , : ; ! ?) then the prompt
8
+ const match = content.match(/^\s*btw[.,;:!?\s]\s*([\s\S]+)$/i);
9
+ if (!match) {
10
+ return null;
11
+ }
12
+ const prompt = match[1]?.trim();
13
+ if (!prompt) {
14
+ return null;
15
+ }
16
+ return { prompt };
17
+ }
@@ -0,0 +1,63 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { extractBtwPrefix } from './btw-prefix-detection.js';
3
+ describe('extractBtwPrefix', () => {
4
+ test('matches lowercase prefix', () => {
5
+ expect(extractBtwPrefix('btw fix this')).toMatchInlineSnapshot(`
6
+ {
7
+ "prompt": "fix this",
8
+ }
9
+ `);
10
+ });
11
+ test('matches uppercase prefix', () => {
12
+ expect(extractBtwPrefix('BTW check this')).toMatchInlineSnapshot(`
13
+ {
14
+ "prompt": "check this",
15
+ }
16
+ `);
17
+ });
18
+ test('keeps multiline content', () => {
19
+ expect(extractBtwPrefix(' btw first line\nsecond line ')).toMatchInlineSnapshot(`
20
+ {
21
+ "prompt": "first line
22
+ second line",
23
+ }
24
+ `);
25
+ });
26
+ test('matches dot separator', () => {
27
+ expect(extractBtwPrefix('btw. fix this')).toMatchInlineSnapshot(`
28
+ {
29
+ "prompt": "fix this",
30
+ }
31
+ `);
32
+ });
33
+ test('matches comma separator', () => {
34
+ expect(extractBtwPrefix('btw, fix this')).toMatchInlineSnapshot(`
35
+ {
36
+ "prompt": "fix this",
37
+ }
38
+ `);
39
+ });
40
+ test('matches colon separator', () => {
41
+ expect(extractBtwPrefix('btw: fix this')).toMatchInlineSnapshot(`
42
+ {
43
+ "prompt": "fix this",
44
+ }
45
+ `);
46
+ });
47
+ test('matches punctuation without trailing space', () => {
48
+ expect(extractBtwPrefix('btw.fix this')).toMatchInlineSnapshot(`
49
+ {
50
+ "prompt": "fix this",
51
+ }
52
+ `);
53
+ });
54
+ test('does not match without separating whitespace', () => {
55
+ expect(extractBtwPrefix('btwfix this')).toMatchInlineSnapshot(`null`);
56
+ });
57
+ test('does not match mid-message', () => {
58
+ expect(extractBtwPrefix('hello btw fix this')).toMatchInlineSnapshot(`null`);
59
+ });
60
+ test('does not match empty payload', () => {
61
+ expect(extractBtwPrefix('btw ')).toMatchInlineSnapshot(`null`);
62
+ });
63
+ });
package/dist/cli.js CHANGED
@@ -10,7 +10,7 @@ import { getChannelsWithDescriptions, createDiscordClient, initDatabase, getChan
10
10
  import { getBotTokenWithMode, ensureServiceAuthToken, setBotToken, setBotMode, setChannelDirectory, findChannelsByDirectory, getThreadSession, getThreadIdBySessionId, getSessionEventSnapshot, getPrisma, createScheduledTask, listScheduledTasks, cancelScheduledTask, getScheduledTask, updateScheduledTask, getSessionStartSourcesBySessionIds, deleteChannelDirectoryById, } from './database.js';
11
11
  import { ShareMarkdown } from './markdown.js';
12
12
  import { parseSessionSearchPattern, findFirstSessionSearchHit, buildSessionSearchSnippet, getPartSearchTexts, } from './session-search.js';
13
- import { formatWorktreeName } from './commands/new-worktree.js';
13
+ import { formatWorktreeName, formatAutoWorktreeName } from './commands/new-worktree.js';
14
14
  import { WORKTREE_PREFIX } from './commands/merge-worktree.js';
15
15
  import { sendWelcomeMessage } from './onboarding-welcome.js';
16
16
  import { buildOpencodeEventLogLine } from './session-handler/opencode-session-event-log.js';
@@ -21,6 +21,7 @@ import { createDiscordRest, discordApiUrl, getDiscordRestApiUrl, getGatewayProxy
21
21
  import crypto from 'node:crypto';
22
22
  import path from 'node:path';
23
23
  import fs from 'node:fs';
24
+ import { fileURLToPath } from 'node:url';
24
25
  import * as errore from 'errore';
25
26
  import { createLogger, formatErrorWithStack, initLogFile, LogPrefix } from './logger.js';
26
27
  import { initSentry, notifyError } from './sentry.js';
@@ -32,7 +33,7 @@ import { backgroundUpgradeKimaki, upgrade, getCurrentVersion, } from './upgrade.
32
33
  import { startHranaServer } from './hrana-server.js';
33
34
  import { startIpcPolling, stopIpcPolling } from './ipc-polling.js';
34
35
  import { getPromptPreview, parseSendAtValue, parseScheduledTaskPayload, serializeScheduledTaskPayload, } from './task-schedule.js';
35
- import { accountLabel, accountsFilePath, loadAccountStore, removeAccount, } from './anthropic-auth-state.js';
36
+ import { accountLabel, accountsFilePath, authFilePath, getCurrentAnthropicAccount, loadAccountStore, removeAccount, } from './anthropic-auth-state.js';
36
37
  const cliLogger = createLogger(LogPrefix.CLI);
37
38
  // Gateway bot mode constants.
38
39
  // KIMAKI_GATEWAY_APP_ID is the Discord Application ID of the gateway bot.
@@ -1287,6 +1288,14 @@ cli
1287
1288
  .option('--no-sentry', 'Disable Sentry error reporting')
1288
1289
  .option('--gateway', 'Force gateway mode (use the gateway Kimaki bot instead of a self-hosted bot)')
1289
1290
  .option('--gateway-callback-url <url>', 'After gateway OAuth install, redirect to this URL instead of the default success page (appends ?guild_id=<id>)')
1291
+ .option('--enable-skill <name>', z
1292
+ .array(z.string())
1293
+ .optional()
1294
+ .describe('Whitelist a built-in skill by name. Only the listed skills are injected into the model (all others are hidden via an opencode permission.skill deny-all rule). Repeatable: pass --enable-skill multiple times. Mutually exclusive with --disable-skill. See https://github.com/remorses/kimaki/tree/main/cli/skills for available skills.'))
1295
+ .option('--disable-skill <name>', z
1296
+ .array(z.string())
1297
+ .optional()
1298
+ .describe('Blacklist a built-in skill by name. Listed skills are hidden from the model. Repeatable: pass --disable-skill multiple times. Mutually exclusive with --enable-skill. See https://github.com/remorses/kimaki/tree/main/cli/skills for available skills.'))
1290
1299
  .action(async (options) => {
1291
1300
  // Guard: only one kimaki bot process can run at a time (they share a lock
1292
1301
  // port). Running `kimaki` here would kill the already-running bot process
@@ -1321,13 +1330,54 @@ cli
1321
1330
  process.exit(EXIT_NO_RESTART);
1322
1331
  }
1323
1332
  }
1333
+ // --enable-skill and --disable-skill are mutually exclusive: the user
1334
+ // either whitelists a small allowlist or blacklists a few unwanted
1335
+ // skills, never both. Applied later in opencode.ts as permission.skill
1336
+ // rules via computeSkillPermission().
1337
+ const enabledSkills = options.enableSkill ?? [];
1338
+ const disabledSkills = options.disableSkill ?? [];
1339
+ if (enabledSkills.length > 0 && disabledSkills.length > 0) {
1340
+ cliLogger.error('Cannot use --enable-skill and --disable-skill at the same time. Use one or the other.');
1341
+ process.exit(EXIT_NO_RESTART);
1342
+ }
1343
+ // Soft-validate skill names against the bundled skills/ folder. Users
1344
+ // may rely on skills loaded from their own .opencode / .claude / .agents
1345
+ // dirs, so unknown names only emit a warning rather than hard-failing.
1346
+ if (enabledSkills.length > 0 || disabledSkills.length > 0) {
1347
+ const bundledSkillsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'skills');
1348
+ const availableBundledSkills = (() => {
1349
+ try {
1350
+ return fs
1351
+ .readdirSync(bundledSkillsDir, { withFileTypes: true })
1352
+ .filter((entry) => entry.isDirectory())
1353
+ .map((entry) => entry.name);
1354
+ }
1355
+ catch {
1356
+ return [];
1357
+ }
1358
+ })();
1359
+ const availableSet = new Set(availableBundledSkills);
1360
+ for (const name of [...enabledSkills, ...disabledSkills]) {
1361
+ if (!availableSet.has(name)) {
1362
+ cliLogger.warn(`Skill "${name}" is not a bundled kimaki skill. Rule will still apply (user-provided skills from .opencode/.claude/.agents dirs may match). Available bundled skills: ${availableBundledSkills.join(', ')}`);
1363
+ }
1364
+ }
1365
+ }
1324
1366
  store.setState({
1325
1367
  ...(options.verbosity && {
1326
1368
  defaultVerbosity: options.verbosity,
1327
1369
  }),
1328
1370
  ...(options.mentionMode && { defaultMentionMode: true }),
1329
1371
  ...(options.noCritique && { critiqueEnabled: false }),
1372
+ ...(enabledSkills.length > 0 && { enabledSkills }),
1373
+ ...(disabledSkills.length > 0 && { disabledSkills }),
1330
1374
  });
1375
+ if (enabledSkills.length > 0) {
1376
+ cliLogger.log(`Skill whitelist enabled: only [${enabledSkills.join(', ')}] will be injected`);
1377
+ }
1378
+ if (disabledSkills.length > 0) {
1379
+ cliLogger.log(`Skill blacklist enabled: [${disabledSkills.join(', ')}] will be hidden`);
1380
+ }
1331
1381
  if (options.verbosity) {
1332
1382
  cliLogger.log(`Default verbosity: ${options.verbosity}`);
1333
1383
  }
@@ -1637,7 +1687,12 @@ cli
1637
1687
  .option('--wait', 'Wait for session to complete, then print session text to stdout')
1638
1688
  .action(async (options) => {
1639
1689
  try {
1640
- let { channel: channelId, prompt, name, appId: optionAppId, notifyOnly, thread: threadId, session: sessionId, } = options;
1690
+ // `--name` / `--app-id` are optional-value flags: `undefined` when
1691
+ // omitted, `''` when passed bare, a real string when given a value.
1692
+ // `||` collapses `''` to `undefined` for downstream consumers.
1693
+ const optionAppId = options.appId || undefined;
1694
+ let { channel: channelId, prompt, notifyOnly, thread: threadId, session: sessionId, } = options;
1695
+ let name = options.name || undefined;
1641
1696
  const { project: projectPath } = options;
1642
1697
  const sendAt = options.sendAt;
1643
1698
  const existingThreadMode = Boolean(threadId || sessionId);
@@ -1981,10 +2036,12 @@ cli
1981
2036
  (cleanPrompt.length > 80
1982
2037
  ? cleanPrompt.slice(0, 77) + '...'
1983
2038
  : cleanPrompt);
2039
+ // Explicit string => use as-is via formatWorktreeName (no vowel strip).
2040
+ // Boolean true => derived from thread/prompt, compress via formatAutoWorktreeName.
1984
2041
  const worktreeName = options.worktree
1985
- ? formatWorktreeName(typeof options.worktree === 'string'
1986
- ? options.worktree
1987
- : baseThreadName)
2042
+ ? typeof options.worktree === 'string'
2043
+ ? formatWorktreeName(options.worktree)
2044
+ : formatAutoWorktreeName(baseThreadName)
1988
2045
  : undefined;
1989
2046
  const threadName = worktreeName
1990
2047
  ? `${WORKTREE_PREFIX}${baseThreadName}`
@@ -2238,6 +2295,33 @@ cli
2238
2295
  });
2239
2296
  process.exit(0);
2240
2297
  });
2298
+ cli
2299
+ .command('anthropic-accounts current', 'Show the current Anthropic OAuth account being used, if any')
2300
+ .action(async () => {
2301
+ const current = await getCurrentAnthropicAccount();
2302
+ console.log(`Store: ${accountsFilePath()}`);
2303
+ console.log(`Auth: ${authFilePath()}`);
2304
+ if (!current) {
2305
+ console.log('No active Anthropic OAuth account configured.');
2306
+ process.exit(0);
2307
+ }
2308
+ const lines = [];
2309
+ lines.push(`Current: ${accountLabel(current.account || current.auth, current.index)}`);
2310
+ if (current.account?.email) {
2311
+ lines.push(`Email: ${current.account.email}`);
2312
+ }
2313
+ else {
2314
+ lines.push('Email: unavailable');
2315
+ }
2316
+ if (current.account?.accountId) {
2317
+ lines.push(`Account ID: ${current.account.accountId}`);
2318
+ }
2319
+ if (!current.account) {
2320
+ lines.push('Rotation pool entry: not found');
2321
+ }
2322
+ console.log(lines.join('\n'));
2323
+ process.exit(0);
2324
+ });
2241
2325
  cli
2242
2326
  .command('anthropic-accounts remove <indexOrEmail>', 'Remove a stored Anthropic OAuth account from the rotation pool by index or email')
2243
2327
  .action(async (indexOrEmail) => {
@@ -2633,13 +2717,15 @@ cli
2633
2717
  process.exit(EXIT_NO_RESTART);
2634
2718
  }
2635
2719
  const guildId = String(options.guild);
2720
+ // Bare `--query` comes through as `''`; collapse it to undefined
2721
+ const query = options.query || undefined;
2636
2722
  await initDatabase();
2637
2723
  const { token: botToken } = await resolveBotCredentials();
2638
2724
  const rest = createDiscordRest(botToken);
2639
2725
  const members = await (async () => {
2640
- if (options.query) {
2726
+ if (query) {
2641
2727
  return (await rest.get(Routes.guildMembersSearch(guildId), {
2642
- query: new URLSearchParams({ query: options.query, limit: '20' }),
2728
+ query: new URLSearchParams({ query, limit: '20' }),
2643
2729
  }));
2644
2730
  }
2645
2731
  return (await rest.get(Routes.guildMembers(guildId), {
@@ -2647,8 +2733,8 @@ cli
2647
2733
  }));
2648
2734
  })();
2649
2735
  if (members.length === 0) {
2650
- const msg = options.query
2651
- ? `No users found matching "${options.query}"`
2736
+ const msg = query
2737
+ ? `No users found matching "${query}"`
2652
2738
  : 'No users found in guild';
2653
2739
  cliLogger.log(msg);
2654
2740
  process.exit(0);
@@ -2659,8 +2745,8 @@ cli
2659
2745
  return `- ${displayName} (ID: ${m.user.id}) - mention: <@${m.user.id}>`;
2660
2746
  })
2661
2747
  .join('\n');
2662
- const header = options.query
2663
- ? `Found ${members.length} users matching "${options.query}":`
2748
+ const header = query
2749
+ ? `Found ${members.length} users matching "${query}":`
2664
2750
  : `Found ${members.length} users:`;
2665
2751
  console.log(`${header}\n${userList}`);
2666
2752
  process.exit(0);
@@ -2693,10 +2779,10 @@ cli
2693
2779
  const { command } = parseCommandFromArgv(process.argv);
2694
2780
  await runTunnel({
2695
2781
  port,
2696
- tunnelId: options.tunnelId,
2697
- localHost: options.host,
2782
+ tunnelId: options.tunnelId || undefined,
2783
+ localHost: options.host || undefined,
2698
2784
  baseDomain: 'kimaki.dev',
2699
- serverUrl: options.server,
2785
+ serverUrl: options.server || undefined,
2700
2786
  command: command.length > 0 ? command : undefined,
2701
2787
  kill: options.kill,
2702
2788
  });
@@ -6,6 +6,7 @@ import { setChannelAgent, setSessionAgent, clearSessionModel, getThreadSession,
6
6
  import { initializeOpencodeForDirectory } from '../opencode.js';
7
7
  import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js';
8
8
  import { createLogger, LogPrefix } from '../logger.js';
9
+ import { getCurrentModelInfo } from './model.js';
9
10
  const agentLogger = createLogger(LogPrefix.AGENT);
10
11
  const AGENT_CONTEXT_TTL_MS = 10 * 60 * 1000;
11
12
  const pendingAgentContexts = new Map();
@@ -315,14 +316,32 @@ export async function handleQuickAgentCommand({ command, appId, }) {
315
316
  const previousText = previousAgentName
316
317
  ? ` (was **${previousAgentName}**)`
317
318
  : '';
319
+ // Resolve the model that will now be used for the new agent so we can
320
+ // show it in the reply. setAgentForContext already cleared any session
321
+ // model preference, so getCurrentModelInfo falls through to the agent's
322
+ // configured model (or channel/global/default).
323
+ const modelInfo = await (async () => {
324
+ const getClient = await initializeOpencodeForDirectory(context.dir);
325
+ if (getClient instanceof Error) {
326
+ return { type: 'none' };
327
+ }
328
+ return getCurrentModelInfo({
329
+ sessionId: context.sessionId,
330
+ channelId: context.channelId,
331
+ appId,
332
+ agentPreference: resolvedAgentName,
333
+ getClient,
334
+ });
335
+ })();
336
+ const modelText = modelInfo.type === 'none' ? '' : `\nModel: *${modelInfo.model}*`;
318
337
  if (context.isThread && context.sessionId) {
319
338
  await command.editReply({
320
- content: `Switched to **${resolvedAgentName}** agent for this session${previousText}\nThe agent will change on the next message.`,
339
+ content: `Switched to **${resolvedAgentName}** agent for this session${previousText}${modelText}\nThe agent will change on the next message.`,
321
340
  });
322
341
  }
323
342
  else {
324
343
  await command.editReply({
325
- content: `Switched to **${resolvedAgentName}** agent for this channel${previousText}\nAll new sessions will use this agent.`,
344
+ content: `Switched to **${resolvedAgentName}** agent for this channel${previousText}${modelText}\nAll new sessions will use this agent.`,
326
345
  });
327
346
  }
328
347
  }
@@ -11,6 +11,32 @@ const logger = createLogger(LogPrefix.ASK_QUESTION);
11
11
  // TTL prevents unbounded growth if user never answers a question.
12
12
  const QUESTION_CONTEXT_TTL_MS = 10 * 60 * 1000;
13
13
  export const pendingQuestionContexts = new Map();
14
+ export function findPendingQuestionContextForRequest({ threadId, requestId, }) {
15
+ for (const [contextHash, context] of pendingQuestionContexts) {
16
+ if (context.thread.id !== threadId) {
17
+ continue;
18
+ }
19
+ if (context.requestId !== requestId) {
20
+ continue;
21
+ }
22
+ return { contextHash, context };
23
+ }
24
+ return null;
25
+ }
26
+ export function deletePendingQuestionContextsForRequest({ threadId, requestId, }) {
27
+ const matchingContextHashes = [...pendingQuestionContexts.entries()]
28
+ .filter(([, context]) => {
29
+ return context.thread.id === threadId && context.requestId === requestId;
30
+ })
31
+ .map(([contextHash]) => {
32
+ return contextHash;
33
+ });
34
+ matchingContextHashes.map((contextHash) => {
35
+ pendingQuestionContexts.delete(contextHash);
36
+ return contextHash;
37
+ });
38
+ return matchingContextHashes.length;
39
+ }
14
40
  export function hasPendingQuestionForThread(threadId) {
15
41
  return [...pendingQuestionContexts.values()].some((ctx) => {
16
42
  return ctx.thread.id === threadId;
@@ -21,6 +47,14 @@ export function hasPendingQuestionForThread(threadId) {
21
47
  * Sends one message per question with the dropdown directly under the question text.
22
48
  */
23
49
  export async function showAskUserQuestionDropdowns({ thread, sessionId, directory, requestId, input, silent, }) {
50
+ const existingPending = findPendingQuestionContextForRequest({
51
+ threadId: thread.id,
52
+ requestId,
53
+ });
54
+ if (existingPending) {
55
+ logger.log(`Deduped question ${requestId} for thread ${thread.id} (existing context ${existingPending.contextHash})`);
56
+ return;
57
+ }
24
58
  const contextHash = crypto.randomBytes(8).toString('hex');
25
59
  const context = {
26
60
  sessionId,
@@ -46,7 +80,10 @@ export async function showAskUserQuestionDropdowns({ thread, sessionId, director
46
80
  // Without this, a user clicking during the abort() await would still
47
81
  // be accepted by handleAskQuestionSelectMenu, then abort() would
48
82
  // kill that valid run.
49
- pendingQuestionContexts.delete(contextHash);
83
+ deletePendingQuestionContextsForRequest({
84
+ threadId: ctx.thread.id,
85
+ requestId: ctx.requestId,
86
+ });
50
87
  // Abort the session so OpenCode isn't stuck waiting for a reply
51
88
  const client = getOpencodeClient(ctx.directory);
52
89
  if (client) {
@@ -150,7 +187,10 @@ export async function handleAskQuestionSelectMenu(interaction) {
150
187
  if (context.answeredCount >= context.totalQuestions) {
151
188
  // All questions answered - send result back to session
152
189
  await submitQuestionAnswers(context);
153
- pendingQuestionContexts.delete(contextHash);
190
+ deletePendingQuestionContextsForRequest({
191
+ threadId: context.thread.id,
192
+ requestId: context.requestId,
193
+ });
154
194
  }
155
195
  }
156
196
  /**
@@ -245,7 +285,10 @@ export async function cancelPendingQuestion(threadId, userMessage) {
245
285
  // the question without providing an answer (e.g. voice/attachment-only
246
286
  // messages where content needs transcription before it can be an answer).
247
287
  if (userMessage === undefined) {
248
- pendingQuestionContexts.delete(contextHash);
288
+ deletePendingQuestionContextsForRequest({
289
+ threadId: context.thread.id,
290
+ requestId: context.requestId,
291
+ });
249
292
  return 'no-pending';
250
293
  }
251
294
  try {
@@ -269,6 +312,9 @@ export async function cancelPendingQuestion(threadId, userMessage) {
269
312
  // Caller should not consume the user message since reply failed.
270
313
  return 'reply-failed';
271
314
  }
272
- pendingQuestionContexts.delete(contextHash);
315
+ deletePendingQuestionContextsForRequest({
316
+ threadId: context.thread.id,
317
+ requestId: context.requestId,
318
+ });
273
319
  return 'replied';
274
320
  }
@@ -0,0 +1,92 @@
1
+ // Tests AskUserQuestion request deduplication and cleanup helpers.
2
+ import { afterEach, describe, expect, test, vi } from 'vitest';
3
+ import { deletePendingQuestionContextsForRequest, pendingQuestionContexts, showAskUserQuestionDropdowns, } from './ask-question.js';
4
+ function createFakeThread() {
5
+ const send = vi.fn(async () => {
6
+ return { id: 'msg-1' };
7
+ });
8
+ return {
9
+ id: 'thread-1',
10
+ send,
11
+ };
12
+ }
13
+ afterEach(() => {
14
+ pendingQuestionContexts.clear();
15
+ vi.restoreAllMocks();
16
+ });
17
+ describe('ask-question', () => {
18
+ test('dedupes duplicate question requests for the same thread', async () => {
19
+ const thread = createFakeThread();
20
+ await showAskUserQuestionDropdowns({
21
+ thread,
22
+ sessionId: 'ses-1',
23
+ directory: '/project',
24
+ requestId: 'req-1',
25
+ input: {
26
+ questions: [{
27
+ question: 'Choose one',
28
+ header: 'Pick',
29
+ options: [
30
+ { label: 'Alpha', description: 'A' },
31
+ { label: 'Beta', description: 'B' },
32
+ ],
33
+ }],
34
+ },
35
+ });
36
+ await showAskUserQuestionDropdowns({
37
+ thread,
38
+ sessionId: 'ses-1',
39
+ directory: '/project',
40
+ requestId: 'req-1',
41
+ input: {
42
+ questions: [{
43
+ question: 'Choose one',
44
+ header: 'Pick',
45
+ options: [
46
+ { label: 'Alpha', description: 'A' },
47
+ { label: 'Beta', description: 'B' },
48
+ ],
49
+ }],
50
+ },
51
+ });
52
+ expect(thread.send).toHaveBeenCalledTimes(1);
53
+ expect(pendingQuestionContexts.size).toBe(1);
54
+ });
55
+ test('removes all duplicate contexts for one request', () => {
56
+ const thread = createFakeThread();
57
+ const baseContext = {
58
+ sessionId: 'ses-1',
59
+ directory: '/project',
60
+ thread,
61
+ requestId: 'req-1',
62
+ questions: [{
63
+ question: 'Choose one',
64
+ header: 'Pick',
65
+ options: [
66
+ { label: 'Alpha', description: 'A' },
67
+ { label: 'Beta', description: 'B' },
68
+ ],
69
+ }],
70
+ answers: {},
71
+ totalQuestions: 1,
72
+ answeredCount: 0,
73
+ contextHash: 'ctx-1',
74
+ };
75
+ pendingQuestionContexts.set('ctx-1', baseContext);
76
+ pendingQuestionContexts.set('ctx-2', {
77
+ ...baseContext,
78
+ contextHash: 'ctx-2',
79
+ });
80
+ pendingQuestionContexts.set('ctx-3', {
81
+ ...baseContext,
82
+ requestId: 'req-2',
83
+ contextHash: 'ctx-3',
84
+ });
85
+ const removed = deletePendingQuestionContextsForRequest({
86
+ threadId: thread.id,
87
+ requestId: 'req-1',
88
+ });
89
+ expect(removed).toBe(2);
90
+ expect([...pendingQuestionContexts.keys()]).toEqual(['ctx-3']);
91
+ });
92
+ });