discoclaw 0.2.4 → 0.3.0

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 (46) hide show
  1. package/.context/pa.md +1 -1
  2. package/.context/runtime.md +48 -4
  3. package/.env.example +6 -0
  4. package/.env.example.full +7 -0
  5. package/README.md +5 -1
  6. package/dist/config.js +2 -0
  7. package/dist/cron/cron-sync-coordinator.js +4 -0
  8. package/dist/cron/cron-sync-coordinator.test.js +8 -0
  9. package/dist/cron/executor.js +36 -1
  10. package/dist/cron/executor.test.js +157 -0
  11. package/dist/cron/forum-sync.js +47 -0
  12. package/dist/cron/forum-sync.test.js +234 -0
  13. package/dist/cron/run-stats.js +10 -3
  14. package/dist/cron/run-stats.test.js +67 -3
  15. package/dist/discord/actions-config.js +41 -8
  16. package/dist/discord/actions-config.test.js +130 -8
  17. package/dist/discord/actions-crons.js +18 -0
  18. package/dist/discord/actions-crons.test.js +12 -0
  19. package/dist/discord/models-command.js +5 -0
  20. package/dist/index.js +28 -0
  21. package/dist/mcp-detect.js +74 -0
  22. package/dist/mcp-detect.test.js +160 -0
  23. package/dist/runtime/openai-compat.js +224 -90
  24. package/dist/runtime/openai-compat.test.js +409 -2
  25. package/dist/runtime/openai-tool-exec.js +433 -0
  26. package/dist/runtime/openai-tool-exec.test.js +267 -0
  27. package/dist/runtime/openai-tool-schemas.js +174 -0
  28. package/dist/runtime/openai-tool-schemas.test.js +74 -0
  29. package/dist/runtime/tools/fs-glob.js +102 -0
  30. package/dist/runtime/tools/fs-glob.test.js +67 -0
  31. package/dist/runtime/tools/fs-read-file.js +49 -0
  32. package/dist/runtime/tools/fs-read-file.test.js +51 -0
  33. package/dist/runtime/tools/fs-realpath.js +51 -0
  34. package/dist/runtime/tools/fs-realpath.test.js +72 -0
  35. package/dist/runtime/tools/fs-write-file.js +45 -0
  36. package/dist/runtime/tools/fs-write-file.test.js +56 -0
  37. package/dist/runtime/tools/image-download.js +138 -0
  38. package/dist/runtime/tools/image-download.test.js +106 -0
  39. package/dist/runtime/tools/path-security.js +72 -0
  40. package/dist/runtime/tools/types.js +4 -0
  41. package/dist/workspace-bootstrap.js +0 -1
  42. package/dist/workspace-bootstrap.test.js +0 -2
  43. package/package.json +1 -1
  44. package/templates/mcp.json +8 -0
  45. package/templates/workspace/TOOLS.md +70 -1
  46. package/templates/workspace/HEARTBEAT.md +0 -10
@@ -310,6 +310,240 @@ describe('initCronForum', () => {
310
310
  });
311
311
  expect(scheduler.register).toHaveBeenCalledWith('thread-1', 'thread-1', 'guild-1', 'Job 1', expect.objectContaining({ schedule: '0 7 * * *' }), 'cron-from-status-id');
312
312
  });
313
+ it('skips AI parse when stats store has stored definition (fast path)', async () => {
314
+ const thread = makeThread();
315
+ // fetchStarterMessage should NOT be called — fast path bypasses loadThreadAsCron entirely.
316
+ thread.fetchStarterMessage.mockResolvedValue(null);
317
+ const forum = makeForum([thread]);
318
+ const client = makeClient(forum);
319
+ const scheduler = makeScheduler();
320
+ scheduler.register.mockReturnValue({ cron: { nextRun: () => new Date() } });
321
+ const statsStore = {
322
+ getRecordByThreadId: vi.fn().mockReturnValue({
323
+ cronId: 'cron-stored',
324
+ threadId: 'thread-1',
325
+ disabled: false,
326
+ schedule: '0 7 * * *',
327
+ timezone: 'America/New_York',
328
+ channel: 'general',
329
+ prompt: 'Say good morning.',
330
+ authorId: 'u-allowed',
331
+ triggerType: 'schedule',
332
+ }),
333
+ getRecord: vi.fn(),
334
+ upsertRecord: vi.fn(async () => ({})),
335
+ };
336
+ await initCronForum({
337
+ client: client,
338
+ forumChannelNameOrId: 'forum-1',
339
+ allowUserIds: new Set(['u-allowed']),
340
+ scheduler: scheduler,
341
+ runtime: {},
342
+ cronModel: 'haiku',
343
+ cwd: '/tmp',
344
+ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
345
+ statsStore: statsStore,
346
+ });
347
+ // AI parser should never be called.
348
+ expect(parseCronDefinition).not.toHaveBeenCalled();
349
+ // scheduler.register should receive the stored values.
350
+ expect(scheduler.register).toHaveBeenCalledWith('thread-1', 'thread-1', 'guild-1', 'Job 1', expect.objectContaining({
351
+ triggerType: 'schedule',
352
+ schedule: '0 7 * * *',
353
+ timezone: 'America/New_York',
354
+ channel: 'general',
355
+ prompt: 'Say good morning.',
356
+ }), 'cron-stored');
357
+ // fetchStarterMessage should not have been called (fast path skips loadThreadAsCron).
358
+ expect(thread.fetchStarterMessage).not.toHaveBeenCalled();
359
+ });
360
+ it('falls through to AI parse when stats store record lacks definition fields', async () => {
361
+ const thread = makeThread();
362
+ thread.fetchStarterMessage.mockResolvedValue({
363
+ id: 'm1',
364
+ content: 'every day at 7am post to #general say hello',
365
+ author: { id: 'u-allowed' },
366
+ react: vi.fn().mockResolvedValue(undefined),
367
+ });
368
+ thread.messages.fetch = vi.fn().mockResolvedValue(new Map());
369
+ const forum = makeForum([thread]);
370
+ const client = makeClient(forum);
371
+ const scheduler = makeScheduler();
372
+ scheduler.register.mockReturnValue({ cron: { nextRun: () => new Date() } });
373
+ vi.mocked(parseCronDefinition).mockResolvedValue({
374
+ triggerType: 'schedule',
375
+ schedule: '0 7 * * *',
376
+ timezone: 'UTC',
377
+ channel: 'general',
378
+ prompt: 'Say hello.',
379
+ });
380
+ // Record exists but has no stored definition fields (pre-upgrade record).
381
+ const statsStore = {
382
+ getRecordByThreadId: vi.fn().mockReturnValue({
383
+ cronId: 'cron-old',
384
+ threadId: 'thread-1',
385
+ disabled: false,
386
+ }),
387
+ getRecord: vi.fn().mockReturnValue({
388
+ cronId: 'cron-old',
389
+ threadId: 'thread-1',
390
+ disabled: false,
391
+ }),
392
+ upsertRecord: vi.fn(async () => ({})),
393
+ };
394
+ await initCronForum({
395
+ client: client,
396
+ forumChannelNameOrId: 'forum-1',
397
+ allowUserIds: new Set(['u-allowed']),
398
+ scheduler: scheduler,
399
+ runtime: {},
400
+ cronModel: 'haiku',
401
+ cwd: '/tmp',
402
+ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
403
+ statsStore: statsStore,
404
+ });
405
+ // Should fall through to AI parse since record lacks definition fields.
406
+ expect(parseCronDefinition).toHaveBeenCalled();
407
+ });
408
+ it('falls through to AI parse when fast-path register throws', async () => {
409
+ const thread = makeThread();
410
+ thread.fetchStarterMessage.mockResolvedValue({
411
+ id: 'm1',
412
+ content: 'every day at 7am post to #general say hello',
413
+ author: { id: 'u-allowed' },
414
+ react: vi.fn().mockResolvedValue(undefined),
415
+ });
416
+ thread.messages.fetch = vi.fn().mockResolvedValue(new Map());
417
+ const forum = makeForum([thread]);
418
+ const client = makeClient(forum);
419
+ const scheduler = makeScheduler();
420
+ // First call (fast path) throws; second call (loadThreadAsCron fallback) succeeds.
421
+ scheduler.register
422
+ .mockImplementationOnce(() => { throw new Error('corrupt schedule'); })
423
+ .mockReturnValueOnce({ cron: { nextRun: () => new Date() } });
424
+ vi.mocked(parseCronDefinition).mockResolvedValue({
425
+ triggerType: 'schedule',
426
+ schedule: '0 7 * * *',
427
+ timezone: 'UTC',
428
+ channel: 'general',
429
+ prompt: 'Say hello.',
430
+ });
431
+ const statsStore = {
432
+ getRecordByThreadId: vi.fn().mockReturnValue({
433
+ cronId: 'cron-corrupt',
434
+ threadId: 'thread-1',
435
+ disabled: false,
436
+ schedule: 'BAD SCHEDULE',
437
+ timezone: 'UTC',
438
+ channel: 'general',
439
+ prompt: 'Say hello.',
440
+ authorId: 'u-allowed',
441
+ }),
442
+ getRecord: vi.fn().mockReturnValue({
443
+ cronId: 'cron-corrupt',
444
+ threadId: 'thread-1',
445
+ disabled: false,
446
+ }),
447
+ upsertRecord: vi.fn(async () => ({})),
448
+ };
449
+ await initCronForum({
450
+ client: client,
451
+ forumChannelNameOrId: 'forum-1',
452
+ allowUserIds: new Set(['u-allowed']),
453
+ scheduler: scheduler,
454
+ runtime: {},
455
+ cronModel: 'haiku',
456
+ cwd: '/tmp',
457
+ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
458
+ statsStore: statsStore,
459
+ });
460
+ // Fast path failed, should fall through to AI parse.
461
+ expect(parseCronDefinition).toHaveBeenCalled();
462
+ // scheduler.register should have been called twice (fast path + fallback).
463
+ expect(scheduler.register).toHaveBeenCalledTimes(2);
464
+ });
465
+ it('disabled state restored via fast path', async () => {
466
+ const thread = makeThread();
467
+ thread.fetchStarterMessage.mockResolvedValue(null);
468
+ const forum = makeForum([thread]);
469
+ const client = makeClient(forum);
470
+ const scheduler = makeScheduler();
471
+ scheduler.register.mockReturnValue({ cron: { nextRun: () => new Date() } });
472
+ const statsStore = {
473
+ getRecordByThreadId: vi.fn().mockReturnValue({
474
+ cronId: 'cron-disabled-fp',
475
+ threadId: 'thread-1',
476
+ disabled: true,
477
+ schedule: '0 7 * * *',
478
+ timezone: 'UTC',
479
+ channel: 'general',
480
+ prompt: 'Say hello.',
481
+ authorId: 'u-allowed',
482
+ }),
483
+ getRecord: vi.fn(),
484
+ upsertRecord: vi.fn(async () => ({})),
485
+ };
486
+ await initCronForum({
487
+ client: client,
488
+ forumChannelNameOrId: 'forum-1',
489
+ allowUserIds: new Set(['u-allowed']),
490
+ scheduler: scheduler,
491
+ runtime: {},
492
+ cronModel: 'haiku',
493
+ cwd: '/tmp',
494
+ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
495
+ statsStore: statsStore,
496
+ });
497
+ // Should register then immediately disable.
498
+ expect(scheduler.register).toHaveBeenCalledOnce();
499
+ expect(scheduler.disable).toHaveBeenCalledWith('thread-1');
500
+ // AI parser should NOT be called.
501
+ expect(parseCronDefinition).not.toHaveBeenCalled();
502
+ });
503
+ it('falls through when stored authorId is not in allowlist', async () => {
504
+ const thread = makeThread();
505
+ thread.fetchStarterMessage.mockResolvedValue({
506
+ id: 'm1',
507
+ content: 'every day at 7am post to #general say hello',
508
+ author: { id: 'u-not-allowed' },
509
+ react: vi.fn().mockResolvedValue(undefined),
510
+ });
511
+ thread.messages.fetch = vi.fn().mockResolvedValue(new Map());
512
+ const forum = makeForum([thread]);
513
+ const client = makeClient(forum);
514
+ const scheduler = makeScheduler();
515
+ const statsStore = {
516
+ getRecordByThreadId: vi.fn().mockReturnValue({
517
+ cronId: 'cron-unauth',
518
+ threadId: 'thread-1',
519
+ disabled: false,
520
+ schedule: '0 7 * * *',
521
+ timezone: 'UTC',
522
+ channel: 'general',
523
+ prompt: 'Say hello.',
524
+ authorId: 'u-not-allowed',
525
+ }),
526
+ getRecord: vi.fn(),
527
+ upsertRecord: vi.fn(async () => ({})),
528
+ };
529
+ await initCronForum({
530
+ client: client,
531
+ forumChannelNameOrId: 'forum-1',
532
+ allowUserIds: new Set(['u-allowed']),
533
+ scheduler: scheduler,
534
+ runtime: {},
535
+ cronModel: 'haiku',
536
+ cwd: '/tmp',
537
+ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
538
+ statsStore: statsStore,
539
+ });
540
+ // Should fall through to loadThreadAsCron which will reject and disable.
541
+ // fetchStarterMessage is called by loadThreadAsCron.
542
+ expect(thread.fetchStarterMessage).toHaveBeenCalled();
543
+ // scheduler.register should NOT be called (auth fails in loadThreadAsCron too).
544
+ expect(scheduler.register).not.toHaveBeenCalled();
545
+ expect(scheduler.disable).toHaveBeenCalledWith('thread-1');
546
+ });
313
547
  it('restores disabled state from stats store', async () => {
314
548
  const thread = makeThread();
315
549
  thread.fetchStarterMessage.mockResolvedValue({
@@ -5,7 +5,7 @@ import crypto from 'node:crypto';
5
5
  // Types
6
6
  // ---------------------------------------------------------------------------
7
7
  export const CADENCE_TAGS = ['yearly', 'frequent', 'hourly', 'daily', 'weekly', 'monthly'];
8
- export const CURRENT_VERSION = 4;
8
+ export const CURRENT_VERSION = 6;
9
9
  // ---------------------------------------------------------------------------
10
10
  // Stable Cron ID generation
11
11
  // ---------------------------------------------------------------------------
@@ -292,7 +292,14 @@ export async function loadRunStats(filePath) {
292
292
  if (store.version === 3) {
293
293
  store.version = 4;
294
294
  }
295
- // Add future migration blocks here:
296
- // if (store.version === 4) { /* transform fields */; store.version = 5; }
295
+ // Migrate v4 v5: no-op — new field (silent) is optional and defaults falsy.
296
+ if (store.version === 4) {
297
+ store.version = 5;
298
+ }
299
+ // Migrate v5 → v6: no-op — new persisted definition fields (schedule, timezone, channel, prompt, authorId) are optional.
300
+ // Absent records fall through to AI parsing on first boot after upgrade.
301
+ if (store.version === 5) {
302
+ store.version = 6;
303
+ }
297
304
  return new CronRunStats(store, filePath);
298
305
  }
@@ -38,7 +38,7 @@ describe('CronRunStats', () => {
38
38
  it('creates empty store on missing file', async () => {
39
39
  const stats = await loadRunStats(statsPath);
40
40
  const store = stats.getStore();
41
- expect(store.version).toBe(4);
41
+ expect(store.version).toBe(6);
42
42
  expect(Object.keys(store.jobs)).toHaveLength(0);
43
43
  });
44
44
  it('upserts and retrieves records by cronId', async () => {
@@ -210,7 +210,7 @@ describe('CronRunStats', () => {
210
210
  describe('emptyStore', () => {
211
211
  it('returns valid initial structure', () => {
212
212
  const store = emptyStore();
213
- expect(store.version).toBe(4);
213
+ expect(store.version).toBe(6);
214
214
  expect(store.updatedAt).toBeGreaterThan(0);
215
215
  expect(Object.keys(store.jobs)).toHaveLength(0);
216
216
  });
@@ -237,7 +237,7 @@ describe('loadRunStats version migration', () => {
237
237
  };
238
238
  await fs.writeFile(statsPath, JSON.stringify(v3Store), 'utf-8');
239
239
  const stats = await loadRunStats(statsPath);
240
- expect(stats.getStore().version).toBe(4);
240
+ expect(stats.getStore().version).toBe(6);
241
241
  const rec = stats.getRecord('cron-migrated');
242
242
  expect(rec).toBeDefined();
243
243
  expect(rec.cronId).toBe('cron-migrated');
@@ -246,4 +246,68 @@ describe('loadRunStats version migration', () => {
246
246
  expect(rec.cadence).toBe('daily');
247
247
  expect(rec.purposeTags).toEqual(['monitoring']);
248
248
  });
249
+ it('migrates a v4 store to v5 with silent undefined on existing records', async () => {
250
+ const v4Store = {
251
+ version: 4,
252
+ updatedAt: Date.now(),
253
+ jobs: {
254
+ 'cron-v4': {
255
+ cronId: 'cron-v4',
256
+ threadId: 'thread-v4',
257
+ runCount: 3,
258
+ lastRunAt: '2025-06-01T00:00:00.000Z',
259
+ lastRunStatus: 'success',
260
+ cadence: 'hourly',
261
+ purposeTags: ['email'],
262
+ disabled: false,
263
+ model: 'sonnet',
264
+ triggerType: 'schedule',
265
+ },
266
+ },
267
+ };
268
+ await fs.writeFile(statsPath, JSON.stringify(v4Store), 'utf-8');
269
+ const stats = await loadRunStats(statsPath);
270
+ expect(stats.getStore().version).toBe(6);
271
+ const rec = stats.getRecord('cron-v4');
272
+ expect(rec).toBeDefined();
273
+ expect(rec.cronId).toBe('cron-v4');
274
+ expect(rec.runCount).toBe(3);
275
+ expect(rec.cadence).toBe('hourly');
276
+ expect(rec.silent).toBeUndefined();
277
+ });
278
+ it('migrates a v5 store to v6 with definition fields undefined on existing records', async () => {
279
+ const v5Store = {
280
+ version: 5,
281
+ updatedAt: Date.now(),
282
+ jobs: {
283
+ 'cron-v5': {
284
+ cronId: 'cron-v5',
285
+ threadId: 'thread-v5',
286
+ runCount: 7,
287
+ lastRunAt: '2025-08-01T00:00:00.000Z',
288
+ lastRunStatus: 'success',
289
+ cadence: 'daily',
290
+ purposeTags: ['greeting'],
291
+ disabled: false,
292
+ model: 'haiku',
293
+ triggerType: 'schedule',
294
+ silent: true,
295
+ },
296
+ },
297
+ };
298
+ await fs.writeFile(statsPath, JSON.stringify(v5Store), 'utf-8');
299
+ const stats = await loadRunStats(statsPath);
300
+ expect(stats.getStore().version).toBe(6);
301
+ const rec = stats.getRecord('cron-v5');
302
+ expect(rec).toBeDefined();
303
+ expect(rec.cronId).toBe('cron-v5');
304
+ expect(rec.runCount).toBe(7);
305
+ expect(rec.cadence).toBe('daily');
306
+ expect(rec.silent).toBe(true);
307
+ expect(rec.schedule).toBeUndefined();
308
+ expect(rec.timezone).toBeUndefined();
309
+ expect(rec.channel).toBeUndefined();
310
+ expect(rec.prompt).toBeUndefined();
311
+ expect(rec.authorId).toBeUndefined();
312
+ });
249
313
  });
@@ -38,14 +38,45 @@ export function executeConfigAction(action, configCtx) {
38
38
  const bp = configCtx.botParams;
39
39
  const changes = [];
40
40
  switch (action.role) {
41
- case 'chat':
42
- bp.runtimeModel = model;
43
- if (bp.planCtx)
44
- bp.planCtx.model = model;
45
- if (bp.cronCtx?.executorCtx)
46
- bp.cronCtx.executorCtx.model = model;
47
- changes.push(`chat ${model}`);
41
+ case 'chat': {
42
+ // Check if the model string is actually a runtime name.
43
+ const normalized = model.toLowerCase();
44
+ const newRuntime = configCtx.runtimeRegistry?.get(normalized);
45
+ if (newRuntime) {
46
+ // Swap runtime across all invocation paths.
47
+ const runtimeModel = newRuntime.defaultModel ?? '';
48
+ bp.runtime = newRuntime;
49
+ bp.runtimeModel = runtimeModel;
50
+ configCtx.runtime = newRuntime;
51
+ configCtx.runtimeName = normalized;
52
+ if (bp.cronCtx) {
53
+ bp.cronCtx.runtime = newRuntime;
54
+ bp.cronCtx.syncCoordinator?.setRuntime?.(newRuntime);
55
+ if (bp.cronCtx.executorCtx) {
56
+ bp.cronCtx.executorCtx.runtime = newRuntime;
57
+ bp.cronCtx.executorCtx.model = runtimeModel;
58
+ }
59
+ }
60
+ if (bp.planCtx) {
61
+ bp.planCtx.runtime = newRuntime;
62
+ bp.planCtx.model = runtimeModel;
63
+ }
64
+ if (bp.deferOpts)
65
+ bp.deferOpts.runtime = newRuntime;
66
+ changes.push(`runtime → ${normalized}`);
67
+ if (runtimeModel)
68
+ changes.push(`chat → ${runtimeModel} (adapter default)`);
69
+ }
70
+ else {
71
+ bp.runtimeModel = model;
72
+ if (bp.planCtx)
73
+ bp.planCtx.model = model;
74
+ if (bp.cronCtx?.executorCtx)
75
+ bp.cronCtx.executorCtx.model = model;
76
+ changes.push(`chat → ${model}`);
77
+ }
48
78
  break;
79
+ }
49
80
  case 'fast':
50
81
  bp.summaryModel = model;
51
82
  changes.push(`summary → ${model}`);
@@ -106,7 +137,9 @@ export function executeConfigAction(action, configCtx) {
106
137
  case 'modelShow': {
107
138
  const bp = configCtx.botParams;
108
139
  const rid = configCtx.runtime.id;
140
+ const runtimeName = configCtx.runtimeName ?? rid;
109
141
  const rows = [
142
+ ['runtime', runtimeName, `Active runtime adapter (${rid})`],
110
143
  ['chat', bp.runtimeModel, ROLE_DESCRIPTIONS.chat],
111
144
  ['summary', bp.summaryModel, ROLE_DESCRIPTIONS.summary],
112
145
  ['forge-drafter', bp.forgeDrafterModel ?? `${bp.runtimeModel} (follows chat)`, ROLE_DESCRIPTIONS['forge-drafter']],
@@ -159,7 +192,7 @@ export function configActionsPromptSection() {
159
192
  <discord-action>{"type":"modelSet","role":"fast","model":"haiku"}</discord-action>
160
193
  \`\`\`
161
194
  - \`role\` (required): One of \`chat\`, \`fast\`, \`forge-drafter\`, \`forge-auditor\`, \`summary\`, \`cron\`, \`cron-exec\`.
162
- - \`model\` (required): Model tier (\`fast\`, \`capable\`), concrete model name (\`haiku\`, \`sonnet\`, \`opus\`), or \`default\` (for cron-exec only, to revert to following chat).
195
+ - \`model\` (required): Model tier (\`fast\`, \`capable\`), concrete model name (\`haiku\`, \`sonnet\`, \`opus\`), runtime name (\`openrouter\`, \`gemini\` — for \`chat\` role, swaps the active runtime adapter), or \`default\` (for cron-exec only, to revert to following chat).
163
196
 
164
197
  **Roles:**
165
198
  | Role | What it controls |
@@ -1,28 +1,45 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { CONFIG_ACTION_TYPES, executeConfigAction, configActionsPromptSection } from './actions-config.js';
3
+ import { RuntimeRegistry } from '../runtime/registry.js';
3
4
  // ---------------------------------------------------------------------------
4
5
  // Helpers
5
6
  // ---------------------------------------------------------------------------
7
+ const stubRuntime = {
8
+ id: 'claude_code',
9
+ capabilities: new Set(),
10
+ async *invoke() { },
11
+ };
12
+ const openrouterRuntime = {
13
+ id: 'openrouter',
14
+ capabilities: new Set(),
15
+ defaultModel: 'anthropic/claude-sonnet-4',
16
+ async *invoke() { },
17
+ };
18
+ function makeRegistry(...entries) {
19
+ const reg = new RuntimeRegistry();
20
+ for (const [name, adapter] of entries) {
21
+ reg.register(name, adapter);
22
+ }
23
+ return reg;
24
+ }
6
25
  function makeBotParams(overrides) {
7
26
  return {
8
27
  runtimeModel: 'capable',
9
28
  summaryModel: 'fast',
29
+ runtime: stubRuntime,
10
30
  forgeDrafterModel: undefined,
11
31
  forgeAuditorModel: undefined,
12
- cronCtx: { autoTagModel: 'fast', executorCtx: { model: 'capable' } },
32
+ cronCtx: { autoTagModel: 'fast', runtime: stubRuntime, executorCtx: { model: 'capable', runtime: stubRuntime } },
13
33
  taskCtx: { autoTagModel: 'fast' },
14
34
  ...overrides,
15
35
  };
16
36
  }
17
- const stubRuntime = {
18
- id: 'claude_code',
19
- capabilities: new Set(),
20
- async *invoke() { },
21
- };
22
37
  function makeCtx(overrides) {
23
38
  return {
24
39
  botParams: makeBotParams(overrides),
25
40
  runtime: stubRuntime,
41
+ runtimeRegistry: makeRegistry(),
42
+ runtimeName: 'claude_code',
26
43
  };
27
44
  }
28
45
  // ---------------------------------------------------------------------------
@@ -142,6 +159,8 @@ describe('modelShow', () => {
142
159
  const ctx = {
143
160
  botParams: makeBotParams({ runtimeModel: '', summaryModel: '' }),
144
161
  runtime: codexRuntime,
162
+ runtimeRegistry: makeRegistry(),
163
+ runtimeName: 'codex',
145
164
  };
146
165
  const result = executeConfigAction({ type: 'modelShow' }, ctx);
147
166
  expect(result.ok).toBe(true);
@@ -159,6 +178,8 @@ describe('modelShow', () => {
159
178
  const ctx = {
160
179
  botParams: makeBotParams({ runtimeModel: '', summaryModel: '' }),
161
180
  runtime: codexRuntime,
181
+ runtimeRegistry: makeRegistry(),
182
+ runtimeName: 'codex',
162
183
  };
163
184
  const result = executeConfigAction({ type: 'modelShow' }, ctx);
164
185
  expect(result.ok).toBe(true);
@@ -322,13 +343,13 @@ describe('modelSet', () => {
322
343
  });
323
344
  it('chat propagates to planCtx.model', () => {
324
345
  const ctx = makeCtx();
325
- ctx.botParams.planCtx = { model: 'old' };
346
+ ctx.botParams.planCtx = { model: 'old', runtime: stubRuntime };
326
347
  executeConfigAction({ type: 'modelSet', role: 'chat', model: 'sonnet' }, ctx);
327
348
  expect(ctx.botParams.planCtx.model).toBe('sonnet');
328
349
  });
329
350
  it('chat propagates to cronCtx.executorCtx.model', () => {
330
351
  const ctx = makeCtx();
331
- ctx.botParams.cronCtx.executorCtx = { model: 'old' };
352
+ ctx.botParams.cronCtx.executorCtx = { model: 'old', runtime: stubRuntime };
332
353
  executeConfigAction({ type: 'modelSet', role: 'chat', model: 'sonnet' }, ctx);
333
354
  expect(ctx.botParams.cronCtx.executorCtx.model).toBe('sonnet');
334
355
  });
@@ -377,6 +398,107 @@ describe('modelSet', () => {
377
398
  });
378
399
  });
379
400
  // ---------------------------------------------------------------------------
401
+ // modelSet — runtime swap
402
+ // ---------------------------------------------------------------------------
403
+ describe('modelSet runtime swap', () => {
404
+ it('swaps all runtime refs and sets default model for a registered runtime name', () => {
405
+ const ctx = makeCtx();
406
+ ctx.runtimeRegistry = makeRegistry(['openrouter', openrouterRuntime]);
407
+ ctx.botParams.planCtx = { model: 'capable', runtime: stubRuntime };
408
+ ctx.botParams.deferOpts = { runtime: stubRuntime };
409
+ const result = executeConfigAction({ type: 'modelSet', role: 'chat', model: 'openrouter' }, ctx);
410
+ expect(result.ok).toBe(true);
411
+ if (!result.ok)
412
+ return;
413
+ // Runtime swapped everywhere
414
+ expect(ctx.botParams.runtime).toBe(openrouterRuntime);
415
+ expect(ctx.runtime).toBe(openrouterRuntime);
416
+ expect(ctx.runtimeName).toBe('openrouter');
417
+ expect(ctx.botParams.runtimeModel).toBe('anthropic/claude-sonnet-4');
418
+ expect(ctx.botParams.planCtx.runtime).toBe(openrouterRuntime);
419
+ expect(ctx.botParams.planCtx.model).toBe('anthropic/claude-sonnet-4');
420
+ expect(ctx.botParams.cronCtx.runtime).toBe(openrouterRuntime);
421
+ expect(ctx.botParams.cronCtx.executorCtx.runtime).toBe(openrouterRuntime);
422
+ expect(ctx.botParams.cronCtx.executorCtx.model).toBe('anthropic/claude-sonnet-4');
423
+ expect(ctx.botParams.deferOpts.runtime).toBe(openrouterRuntime);
424
+ expect(result.summary).toContain('runtime → openrouter');
425
+ expect(result.summary).toContain('adapter default');
426
+ });
427
+ it('does not swap runtime for a plain model name like opus', () => {
428
+ const ctx = makeCtx();
429
+ ctx.runtimeRegistry = makeRegistry(['openrouter', openrouterRuntime]);
430
+ const result = executeConfigAction({ type: 'modelSet', role: 'chat', model: 'opus' }, ctx);
431
+ expect(result.ok).toBe(true);
432
+ expect(ctx.botParams.runtime).toBe(stubRuntime);
433
+ expect(ctx.runtime).toBe(stubRuntime);
434
+ expect(ctx.runtimeName).toBe('claude_code');
435
+ expect(ctx.botParams.runtimeModel).toBe('opus');
436
+ });
437
+ it('passes through unregistered name as model string without error', () => {
438
+ const ctx = makeCtx();
439
+ ctx.runtimeRegistry = makeRegistry(['openrouter', openrouterRuntime]);
440
+ const result = executeConfigAction({ type: 'modelSet', role: 'chat', model: 'mistral' }, ctx);
441
+ expect(result.ok).toBe(true);
442
+ // No swap — treated as model string
443
+ expect(ctx.botParams.runtime).toBe(stubRuntime);
444
+ expect(ctx.botParams.runtimeModel).toBe('mistral');
445
+ });
446
+ it('case-insensitive matching — OpenRouter matches openrouter', () => {
447
+ const ctx = makeCtx();
448
+ ctx.runtimeRegistry = makeRegistry(['openrouter', openrouterRuntime]);
449
+ const result = executeConfigAction({ type: 'modelSet', role: 'chat', model: 'OpenRouter' }, ctx);
450
+ expect(result.ok).toBe(true);
451
+ expect(ctx.botParams.runtime).toBe(openrouterRuntime);
452
+ expect(ctx.runtimeName).toBe('openrouter');
453
+ });
454
+ it('swap then set model name — runtime stays swapped, model changes', () => {
455
+ const ctx = makeCtx();
456
+ ctx.runtimeRegistry = makeRegistry(['openrouter', openrouterRuntime]);
457
+ // First swap runtime
458
+ executeConfigAction({ type: 'modelSet', role: 'chat', model: 'openrouter' }, ctx);
459
+ expect(ctx.botParams.runtime).toBe(openrouterRuntime);
460
+ // Then set a plain model name — runtime stays
461
+ executeConfigAction({ type: 'modelSet', role: 'chat', model: 'gpt-4o' }, ctx);
462
+ expect(ctx.botParams.runtime).toBe(openrouterRuntime);
463
+ expect(ctx.botParams.runtimeModel).toBe('gpt-4o');
464
+ });
465
+ it('propagation to syncCoordinator.setRuntime is called when present', () => {
466
+ const ctx = makeCtx();
467
+ ctx.runtimeRegistry = makeRegistry(['openrouter', openrouterRuntime]);
468
+ let capturedRuntime;
469
+ ctx.botParams.cronCtx.syncCoordinator = {
470
+ setAutoTagModel: () => { },
471
+ setRuntime: (rt) => { capturedRuntime = rt; },
472
+ };
473
+ executeConfigAction({ type: 'modelSet', role: 'chat', model: 'openrouter' }, ctx);
474
+ expect(capturedRuntime).toBe(openrouterRuntime);
475
+ });
476
+ });
477
+ // ---------------------------------------------------------------------------
478
+ // modelShow — runtime line
479
+ // ---------------------------------------------------------------------------
480
+ describe('modelShow runtime line', () => {
481
+ it('displays active runtime name', () => {
482
+ const ctx = makeCtx();
483
+ const result = executeConfigAction({ type: 'modelShow' }, ctx);
484
+ expect(result.ok).toBe(true);
485
+ if (!result.ok)
486
+ return;
487
+ expect(result.summary).toContain('runtime');
488
+ expect(result.summary).toContain('claude_code');
489
+ });
490
+ it('displays swapped runtime name after swap', () => {
491
+ const ctx = makeCtx();
492
+ ctx.runtimeRegistry = makeRegistry(['openrouter', openrouterRuntime]);
493
+ executeConfigAction({ type: 'modelSet', role: 'chat', model: 'openrouter' }, ctx);
494
+ const result = executeConfigAction({ type: 'modelShow' }, ctx);
495
+ expect(result.ok).toBe(true);
496
+ if (!result.ok)
497
+ return;
498
+ expect(result.summary).toContain('openrouter');
499
+ });
500
+ });
501
+ // ---------------------------------------------------------------------------
380
502
  // configActionsPromptSection
381
503
  // ---------------------------------------------------------------------------
382
504
  describe('configActionsPromptSection', () => {