discoclaw 1.2.4 → 1.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 (42) hide show
  1. package/.context/voice.md +30 -2
  2. package/.env.example +6 -0
  3. package/dist/cli/dashboard.js +7 -1
  4. package/dist/config.js +7 -0
  5. package/dist/cron/executor.js +72 -1
  6. package/dist/dashboard/api/metrics.js +7 -0
  7. package/dist/dashboard/api/metrics.test.js +16 -0
  8. package/dist/dashboard/api/traces.js +14 -0
  9. package/dist/dashboard/api/traces.test.js +40 -0
  10. package/dist/dashboard/page.js +187 -8
  11. package/dist/dashboard/server.js +81 -14
  12. package/dist/dashboard/server.test.js +120 -4
  13. package/dist/discord/deferred-runner.js +306 -219
  14. package/dist/discord/message-coordinator.js +1 -28
  15. package/dist/discord/reaction-handler.js +81 -3
  16. package/dist/index.js +15 -1
  17. package/dist/observability/trace-store.js +56 -0
  18. package/dist/observability/trace-utils.js +31 -0
  19. package/dist/runtime/codex-cli.js +3 -2
  20. package/dist/runtime/codex-cli.test.js +33 -0
  21. package/dist/runtime/model-tiers.js +1 -1
  22. package/dist/runtime/model-tiers.test.js +9 -0
  23. package/dist/runtime/openai-tool-schemas.js +17 -0
  24. package/dist/voice/audio-pipeline.js +246 -6
  25. package/dist/voice/audio-pipeline.test.js +481 -0
  26. package/dist/voice/audio-receiver.js +8 -0
  27. package/dist/voice/audio-receiver.test.js +16 -0
  28. package/dist/voice/conversation-buffer.js +16 -6
  29. package/dist/voice/providers/gemini-live-provider.js +481 -0
  30. package/dist/voice/providers/gemini-live-provider.test.js +834 -0
  31. package/dist/voice/providers/gemini-live-responder.js +267 -0
  32. package/dist/voice/providers/gemini-live-responder.test.js +615 -0
  33. package/dist/voice/providers/gemini-live-token-estimator.js +100 -0
  34. package/dist/voice/providers/gemini-live-token-estimator.test.js +160 -0
  35. package/dist/voice/providers/gemini-live-types.js +32 -0
  36. package/dist/voice/providers/gemini-tool-mapper.js +91 -0
  37. package/dist/voice/providers/gemini-tool-mapper.test.js +253 -0
  38. package/dist/voice/providers/index.js +3 -0
  39. package/dist/voice/types.test.js +6 -0
  40. package/dist/voice/voice-prompt-builder.js +26 -17
  41. package/dist/voice/voice-prompt-builder.test.js +16 -1
  42. package/package.json +1 -1
@@ -10,11 +10,15 @@ import { DASHBOARD_HOST, DEFAULT_DASHBOARD_PORT, formatDashboardUrl } from './op
10
10
  import { renderDashboardPage } from './page.js';
11
11
  import { buildSnapshotResponse } from './api/snapshot.js';
12
12
  import { buildSettingsGetResponse, buildSettingsPostResponse, } from './api/settings.js';
13
+ import { buildMetricsResponse } from './api/metrics.js';
14
+ import { buildTracesResponse } from './api/traces.js';
13
15
  import { hasErrorCode, mapListenError } from './server-errors.js';
14
16
  import { applyFixes, inspect, KNOWN_RUNTIMES, loadDoctorContext, updateEnvKey } from '../health/config-doctor.js';
15
17
  import { DEFAULTS as MODEL_DEFAULTS, saveModelConfig } from '../model-config.js';
16
18
  import { isModelTier } from '../runtime/model-tiers.js';
17
19
  import { saveOverrides } from '../runtime-overrides.js';
20
+ import { findRuntimeForModel } from '../runtime/model-tiers.js';
21
+ import { getRuntimePathDefinition } from '../runtime/runtime-path-contract.js';
18
22
  import { getPlatformCommands, getServiceLogs, getServiceStatus, normalizeServiceName, summarizeServiceStatus, } from '../service-control.js';
19
23
  const DASHBOARD_MODEL_ROLES = [
20
24
  'chat',
@@ -178,6 +182,9 @@ function normalizeRuntimeName(value, knownRuntimes) {
178
182
  const normalized = trimmed === 'claude_code' ? 'claude' : trimmed;
179
183
  return knownRuntimes.has(normalized) ? normalized : undefined;
180
184
  }
185
+ function presetToPrimaryRuntime(preset) {
186
+ return preset === 'codex' ? 'codex-cli' : 'claude-cli';
187
+ }
181
188
  async function loadServiceName(inspectOpts, deps) {
182
189
  const ctx = await deps.loadDoctorContext(inspectOpts);
183
190
  return normalizeServiceName(ctx.env.DISCOCLAW_SERVICE_NAME);
@@ -352,7 +359,8 @@ async function applyPreset(preset, inspectOpts, deps) {
352
359
  throw new Error(`Unknown preset: ${preset}. Allowed values: ${[...ALLOWED_PRESETS].join(', ')}`);
353
360
  }
354
361
  const ctx = await deps.loadDoctorContext(inspectOpts);
355
- await deps.updateEnvKey(ctx.configPaths.env, 'PRIMARY_RUNTIME', preset);
362
+ const primaryRuntime = presetToPrimaryRuntime(preset);
363
+ await deps.updateEnvKey(ctx.configPaths.env, 'PRIMARY_RUNTIME', primaryRuntime);
356
364
  const preservedOverrides = {};
357
365
  if (ctx.runtimeOverrides.ttsVoice) {
358
366
  preservedOverrides.ttsVoice = ctx.runtimeOverrides.ttsVoice;
@@ -361,7 +369,8 @@ async function applyPreset(preset, inspectOpts, deps) {
361
369
  await deps.saveModelConfig(ctx.configPaths.models, { ...MODEL_DEFAULTS });
362
370
  return {
363
371
  message: `Preset switched to ${preset}. Models reset to tier defaults. Restart the service to apply.`,
364
- snapshot: await collectDashboardSnapshot(inspectOpts, deps),
372
+ snapshot: await collectDashboardSnapshot({ cwd: inspectOpts.cwd, env: { ...inspectOpts.env, PRIMARY_RUNTIME: primaryRuntime } }, deps),
373
+ primaryRuntime,
365
374
  };
366
375
  }
367
376
  async function buildPresetResponse(input, inspectOpts, deps) {
@@ -373,6 +382,27 @@ async function buildPresetResponse(input, inspectOpts, deps) {
373
382
  ...await applyPreset(preset, inspectOpts, deps),
374
383
  };
375
384
  }
385
+ async function persistChatRuntimeSelection(runtimeName, inspectOpts, deps) {
386
+ const ctx = await deps.loadDoctorContext(inspectOpts);
387
+ await deps.updateEnvKey(ctx.configPaths.env, 'PRIMARY_RUNTIME', runtimeName);
388
+ let nextModels = ctx.models;
389
+ let clearedCrossRuntimeOverride = false;
390
+ const targetRuntimeId = getRuntimePathDefinition(runtimeName)?.runtimeId;
391
+ const savedChatModel = ctx.models['chat'];
392
+ const savedChatModelRuntimeId = savedChatModel ? findRuntimeForModel(savedChatModel) : undefined;
393
+ if (savedChatModel && targetRuntimeId && savedChatModelRuntimeId && savedChatModelRuntimeId !== targetRuntimeId) {
394
+ nextModels = updateModelConfig(nextModels, 'chat', null);
395
+ await deps.saveModelConfig(ctx.configPaths.models, nextModels);
396
+ clearedCrossRuntimeOverride = true;
397
+ }
398
+ return {
399
+ message: clearedCrossRuntimeOverride
400
+ ? `Saved startup chat runtime: ${runtimeName}. Cleared the saved chat model override because it targeted a different provider. Restart the service to apply.`
401
+ : `Saved startup chat runtime: ${runtimeName}. Restart the service to apply.`,
402
+ snapshot: await collectDashboardSnapshot({ cwd: inspectOpts.cwd, env: { ...inspectOpts.env, PRIMARY_RUNTIME: runtimeName } }, deps),
403
+ primaryRuntime: runtimeName,
404
+ };
405
+ }
376
406
  function isDashboardBadRequest(message) {
377
407
  return (message === 'Request body too large'
378
408
  || message === 'JSON body must be an object'
@@ -415,6 +445,11 @@ export async function startDashboardServer(opts = {}) {
415
445
  const liveAuthCheckHandler = opts.liveAuthCheckHandler;
416
446
  // Mutable flag — set when a persisted config change requires a restart to take effect.
417
447
  let pendingRestart = false;
448
+ const previewEnvOverrides = {};
449
+ const configInspectOpts = () => ({
450
+ cwd: inspectOpts.cwd,
451
+ env: { ...inspectOpts.env, ...previewEnvOverrides },
452
+ });
418
453
  const server = http.createServer(async (req, res) => {
419
454
  const method = req.method ?? 'GET';
420
455
  const requestHostname = parseHostHeaderHostname(req.headers.host);
@@ -422,14 +457,15 @@ export async function startDashboardServer(opts = {}) {
422
457
  respondJson(res, 403, { ok: false, message: DNS_REBIND_ERROR });
423
458
  return;
424
459
  }
425
- const pathname = new URL(req.url ?? '/', `http://${DASHBOARD_HOST}`).pathname;
460
+ const parsedUrl = new URL(req.url ?? '/', `http://${DASHBOARD_HOST}`);
461
+ const pathname = parsedUrl.pathname;
426
462
  try {
427
463
  if (method === 'GET' && pathname === '/') {
428
464
  respondHtml(res, 200, html);
429
465
  return;
430
466
  }
431
467
  if (method === 'GET' && pathname === '/api/snapshot') {
432
- respondJson(res, 200, withLiveSnapshot(withStartupMcpSnapshot(await buildSnapshotResponse(inspectOpts, deps), opts.startupMcpStatus, opts.startupMcpWarnings), opts.liveSnapshotProvider, pendingRestart));
468
+ respondJson(res, 200, withLiveSnapshot(withStartupMcpSnapshot(await buildSnapshotResponse(configInspectOpts(), deps), opts.startupMcpStatus, opts.startupMcpWarnings), opts.liveSnapshotProvider, pendingRestart));
433
469
  return;
434
470
  }
435
471
  if (method === 'GET' && pathname === '/api/status') {
@@ -463,7 +499,7 @@ export async function startDashboardServer(opts = {}) {
463
499
  return;
464
500
  }
465
501
  if (method === 'GET' && pathname === '/api/doctor') {
466
- respondJson(res, 200, await buildDoctorResponse(inspectOpts, deps));
502
+ respondJson(res, 200, await buildDoctorResponse(configInspectOpts(), deps));
467
503
  return;
468
504
  }
469
505
  if (pathname === '/api/doctor/fix') {
@@ -475,7 +511,7 @@ export async function startDashboardServer(opts = {}) {
475
511
  respondJson(res, 403, { ok: false, message: CROSS_ORIGIN_MUTATION_ERROR });
476
512
  return;
477
513
  }
478
- respondJson(res, 200, withLiveSnapshot(withStartupMcpSnapshot(await buildDoctorFixResponse(inspectOpts, deps), opts.startupMcpStatus, opts.startupMcpWarnings), opts.liveSnapshotProvider, pendingRestart));
514
+ respondJson(res, 200, withLiveSnapshot(withStartupMcpSnapshot(await buildDoctorFixResponse(configInspectOpts(), deps), opts.startupMcpStatus, opts.startupMcpWarnings), opts.liveSnapshotProvider, pendingRestart));
479
515
  return;
480
516
  }
481
517
  if (pathname === '/api/live-model') {
@@ -494,6 +530,7 @@ export async function startDashboardServer(opts = {}) {
494
530
  const body = await readJsonBody(req);
495
531
  const role = typeof body.role === 'string' ? body.role.trim() : '';
496
532
  const model = typeof body.model === 'string' ? body.model.trim() : '';
533
+ const persist = body.persist === true;
497
534
  if (!role)
498
535
  throw new Error('Model role is required.');
499
536
  if (!model)
@@ -503,8 +540,24 @@ export async function startDashboardServer(opts = {}) {
503
540
  respondJson(res, 400, { ok: false, message: result.error });
504
541
  return;
505
542
  }
506
- const snapshot = await collectDashboardSnapshot(inspectOpts, deps);
507
- respondJson(res, 200, withLiveSnapshot(withStartupMcpSnapshot({ ok: true, message: result.summary, snapshot }, opts.startupMcpStatus, opts.startupMcpWarnings), opts.liveSnapshotProvider, pendingRestart));
543
+ let message = result.summary;
544
+ let snapshot = await collectDashboardSnapshot(configInspectOpts(), deps);
545
+ if (persist && role === 'chat') {
546
+ const runtimeInput = normalizeRuntimeName(model, KNOWN_RUNTIMES);
547
+ if (runtimeInput) {
548
+ const persisted = await persistChatRuntimeSelection(runtimeInput, configInspectOpts(), deps);
549
+ previewEnvOverrides['PRIMARY_RUNTIME'] = persisted.primaryRuntime;
550
+ message = `${message} ${persisted.message}`;
551
+ snapshot = persisted.snapshot;
552
+ }
553
+ else {
554
+ const persisted = await applyModelChange({ role, model }, configInspectOpts(), deps, KNOWN_RUNTIMES);
555
+ message = `${message} ${persisted.message}`;
556
+ snapshot = persisted.snapshot;
557
+ }
558
+ pendingRestart = true;
559
+ }
560
+ respondJson(res, 200, withLiveSnapshot(withStartupMcpSnapshot({ ok: true, message, snapshot }, opts.startupMcpStatus, opts.startupMcpWarnings), opts.liveSnapshotProvider, pendingRestart));
508
561
  return;
509
562
  }
510
563
  if (pathname === '/api/model') {
@@ -517,7 +570,7 @@ export async function startDashboardServer(opts = {}) {
517
570
  return;
518
571
  }
519
572
  const body = await readJsonBody(req);
520
- const modelResponse = await buildModelResponse(body, inspectOpts, deps, KNOWN_RUNTIMES);
573
+ const modelResponse = await buildModelResponse(body, configInspectOpts(), deps, KNOWN_RUNTIMES);
521
574
  pendingRestart = true;
522
575
  respondJson(res, 200, withLiveSnapshot(withStartupMcpSnapshot(modelResponse, opts.startupMcpStatus, opts.startupMcpWarnings), opts.liveSnapshotProvider, pendingRestart));
523
576
  return;
@@ -532,7 +585,11 @@ export async function startDashboardServer(opts = {}) {
532
585
  return;
533
586
  }
534
587
  const body = await readJsonBody(req);
535
- const presetResponse = await buildPresetResponse(body, inspectOpts, deps);
588
+ const presetResponse = await buildPresetResponse(body, configInspectOpts(), deps);
589
+ const preset = typeof body.preset === 'string' ? body.preset.trim().toLowerCase() : '';
590
+ if (preset) {
591
+ previewEnvOverrides['PRIMARY_RUNTIME'] = presetToPrimaryRuntime(preset);
592
+ }
536
593
  pendingRestart = true;
537
594
  respondJson(res, 200, withLiveSnapshot(withStartupMcpSnapshot(presetResponse, opts.startupMcpStatus, opts.startupMcpWarnings), opts.liveSnapshotProvider, pendingRestart));
538
595
  return;
@@ -555,10 +612,11 @@ export async function startDashboardServer(opts = {}) {
555
612
  throw new Error(`Unknown secret key: ${key}`);
556
613
  if (!value)
557
614
  throw new Error('Secret value is required.');
558
- const ctx = await deps.loadDoctorContext(inspectOpts);
615
+ const ctx = await deps.loadDoctorContext(configInspectOpts());
559
616
  await deps.updateEnvKey(ctx.configPaths.env, key, value);
617
+ previewEnvOverrides[key] = value;
560
618
  pendingRestart = true;
561
- const snapshot = await collectDashboardSnapshot(inspectOpts, deps);
619
+ const snapshot = await collectDashboardSnapshot(configInspectOpts(), deps);
562
620
  respondJson(res, 200, withLiveSnapshot(withStartupMcpSnapshot({ ok: true, message: `Updated ${key}. Restart the service to apply.`, snapshot }, opts.startupMcpStatus, opts.startupMcpWarnings), opts.liveSnapshotProvider, pendingRestart));
563
621
  return;
564
622
  }
@@ -600,7 +658,7 @@ export async function startDashboardServer(opts = {}) {
600
658
  return;
601
659
  }
602
660
  if (method === 'GET' && pathname === '/api/settings') {
603
- respondJson(res, 200, buildSettingsGetResponse(inspectOpts.env));
661
+ respondJson(res, 200, buildSettingsGetResponse(configInspectOpts().env));
604
662
  return;
605
663
  }
606
664
  if (pathname === '/api/settings') {
@@ -615,11 +673,20 @@ export async function startDashboardServer(opts = {}) {
615
673
  const body = await readJsonBody(req);
616
674
  const key = typeof body.key === 'string' ? body.key.trim() : '';
617
675
  const value = typeof body.value === 'string' ? body.value.trim() : '';
618
- const response = await buildSettingsPostResponse(inspectOpts, deps, key, value);
676
+ const response = await buildSettingsPostResponse(configInspectOpts(), deps, key, value);
677
+ previewEnvOverrides[key] = value;
619
678
  pendingRestart = true;
620
679
  respondJson(res, 200, response);
621
680
  return;
622
681
  }
682
+ if (method === 'GET' && pathname === '/api/traces') {
683
+ respondJson(res, 200, buildTracesResponse(parsedUrl.searchParams.get('limit')));
684
+ return;
685
+ }
686
+ if (method === 'GET' && pathname === '/api/metrics') {
687
+ respondJson(res, 200, buildMetricsResponse());
688
+ return;
689
+ }
623
690
  respondJson(res, 404, { ok: false, message: 'Not found' });
624
691
  }
625
692
  catch (err) {
@@ -777,8 +777,16 @@ describe('startDashboardServer', () => {
777
777
  const updateEnvKeyMock = vi.fn(async () => undefined);
778
778
  const saveModelConfigMock = vi.fn(async () => undefined);
779
779
  const saveOverridesMock = vi.fn(async () => undefined);
780
+ const loadDoctorContextMock = vi.fn(async (opts) => makeDoctorContext({
781
+ runtimeOverrides: ctx.runtimeOverrides,
782
+ runtimeOverridesFile: ctx.runtimeOverridesFile,
783
+ env: {
784
+ ...ctx.env,
785
+ ...(opts?.env ?? {}),
786
+ },
787
+ }));
780
788
  const { port } = await startServer({
781
- loadDoctorContext: vi.fn(async () => ctx),
789
+ loadDoctorContext: loadDoctorContextMock,
782
790
  updateEnvKey: updateEnvKeyMock,
783
791
  saveModelConfig: saveModelConfigMock,
784
792
  saveOverrides: saveOverridesMock,
@@ -793,11 +801,11 @@ describe('startDashboardServer', () => {
793
801
  expect(body.ok).toBe(true);
794
802
  expect(body.message).toContain('codex');
795
803
  expect(body.message).toContain('tier defaults');
796
- expect(updateEnvKeyMock).toHaveBeenCalledWith('/repo/.env', 'PRIMARY_RUNTIME', 'codex');
804
+ expect(updateEnvKeyMock).toHaveBeenCalledWith('/repo/.env', 'PRIMARY_RUNTIME', 'codex-cli');
797
805
  expect(saveOverridesMock).toHaveBeenCalledWith('/repo/data/runtime-overrides.json', { ttsVoice: 'alloy' });
798
806
  expect(saveModelConfigMock).toHaveBeenCalledWith('/repo/data/models.json', expect.objectContaining({}));
799
807
  expect(body.snapshot).toBeDefined();
800
- expect(body.snapshot.primaryRuntime).toBe('claude');
808
+ expect(body.snapshot.primaryRuntime).toBe('codex');
801
809
  });
802
810
  it('applies claude preset correctly on /api/preset', async () => {
803
811
  const ctx = makeDoctorContext();
@@ -819,7 +827,7 @@ describe('startDashboardServer', () => {
819
827
  expect(response.status).toBe(200);
820
828
  expect(body.ok).toBe(true);
821
829
  expect(body.message).toContain('claude');
822
- expect(updateEnvKeyMock).toHaveBeenCalledWith('/repo/.env', 'PRIMARY_RUNTIME', 'claude');
830
+ expect(updateEnvKeyMock).toHaveBeenCalledWith('/repo/.env', 'PRIMARY_RUNTIME', 'claude-cli');
823
831
  });
824
832
  it('preserves ttsVoice when clearing overrides via /api/preset', async () => {
825
833
  const ctx = makeDoctorContext({
@@ -1125,6 +1133,114 @@ describe('startDashboardServer', () => {
1125
1133
  expect(body.snapshot).toBeDefined();
1126
1134
  expect(liveModelHandler).toHaveBeenCalledWith('chat', 'opus');
1127
1135
  });
1136
+ it('persists chat runtime changes requested from /api/live-model', async () => {
1137
+ const ctx = makeDoctorContext({
1138
+ models: {
1139
+ chat: 'claude-opus-4-6',
1140
+ },
1141
+ modelsFile: {
1142
+ exists: true,
1143
+ values: {
1144
+ chat: 'claude-opus-4-6',
1145
+ },
1146
+ },
1147
+ });
1148
+ const liveModelHandler = vi.fn(() => ({
1149
+ ok: true,
1150
+ summary: 'Model updated: runtime → codex-cli, chat → gpt-5.4 (adapter default)',
1151
+ }));
1152
+ const updateEnvKeyMock = vi.fn(async () => undefined);
1153
+ const saveModelConfigMock = vi.fn(async () => undefined);
1154
+ const loadDoctorContextMock = vi.fn(async (opts) => makeDoctorContext({
1155
+ models: ctx.models,
1156
+ modelsFile: ctx.modelsFile,
1157
+ env: {
1158
+ ...ctx.env,
1159
+ ...(opts?.env ?? {}),
1160
+ },
1161
+ }));
1162
+ const { port } = await startServer({
1163
+ loadDoctorContext: loadDoctorContextMock,
1164
+ updateEnvKey: updateEnvKeyMock,
1165
+ saveModelConfig: saveModelConfigMock,
1166
+ }, {
1167
+ liveModelHandler,
1168
+ liveSnapshotProvider: () => ({
1169
+ chatRuntime: 'codex-cli',
1170
+ chatModel: 'gpt-5.4',
1171
+ chatThinking: 'high',
1172
+ availableRuntimes: ['claude-cli', 'codex-cli'],
1173
+ pendingRestart: false,
1174
+ imagegenProvider: undefined,
1175
+ imagegenModel: undefined,
1176
+ imagegenOptions: [],
1177
+ imagegenHasGeminiKey: false,
1178
+ imagegenHasOpenaiKey: false,
1179
+ }),
1180
+ });
1181
+ const response = await makeRequest(port, {
1182
+ path: '/api/live-model',
1183
+ method: 'POST',
1184
+ body: JSON.stringify({ role: 'chat', model: 'codex-cli', persist: true }),
1185
+ });
1186
+ const body = parseJson(response.text);
1187
+ expect(response.status).toBe(200);
1188
+ expect(body.ok).toBe(true);
1189
+ expect(body.message).toContain('runtime → codex-cli');
1190
+ expect(body.message).toContain('Saved startup chat runtime: codex-cli.');
1191
+ expect(body.snapshot.primaryRuntime).toBe('codex');
1192
+ expect(body.snapshot.live?.pendingRestart).toBe(true);
1193
+ expect(updateEnvKeyMock).toHaveBeenCalledWith('/repo/.env', 'PRIMARY_RUNTIME', 'codex-cli');
1194
+ expect(saveModelConfigMock).toHaveBeenCalledWith('/repo/data/models.json', {});
1195
+ });
1196
+ it('persists chat model changes requested from /api/live-model', async () => {
1197
+ const ctx = makeDoctorContext({
1198
+ env: {
1199
+ DISCOCLAW_SERVICE_NAME: 'discoclaw-beta',
1200
+ PRIMARY_RUNTIME: 'codex-cli',
1201
+ },
1202
+ models: {},
1203
+ modelsFile: {
1204
+ exists: true,
1205
+ values: {},
1206
+ },
1207
+ });
1208
+ const liveModelHandler = vi.fn(() => ({
1209
+ ok: true,
1210
+ summary: 'Model updated: chat → gpt-5.4',
1211
+ }));
1212
+ const saveModelConfigMock = vi.fn(async () => undefined);
1213
+ const { port } = await startServer({
1214
+ loadDoctorContext: vi.fn(async () => ctx),
1215
+ saveModelConfig: saveModelConfigMock,
1216
+ }, {
1217
+ liveModelHandler,
1218
+ liveSnapshotProvider: () => ({
1219
+ chatRuntime: 'codex-cli',
1220
+ chatModel: 'gpt-5.4',
1221
+ chatThinking: 'high',
1222
+ availableRuntimes: ['claude-cli', 'codex-cli'],
1223
+ pendingRestart: false,
1224
+ imagegenProvider: undefined,
1225
+ imagegenModel: undefined,
1226
+ imagegenOptions: [],
1227
+ imagegenHasGeminiKey: false,
1228
+ imagegenHasOpenaiKey: false,
1229
+ }),
1230
+ });
1231
+ const response = await makeRequest(port, {
1232
+ path: '/api/live-model',
1233
+ method: 'POST',
1234
+ body: JSON.stringify({ role: 'chat', model: 'gpt-5.4', persist: true }),
1235
+ });
1236
+ const body = parseJson(response.text);
1237
+ expect(response.status).toBe(200);
1238
+ expect(body.ok).toBe(true);
1239
+ expect(body.message).toContain('Model updated: chat → gpt-5.4');
1240
+ expect(body.message).toContain('Saved chat override: gpt-5.4. Changes take effect on next service restart.');
1241
+ expect(body.snapshot.live?.pendingRestart).toBe(true);
1242
+ expect(saveModelConfigMock).toHaveBeenCalledWith('/repo/data/models.json', { chat: 'gpt-5.4' });
1243
+ });
1128
1244
  it('returns 400 when live model handler rejects the change', async () => {
1129
1245
  const liveModelHandler = vi.fn(() => ({
1130
1246
  ok: false,