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.
- package/.context/voice.md +30 -2
- package/.env.example +6 -0
- package/dist/cli/dashboard.js +7 -1
- package/dist/config.js +7 -0
- package/dist/cron/executor.js +72 -1
- package/dist/dashboard/api/metrics.js +7 -0
- package/dist/dashboard/api/metrics.test.js +16 -0
- package/dist/dashboard/api/traces.js +14 -0
- package/dist/dashboard/api/traces.test.js +40 -0
- package/dist/dashboard/page.js +187 -8
- package/dist/dashboard/server.js +81 -14
- package/dist/dashboard/server.test.js +120 -4
- package/dist/discord/deferred-runner.js +306 -219
- package/dist/discord/message-coordinator.js +1 -28
- package/dist/discord/reaction-handler.js +81 -3
- package/dist/index.js +15 -1
- package/dist/observability/trace-store.js +56 -0
- package/dist/observability/trace-utils.js +31 -0
- package/dist/runtime/codex-cli.js +3 -2
- package/dist/runtime/codex-cli.test.js +33 -0
- package/dist/runtime/model-tiers.js +1 -1
- package/dist/runtime/model-tiers.test.js +9 -0
- package/dist/runtime/openai-tool-schemas.js +17 -0
- package/dist/voice/audio-pipeline.js +246 -6
- package/dist/voice/audio-pipeline.test.js +481 -0
- package/dist/voice/audio-receiver.js +8 -0
- package/dist/voice/audio-receiver.test.js +16 -0
- package/dist/voice/conversation-buffer.js +16 -6
- package/dist/voice/providers/gemini-live-provider.js +481 -0
- package/dist/voice/providers/gemini-live-provider.test.js +834 -0
- package/dist/voice/providers/gemini-live-responder.js +267 -0
- package/dist/voice/providers/gemini-live-responder.test.js +615 -0
- package/dist/voice/providers/gemini-live-token-estimator.js +100 -0
- package/dist/voice/providers/gemini-live-token-estimator.test.js +160 -0
- package/dist/voice/providers/gemini-live-types.js +32 -0
- package/dist/voice/providers/gemini-tool-mapper.js +91 -0
- package/dist/voice/providers/gemini-tool-mapper.test.js +253 -0
- package/dist/voice/providers/index.js +3 -0
- package/dist/voice/types.test.js +6 -0
- package/dist/voice/voice-prompt-builder.js +26 -17
- package/dist/voice/voice-prompt-builder.test.js +16 -1
- package/package.json +1 -1
package/dist/dashboard/server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
507
|
-
|
|
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,
|
|
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,
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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:
|
|
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('
|
|
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,
|