@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
@@ -17,6 +17,12 @@ export type OAuthStored = {
17
17
  expires: number
18
18
  }
19
19
 
20
+ export type CurrentAnthropicAccount = {
21
+ auth: OAuthStored
22
+ account?: OAuthStored & AnthropicAccountIdentity
23
+ index?: number
24
+ }
25
+
20
26
  type AccountRecord = OAuthStored & {
21
27
  email?: string
22
28
  accountId?: string
@@ -243,6 +249,45 @@ async function writeAnthropicAuthFile(auth: OAuthStored | undefined) {
243
249
  await writeJson(file, data)
244
250
  }
245
251
 
252
+ function isOAuthStored(value: unknown): value is OAuthStored {
253
+ if (!value || typeof value !== 'object') {
254
+ return false
255
+ }
256
+
257
+ const record = value as Record<string, unknown>
258
+ return (
259
+ record.type === 'oauth' &&
260
+ typeof record.refresh === 'string' &&
261
+ typeof record.access === 'string' &&
262
+ typeof record.expires === 'number'
263
+ )
264
+ }
265
+
266
+ export async function getCurrentAnthropicAccount() {
267
+ const authJson = await readJson<Record<string, unknown>>(authFilePath(), {})
268
+ const auth = authJson.anthropic
269
+ if (!isOAuthStored(auth)) {
270
+ return null
271
+ }
272
+
273
+ const store = await loadAccountStore()
274
+ const index = findCurrentAccountIndex(store, auth)
275
+ const account = store.accounts[index]
276
+ if (!account) {
277
+ return { auth } satisfies CurrentAnthropicAccount
278
+ }
279
+
280
+ if (account.refresh !== auth.refresh && account.access !== auth.access) {
281
+ return { auth } satisfies CurrentAnthropicAccount
282
+ }
283
+
284
+ return {
285
+ auth,
286
+ account,
287
+ index,
288
+ } satisfies CurrentAnthropicAccount
289
+ }
290
+
246
291
  export async function setAnthropicAuth(
247
292
  auth: OAuthStored,
248
293
  client: Parameters<Plugin>[0]['client'],
@@ -0,0 +1,73 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import { extractBtwPrefix } from './btw-prefix-detection.js'
3
+
4
+ describe('extractBtwPrefix', () => {
5
+ test('matches lowercase prefix', () => {
6
+ expect(extractBtwPrefix('btw fix this')).toMatchInlineSnapshot(`
7
+ {
8
+ "prompt": "fix this",
9
+ }
10
+ `)
11
+ })
12
+
13
+ test('matches uppercase prefix', () => {
14
+ expect(extractBtwPrefix('BTW check this')).toMatchInlineSnapshot(`
15
+ {
16
+ "prompt": "check this",
17
+ }
18
+ `)
19
+ })
20
+
21
+ test('keeps multiline content', () => {
22
+ expect(extractBtwPrefix(' btw first line\nsecond line ')).toMatchInlineSnapshot(`
23
+ {
24
+ "prompt": "first line
25
+ second line",
26
+ }
27
+ `)
28
+ })
29
+
30
+ test('matches dot separator', () => {
31
+ expect(extractBtwPrefix('btw. fix this')).toMatchInlineSnapshot(`
32
+ {
33
+ "prompt": "fix this",
34
+ }
35
+ `)
36
+ })
37
+
38
+ test('matches comma separator', () => {
39
+ expect(extractBtwPrefix('btw, fix this')).toMatchInlineSnapshot(`
40
+ {
41
+ "prompt": "fix this",
42
+ }
43
+ `)
44
+ })
45
+
46
+ test('matches colon separator', () => {
47
+ expect(extractBtwPrefix('btw: fix this')).toMatchInlineSnapshot(`
48
+ {
49
+ "prompt": "fix this",
50
+ }
51
+ `)
52
+ })
53
+
54
+ test('matches punctuation without trailing space', () => {
55
+ expect(extractBtwPrefix('btw.fix this')).toMatchInlineSnapshot(`
56
+ {
57
+ "prompt": "fix this",
58
+ }
59
+ `)
60
+ })
61
+
62
+ test('does not match without separating whitespace', () => {
63
+ expect(extractBtwPrefix('btwfix this')).toMatchInlineSnapshot(`null`)
64
+ })
65
+
66
+ test('does not match mid-message', () => {
67
+ expect(extractBtwPrefix('hello btw fix this')).toMatchInlineSnapshot(`null`)
68
+ })
69
+
70
+ test('does not match empty payload', () => {
71
+ expect(extractBtwPrefix('btw ')).toMatchInlineSnapshot(`null`)
72
+ })
73
+ })
@@ -0,0 +1,23 @@
1
+ // Detects the raw `btw ` Discord message shortcut used to fork a side-question
2
+ // thread without invoking the /btw slash command UI.
3
+
4
+ export function extractBtwPrefix(
5
+ content: string,
6
+ ): { prompt: string } | null {
7
+ if (!content) {
8
+ return null
9
+ }
10
+
11
+ // Match "btw" followed by whitespace or punctuation (. , : ; ! ?) then the prompt
12
+ const match = content.match(/^\s*btw[.,;:!?\s]\s*([\s\S]+)$/i)
13
+ if (!match) {
14
+ return null
15
+ }
16
+
17
+ const prompt = match[1]?.trim()
18
+ if (!prompt) {
19
+ return null
20
+ }
21
+
22
+ return { prompt }
23
+ }
package/src/cli.ts CHANGED
@@ -64,7 +64,7 @@ import {
64
64
  buildSessionSearchSnippet,
65
65
  getPartSearchTexts,
66
66
  } from './session-search.js'
67
- import { formatWorktreeName } from './commands/new-worktree.js'
67
+ import { formatWorktreeName, formatAutoWorktreeName } from './commands/new-worktree.js'
68
68
  import { WORKTREE_PREFIX } from './commands/merge-worktree.js'
69
69
  import type { ThreadStartMarker } from './system-message.js'
70
70
  import { sendWelcomeMessage } from './onboarding-welcome.js'
@@ -90,6 +90,7 @@ import { createDiscordRest, discordApiUrl, getDiscordRestApiUrl, getGatewayProxy
90
90
  import crypto from 'node:crypto'
91
91
  import path from 'node:path'
92
92
  import fs from 'node:fs'
93
+ import { fileURLToPath } from 'node:url'
93
94
  import * as errore from 'errore'
94
95
 
95
96
  import { createLogger, formatErrorWithStack, initLogFile, LogPrefix } from './logger.js'
@@ -127,6 +128,8 @@ import {
127
128
  import {
128
129
  accountLabel,
129
130
  accountsFilePath,
131
+ authFilePath,
132
+ getCurrentAnthropicAccount,
130
133
  loadAccountStore,
131
134
  removeAccount,
132
135
  } from './anthropic-auth-state.js'
@@ -1840,6 +1843,24 @@ cli
1840
1843
  '--gateway-callback-url <url>',
1841
1844
  'After gateway OAuth install, redirect to this URL instead of the default success page (appends ?guild_id=<id>)',
1842
1845
  )
1846
+ .option(
1847
+ '--enable-skill <name>',
1848
+ z
1849
+ .array(z.string())
1850
+ .optional()
1851
+ .describe(
1852
+ '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.',
1853
+ ),
1854
+ )
1855
+ .option(
1856
+ '--disable-skill <name>',
1857
+ z
1858
+ .array(z.string())
1859
+ .optional()
1860
+ .describe(
1861
+ '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.',
1862
+ ),
1863
+ )
1843
1864
  .action(
1844
1865
  async (options: {
1845
1866
  restartOnboarding?: boolean
@@ -1856,6 +1877,8 @@ cli
1856
1877
  noSentry?: boolean
1857
1878
  gateway?: boolean
1858
1879
  gatewayCallbackUrl?: string
1880
+ enableSkill?: string[]
1881
+ disableSkill?: string[]
1859
1882
  }) => {
1860
1883
  // Guard: only one kimaki bot process can run at a time (they share a lock
1861
1884
  // port). Running `kimaki` here would kill the already-running bot process
@@ -1899,6 +1922,47 @@ cli
1899
1922
  }
1900
1923
  }
1901
1924
 
1925
+ // --enable-skill and --disable-skill are mutually exclusive: the user
1926
+ // either whitelists a small allowlist or blacklists a few unwanted
1927
+ // skills, never both. Applied later in opencode.ts as permission.skill
1928
+ // rules via computeSkillPermission().
1929
+ const enabledSkills = options.enableSkill ?? []
1930
+ const disabledSkills = options.disableSkill ?? []
1931
+ if (enabledSkills.length > 0 && disabledSkills.length > 0) {
1932
+ cliLogger.error(
1933
+ 'Cannot use --enable-skill and --disable-skill at the same time. Use one or the other.',
1934
+ )
1935
+ process.exit(EXIT_NO_RESTART)
1936
+ }
1937
+ // Soft-validate skill names against the bundled skills/ folder. Users
1938
+ // may rely on skills loaded from their own .opencode / .claude / .agents
1939
+ // dirs, so unknown names only emit a warning rather than hard-failing.
1940
+ if (enabledSkills.length > 0 || disabledSkills.length > 0) {
1941
+ const bundledSkillsDir = path.resolve(
1942
+ path.dirname(fileURLToPath(import.meta.url)),
1943
+ '..',
1944
+ 'skills',
1945
+ )
1946
+ const availableBundledSkills = (() => {
1947
+ try {
1948
+ return fs
1949
+ .readdirSync(bundledSkillsDir, { withFileTypes: true })
1950
+ .filter((entry) => entry.isDirectory())
1951
+ .map((entry) => entry.name)
1952
+ } catch {
1953
+ return [] as string[]
1954
+ }
1955
+ })()
1956
+ const availableSet = new Set(availableBundledSkills)
1957
+ for (const name of [...enabledSkills, ...disabledSkills]) {
1958
+ if (!availableSet.has(name)) {
1959
+ cliLogger.warn(
1960
+ `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(', ')}`,
1961
+ )
1962
+ }
1963
+ }
1964
+ }
1965
+
1902
1966
  store.setState({
1903
1967
  ...(options.verbosity && {
1904
1968
  defaultVerbosity: options.verbosity as
@@ -1908,8 +1972,21 @@ cli
1908
1972
  }),
1909
1973
  ...(options.mentionMode && { defaultMentionMode: true }),
1910
1974
  ...(options.noCritique && { critiqueEnabled: false }),
1975
+ ...(enabledSkills.length > 0 && { enabledSkills }),
1976
+ ...(disabledSkills.length > 0 && { disabledSkills }),
1911
1977
  })
1912
1978
 
1979
+ if (enabledSkills.length > 0) {
1980
+ cliLogger.log(
1981
+ `Skill whitelist enabled: only [${enabledSkills.join(', ')}] will be injected`,
1982
+ )
1983
+ }
1984
+ if (disabledSkills.length > 0) {
1985
+ cliLogger.log(
1986
+ `Skill blacklist enabled: [${disabledSkills.join(', ')}] will be hidden`,
1987
+ )
1988
+ }
1989
+
1913
1990
  if (options.verbosity) {
1914
1991
  cliLogger.log(`Default verbosity: ${options.verbosity}`)
1915
1992
  }
@@ -2382,36 +2459,20 @@ cli
2382
2459
  '--wait',
2383
2460
  'Wait for session to complete, then print session text to stdout',
2384
2461
  )
2385
- .action(
2386
- async (options: {
2387
- channel?: string
2388
- project?: string
2389
- prompt?: string
2390
- name?: string
2391
- appId?: string
2392
- notifyOnly?: boolean
2393
- worktree?: string | boolean
2394
- cwd?: string
2395
- user?: string
2396
- agent?: string
2397
- model?: string
2398
- permission?: string[]
2399
- injectionGuard?: string[]
2400
- sendAt?: string
2401
- thread?: string
2402
- session?: string
2403
- wait?: boolean
2404
- }) => {
2462
+ .action(async (options) => {
2405
2463
  try {
2464
+ // `--name` / `--app-id` are optional-value flags: `undefined` when
2465
+ // omitted, `''` when passed bare, a real string when given a value.
2466
+ // `||` collapses `''` to `undefined` for downstream consumers.
2467
+ const optionAppId = options.appId || undefined
2406
2468
  let {
2407
2469
  channel: channelId,
2408
2470
  prompt,
2409
- name,
2410
- appId: optionAppId,
2411
2471
  notifyOnly,
2412
2472
  thread: threadId,
2413
2473
  session: sessionId,
2414
2474
  } = options
2475
+ let name: string | undefined = options.name || undefined
2415
2476
  const { project: projectPath } = options
2416
2477
  const sendAt = options.sendAt
2417
2478
 
@@ -2862,12 +2923,12 @@ cli
2862
2923
  (cleanPrompt.length > 80
2863
2924
  ? cleanPrompt.slice(0, 77) + '...'
2864
2925
  : cleanPrompt)
2926
+ // Explicit string => use as-is via formatWorktreeName (no vowel strip).
2927
+ // Boolean true => derived from thread/prompt, compress via formatAutoWorktreeName.
2865
2928
  const worktreeName = options.worktree
2866
- ? formatWorktreeName(
2867
- typeof options.worktree === 'string'
2868
- ? options.worktree
2869
- : baseThreadName,
2870
- )
2929
+ ? typeof options.worktree === 'string'
2930
+ ? formatWorktreeName(options.worktree)
2931
+ : formatAutoWorktreeName(baseThreadName)
2871
2932
  : undefined
2872
2933
  const threadName = worktreeName
2873
2934
  ? `${WORKTREE_PREFIX}${baseThreadName}`
@@ -3185,6 +3246,42 @@ cli
3185
3246
  process.exit(0)
3186
3247
  })
3187
3248
 
3249
+ cli
3250
+ .command(
3251
+ 'anthropic-accounts current',
3252
+ 'Show the current Anthropic OAuth account being used, if any',
3253
+ )
3254
+ .action(async () => {
3255
+ const current = await getCurrentAnthropicAccount()
3256
+ console.log(`Store: ${accountsFilePath()}`)
3257
+ console.log(`Auth: ${authFilePath()}`)
3258
+
3259
+ if (!current) {
3260
+ console.log('No active Anthropic OAuth account configured.')
3261
+ process.exit(0)
3262
+ }
3263
+
3264
+ const lines: string[] = []
3265
+ lines.push(`Current: ${accountLabel(current.account || current.auth, current.index)}`)
3266
+
3267
+ if (current.account?.email) {
3268
+ lines.push(`Email: ${current.account.email}`)
3269
+ } else {
3270
+ lines.push('Email: unavailable')
3271
+ }
3272
+
3273
+ if (current.account?.accountId) {
3274
+ lines.push(`Account ID: ${current.account.accountId}`)
3275
+ }
3276
+
3277
+ if (!current.account) {
3278
+ lines.push('Rotation pool entry: not found')
3279
+ }
3280
+
3281
+ console.log(lines.join('\n'))
3282
+ process.exit(0)
3283
+ })
3284
+
3188
3285
  cli
3189
3286
  .command(
3190
3287
  'anthropic-accounts remove <indexOrEmail>',
@@ -3693,13 +3790,15 @@ cli
3693
3790
  )
3694
3791
  .option('-g, --guild <guildId>', 'Discord guild/server ID (required)')
3695
3792
  .option('-q, --query [query]', 'Search query to filter users by name')
3696
- .action(async (options: { guild?: string; query?: string }) => {
3793
+ .action(async (options) => {
3697
3794
  try {
3698
3795
  if (!options.guild) {
3699
3796
  cliLogger.error('Guild ID is required. Use --guild <guildId>')
3700
3797
  process.exit(EXIT_NO_RESTART)
3701
3798
  }
3702
3799
  const guildId = String(options.guild)
3800
+ // Bare `--query` comes through as `''`; collapse it to undefined
3801
+ const query = options.query || undefined
3703
3802
 
3704
3803
  await initDatabase()
3705
3804
  const { token: botToken } = await resolveBotCredentials()
@@ -3711,9 +3810,9 @@ cli
3711
3810
  }
3712
3811
 
3713
3812
  const members: GuildMember[] = await (async () => {
3714
- if (options.query) {
3813
+ if (query) {
3715
3814
  return (await rest.get(Routes.guildMembersSearch(guildId), {
3716
- query: new URLSearchParams({ query: options.query, limit: '20' }),
3815
+ query: new URLSearchParams({ query, limit: '20' }),
3717
3816
  })) as GuildMember[]
3718
3817
  }
3719
3818
  return (await rest.get(Routes.guildMembers(guildId), {
@@ -3722,8 +3821,8 @@ cli
3722
3821
  })()
3723
3822
 
3724
3823
  if (members.length === 0) {
3725
- const msg = options.query
3726
- ? `No users found matching "${options.query}"`
3824
+ const msg = query
3825
+ ? `No users found matching "${query}"`
3727
3826
  : 'No users found in guild'
3728
3827
  cliLogger.log(msg)
3729
3828
  process.exit(0)
@@ -3736,8 +3835,8 @@ cli
3736
3835
  })
3737
3836
  .join('\n')
3738
3837
 
3739
- const header = options.query
3740
- ? `Found ${members.length} users matching "${options.query}":`
3838
+ const header = query
3839
+ ? `Found ${members.length} users matching "${query}":`
3741
3840
  : `Found ${members.length} users:`
3742
3841
 
3743
3842
  console.log(`${header}\n${userList}`)
@@ -3761,14 +3860,7 @@ cli
3761
3860
  .option('-h, --host [host]', 'Local host (default: localhost)')
3762
3861
  .option('-s, --server [url]', 'Tunnel server URL')
3763
3862
  .option('-k, --kill', 'Kill any existing process on the port before starting')
3764
- .action(
3765
- async (options: {
3766
- port?: string
3767
- tunnelId?: string
3768
- host?: string
3769
- server?: string
3770
- kill?: boolean
3771
- }) => {
3863
+ .action(async (options) => {
3772
3864
  const { runTunnel, parseCommandFromArgv, CLI_NAME } = await import(
3773
3865
  'traforo/run-tunnel'
3774
3866
  )
@@ -3790,10 +3882,10 @@ cli
3790
3882
 
3791
3883
  await runTunnel({
3792
3884
  port,
3793
- tunnelId: options.tunnelId,
3794
- localHost: options.host,
3885
+ tunnelId: options.tunnelId || undefined,
3886
+ localHost: options.host || undefined,
3795
3887
  baseDomain: 'kimaki.dev',
3796
- serverUrl: options.server,
3888
+ serverUrl: options.server || undefined,
3797
3889
  command: command.length > 0 ? command : undefined,
3798
3890
  kill: options.kill,
3799
3891
  })
@@ -23,6 +23,7 @@ import {
23
23
  import { initializeOpencodeForDirectory } from '../opencode.js'
24
24
  import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js'
25
25
  import { createLogger, LogPrefix } from '../logger.js'
26
+ import { getCurrentModelInfo } from './model.js'
26
27
 
27
28
  const agentLogger = createLogger(LogPrefix.AGENT)
28
29
 
@@ -455,13 +456,34 @@ export async function handleQuickAgentCommand({
455
456
  ? ` (was **${previousAgentName}**)`
456
457
  : ''
457
458
 
459
+ // Resolve the model that will now be used for the new agent so we can
460
+ // show it in the reply. setAgentForContext already cleared any session
461
+ // model preference, so getCurrentModelInfo falls through to the agent's
462
+ // configured model (or channel/global/default).
463
+ const modelInfo = await (async () => {
464
+ const getClient = await initializeOpencodeForDirectory(context.dir)
465
+ if (getClient instanceof Error) {
466
+ return { type: 'none' as const }
467
+ }
468
+ return getCurrentModelInfo({
469
+ sessionId: context.sessionId,
470
+ channelId: context.channelId,
471
+ appId,
472
+ agentPreference: resolvedAgentName,
473
+ getClient,
474
+ })
475
+ })()
476
+
477
+ const modelText =
478
+ modelInfo.type === 'none' ? '' : `\nModel: *${modelInfo.model}*`
479
+
458
480
  if (context.isThread && context.sessionId) {
459
481
  await command.editReply({
460
- content: `Switched to **${resolvedAgentName}** agent for this session${previousText}\nThe agent will change on the next message.`,
482
+ content: `Switched to **${resolvedAgentName}** agent for this session${previousText}${modelText}\nThe agent will change on the next message.`,
461
483
  })
462
484
  } else {
463
485
  await command.editReply({
464
- content: `Switched to **${resolvedAgentName}** agent for this channel${previousText}\nAll new sessions will use this agent.`,
486
+ content: `Switched to **${resolvedAgentName}** agent for this channel${previousText}${modelText}\nAll new sessions will use this agent.`,
465
487
  })
466
488
  }
467
489
  } catch (error) {
@@ -0,0 +1,111 @@
1
+ // Tests AskUserQuestion request deduplication and cleanup helpers.
2
+
3
+ import { afterEach, describe, expect, test, vi } from 'vitest'
4
+ import type { ThreadChannel } from 'discord.js'
5
+ import {
6
+ deletePendingQuestionContextsForRequest,
7
+ pendingQuestionContexts,
8
+ showAskUserQuestionDropdowns,
9
+ } from './ask-question.js'
10
+
11
+ function createFakeThread(): ThreadChannel {
12
+ const send = vi.fn(async () => {
13
+ return { id: 'msg-1' }
14
+ })
15
+
16
+ return {
17
+ id: 'thread-1',
18
+ send,
19
+ } as unknown as ThreadChannel
20
+ }
21
+
22
+ afterEach(() => {
23
+ pendingQuestionContexts.clear()
24
+ vi.restoreAllMocks()
25
+ })
26
+
27
+ describe('ask-question', () => {
28
+ test('dedupes duplicate question requests for the same thread', async () => {
29
+ const thread = createFakeThread()
30
+
31
+ await showAskUserQuestionDropdowns({
32
+ thread,
33
+ sessionId: 'ses-1',
34
+ directory: '/project',
35
+ requestId: 'req-1',
36
+ input: {
37
+ questions: [{
38
+ question: 'Choose one',
39
+ header: 'Pick',
40
+ options: [
41
+ { label: 'Alpha', description: 'A' },
42
+ { label: 'Beta', description: 'B' },
43
+ ],
44
+ }],
45
+ },
46
+ })
47
+
48
+ await showAskUserQuestionDropdowns({
49
+ thread,
50
+ sessionId: 'ses-1',
51
+ directory: '/project',
52
+ requestId: 'req-1',
53
+ input: {
54
+ questions: [{
55
+ question: 'Choose one',
56
+ header: 'Pick',
57
+ options: [
58
+ { label: 'Alpha', description: 'A' },
59
+ { label: 'Beta', description: 'B' },
60
+ ],
61
+ }],
62
+ },
63
+ })
64
+
65
+ expect(thread.send).toHaveBeenCalledTimes(1)
66
+ expect(pendingQuestionContexts.size).toBe(1)
67
+ })
68
+
69
+ test('removes all duplicate contexts for one request', () => {
70
+ const thread = createFakeThread()
71
+ const baseContext: typeof pendingQuestionContexts extends Map<string, infer T>
72
+ ? T
73
+ : never = {
74
+ sessionId: 'ses-1',
75
+ directory: '/project',
76
+ thread,
77
+ requestId: 'req-1',
78
+ questions: [{
79
+ question: 'Choose one',
80
+ header: 'Pick',
81
+ options: [
82
+ { label: 'Alpha', description: 'A' },
83
+ { label: 'Beta', description: 'B' },
84
+ ],
85
+ }],
86
+ answers: {},
87
+ totalQuestions: 1,
88
+ answeredCount: 0,
89
+ contextHash: 'ctx-1',
90
+ }
91
+
92
+ pendingQuestionContexts.set('ctx-1', baseContext)
93
+ pendingQuestionContexts.set('ctx-2', {
94
+ ...baseContext,
95
+ contextHash: 'ctx-2',
96
+ })
97
+ pendingQuestionContexts.set('ctx-3', {
98
+ ...baseContext,
99
+ requestId: 'req-2',
100
+ contextHash: 'ctx-3',
101
+ })
102
+
103
+ const removed = deletePendingQuestionContextsForRequest({
104
+ threadId: thread.id,
105
+ requestId: 'req-1',
106
+ })
107
+
108
+ expect(removed).toBe(2)
109
+ expect([...pendingQuestionContexts.keys()]).toEqual(['ctx-3'])
110
+ })
111
+ })