@semalt-ai/code 1.8.5 → 1.19.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/.claude/settings.local.json +6 -1
- package/.github/workflows/ci.yml +69 -0
- package/CLAUDE.md +1584 -26
- package/README.md +147 -3
- package/examples/embed.js +74 -0
- package/index.js +251 -10
- package/lib/agent.js +711 -104
- package/lib/api.js +213 -49
- package/lib/args.js +74 -2
- package/lib/audit.js +23 -1
- package/lib/background.js +584 -0
- package/lib/checkpoints.js +757 -0
- package/lib/commands/auth.js +94 -0
- package/lib/commands/chat-session.js +306 -0
- package/lib/commands/chat-slash.js +399 -0
- package/lib/commands/chat-turn.js +446 -0
- package/lib/commands/chat.js +403 -0
- package/lib/commands/custom.js +157 -0
- package/lib/commands/history-utils.js +66 -0
- package/lib/commands/index.js +268 -0
- package/lib/commands/mcp.js +113 -0
- package/lib/commands/oneshot.js +193 -0
- package/lib/commands/registry.js +269 -0
- package/lib/commands/tasks.js +89 -0
- package/lib/compact.js +87 -0
- package/lib/config.js +333 -11
- package/lib/constants.js +372 -3
- package/lib/deny.js +199 -0
- package/lib/doctor.js +160 -0
- package/lib/headless.js +167 -0
- package/lib/hooks.js +286 -0
- package/lib/images.js +264 -0
- package/lib/internals.js +49 -0
- package/lib/mcp/boundary.js +131 -0
- package/lib/mcp/client.js +270 -0
- package/lib/mcp/oauth.js +134 -0
- package/lib/memory.js +209 -0
- package/lib/metrics.js +37 -2
- package/lib/payload.js +54 -0
- package/lib/permission-rules.js +401 -0
- package/lib/permissions.js +100 -10
- package/lib/pricing.js +67 -0
- package/lib/proc.js +62 -0
- package/lib/prompts.js +84 -5
- package/lib/sandbox.js +568 -0
- package/lib/sdk.js +328 -0
- package/lib/secrets.js +211 -0
- package/lib/skills.js +223 -0
- package/lib/subagents.js +516 -0
- package/lib/tool_registry.js +2558 -0
- package/lib/tool_specs.js +222 -2
- package/lib/tools.js +272 -1020
- package/lib/ui/format.js +22 -1
- package/lib/ui/input-field.js +16 -7
- package/lib/ui/status-bar.js +79 -11
- package/lib/ui/theme.js +1 -0
- package/lib/ui/web-activity.js +218 -0
- package/lib/verify.js +229 -0
- package/lib/web-extract.js +213 -0
- package/lib/web-summarize.js +68 -0
- package/package.json +19 -4
- package/scripts/lint.js +57 -0
- package/test/agent-loop.test.js +389 -0
- package/test/background.test.js +414 -0
- package/test/chat.test.js +114 -0
- package/test/checkpoints-agent.test.js +181 -0
- package/test/checkpoints.test.js +650 -0
- package/test/command-registry.test.js +160 -0
- package/test/compact.test.js +116 -0
- package/test/completion-lazy.test.js +52 -0
- package/test/config-merge.test.js +324 -0
- package/test/config-quarantine.test.js +128 -0
- package/test/config-write-guard-allow-anywhere.test.js +56 -0
- package/test/config-write-guard-skip.test.js +46 -0
- package/test/config-write-guard.test.js +153 -0
- package/test/context-split.test.js +215 -0
- package/test/cost-doctor.test.js +142 -0
- package/test/custom-commands-chat.test.js +106 -0
- package/test/custom-commands.test.js +230 -0
- package/test/deny-windows.test.js +120 -0
- package/test/deny.test.js +83 -0
- package/test/download-allow-anywhere.test.js +66 -0
- package/test/download-confine.test.js +153 -0
- package/test/executors.test.js +362 -0
- package/test/extract-tool-calls.test.js +315 -0
- package/test/fetch-url-validation.test.js +219 -0
- package/test/fixtures/tool-calls.js +57 -0
- package/test/fixtures/web-page.js +91 -0
- package/test/git-tools.test.js +384 -0
- package/test/grep-glob-serialize.test.js +242 -0
- package/test/grep-glob.test.js +268 -0
- package/test/harness/README.md +57 -0
- package/test/harness/chat-harness.js +142 -0
- package/test/harness/memwarn-headless-child.js +65 -0
- package/test/harness/mock-llm.js +120 -0
- package/test/harness/mock-mcp-server.js +142 -0
- package/test/harness/sse-server.js +69 -0
- package/test/headless.test.js +203 -0
- package/test/history-utils.test.js +88 -0
- package/test/hooks-agent.test.js +238 -0
- package/test/hooks-verify-sandbox.test.js +232 -0
- package/test/hooks.test.js +216 -0
- package/test/http-get-user-agent.test.js +142 -0
- package/test/images-api.test.js +208 -0
- package/test/images.test.js +238 -0
- package/test/max-iterations.test.js +216 -0
- package/test/mcp-boundary.test.js +57 -0
- package/test/mcp-client.test.js +267 -0
- package/test/mcp-oauth.test.js +86 -0
- package/test/memory-truncation-warning.test.js +222 -0
- package/test/memory.test.js +198 -0
- package/test/native-dispatch.test.js +356 -0
- package/test/output-chokepoint.test.js +188 -0
- package/test/path-guards.test.js +134 -0
- package/test/payload.test.js +99 -0
- package/test/permission-rules-agent.test.js +210 -0
- package/test/permission-rules.test.js +297 -0
- package/test/permissions.test.js +163 -0
- package/test/plan-mode.test.js +167 -0
- package/test/read-paginate.test.js +275 -0
- package/test/readonly-tools.test.js +177 -0
- package/test/result-cap.test.js +233 -0
- package/test/sandbox-agent.test.js +147 -0
- package/test/sandbox-integration.test.js +216 -0
- package/test/sandbox.test.js +408 -0
- package/test/sdk.test.js +234 -0
- package/test/shell-output-cap.test.js +181 -0
- package/test/skills-chat.test.js +110 -0
- package/test/skills.test.js +295 -0
- package/test/smoke.test.js +68 -0
- package/test/status-bar-pause.test.js +164 -0
- package/test/stream-parser.test.js +147 -0
- package/test/subagents-agent.test.js +178 -0
- package/test/subagents.test.js +222 -0
- package/test/tool-registry.test.js +85 -0
- package/test/trim-budget.test.js +101 -0
- package/test/verify-agent.test.js +317 -0
- package/test/verify.test.js +141 -0
- package/test/web-activity-ordering.test.js +194 -0
- package/test/web-activity.test.js +207 -0
- package/test/web-data-extraction-guidance.test.js +71 -0
- package/test/web-extract.test.js +185 -0
- package/test/web-fetch-agent.test.js +291 -0
- package/test/web-fetch-mode.test.js +193 -0
- package/test/web-search.test.js +380 -0
- package/lib/commands.js +0 -1438
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Characterization tests for the slash-command registry (Task 1.3). These lock
|
|
4
|
+
// in the command SET, the /help text, and the resolve(text) → command dispatch
|
|
5
|
+
// so the registry refactor is provably behavior-preserving versus the former
|
|
6
|
+
// hardcoded if-chain + SLASH_CMDS array.
|
|
7
|
+
|
|
8
|
+
const { test } = require('node:test');
|
|
9
|
+
const assert = require('node:assert');
|
|
10
|
+
|
|
11
|
+
const { resolveCommand, completionNames, helpText, commandNames } = require('../lib/commands/registry');
|
|
12
|
+
|
|
13
|
+
// The exact completion set the hardcoded SLASH_CMDS array carried before 1.3.
|
|
14
|
+
const ORIGINAL_COMPLETION = [
|
|
15
|
+
'/help', '/file', '/new', '/model', '/models', '/shell', '/compact',
|
|
16
|
+
'/clear', '/approve', '/debug', '/config', '/history', '/login', '/whoami', '/logout', '/chats',
|
|
17
|
+
'/memory', // added in Task 2.3 (project memory)
|
|
18
|
+
'/plan', // added in Task 2.5 (plan mode)
|
|
19
|
+
'/doctor', // added in Task 2.6 (self-diagnostics)
|
|
20
|
+
'/mcp', // added in Task 3.3 (MCP servers)
|
|
21
|
+
'/skills', // added in Task 3.5 (skills)
|
|
22
|
+
'/rewind', // added in Task 4.3 (checkpoints & rewind)
|
|
23
|
+
'/sandbox', // added in Task 4.4 (OS sandbox status)
|
|
24
|
+
'/image', // added in Task 5.4 (multimodal image input)
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
// The exact /help body the chain emitted before 1.3.
|
|
28
|
+
const ORIGINAL_HELP = [
|
|
29
|
+
'Commands:',
|
|
30
|
+
' /file <path> Load file or dir into context',
|
|
31
|
+
' /image <path> Attach an image (PNG/JPEG/WebP/GIF) to your next message',
|
|
32
|
+
' /history Browse local sessions',
|
|
33
|
+
' /chats Browse saved dashboard chats',
|
|
34
|
+
' /new Start fresh conversation',
|
|
35
|
+
' /login Authorize via browser',
|
|
36
|
+
' /whoami Show current user',
|
|
37
|
+
' /logout Clear CLI login',
|
|
38
|
+
' /model Show current model',
|
|
39
|
+
' /model <name> Switch model manually',
|
|
40
|
+
' /models Choose from dashboard models',
|
|
41
|
+
' /clear Clear conversation',
|
|
42
|
+
' /compact Show token usage',
|
|
43
|
+
' /shell <cmd> Run shell command',
|
|
44
|
+
' !<cmd> Run shell command',
|
|
45
|
+
' /approve Toggle auto-approve',
|
|
46
|
+
' /debug [off] Enable debug output + show last 5 audit entries',
|
|
47
|
+
' /config Show config',
|
|
48
|
+
' /memory Show loaded project memory files',
|
|
49
|
+
' /mcp Show MCP server connection status and tools',
|
|
50
|
+
' /skills List available skills (bodies load on invocation)',
|
|
51
|
+
' /plan Toggle plan mode (withhold changes until approved)',
|
|
52
|
+
' /rewind List file checkpoints (file changes only — shell not reversible)',
|
|
53
|
+
' /rewind <seq> [code|conversation|both] Restore files and/or conversation (default both; add "force" to override out-of-band edits)',
|
|
54
|
+
' /doctor Run self-diagnostics (config, dashboard, model, audit, key, memory)',
|
|
55
|
+
' /sandbox Show OS sandbox status (mode, tool, availability, network)',
|
|
56
|
+
' exit Quit',
|
|
57
|
+
].join('\n');
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Command set / help / completion parity
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
test('completion set is unchanged from the old hardcoded SLASH_CMDS', () => {
|
|
64
|
+
assert.deepStrictEqual([...completionNames()].sort(), [...ORIGINAL_COMPLETION].sort());
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('input-field SLASH_CMDS is now sourced from the registry', () => {
|
|
68
|
+
const { SLASH_CMDS } = require('../lib/ui/input-field');
|
|
69
|
+
assert.deepStrictEqual(SLASH_CMDS, completionNames());
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('/help text is byte-for-byte identical to the old hardcoded help', () => {
|
|
73
|
+
assert.strictEqual(helpText(), ORIGINAL_HELP);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('every command name is unique', () => {
|
|
77
|
+
const names = commandNames();
|
|
78
|
+
assert.strictEqual(new Set(names).size, names.length);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Dispatch resolution — exact, alias, optional-arg, required-arg, bang
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
const EXACT = ['/help', '/history', '/chats', '/new', '/login', '/whoami', '/logout', '/models', '/clear', '/compact', '/config', '/memory', '/mcp', '/skills', '/plan', '/doctor', '/approve', '/prompt'];
|
|
86
|
+
for (const name of EXACT) {
|
|
87
|
+
test(`exact command resolves: ${name}`, () => {
|
|
88
|
+
const r = resolveCommand(name);
|
|
89
|
+
assert.ok(r, `${name} should resolve`);
|
|
90
|
+
assert.strictEqual(r.name, name);
|
|
91
|
+
assert.strictEqual(r.arg, '');
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
test('alias /cost resolves to /compact', () => {
|
|
96
|
+
assert.strictEqual(resolveCommand('/cost').name, '/compact');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('exit and its aliases resolve to exit (case-insensitive)', () => {
|
|
100
|
+
for (const t of ['exit', 'quit', '/exit', '/quit', 'EXIT', 'Quit']) {
|
|
101
|
+
assert.strictEqual(resolveCommand(t).name, 'exit', `${t} should resolve to exit`);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('required-arg command: /file resolves with its argument', () => {
|
|
106
|
+
const r = resolveCommand('/file a/b.js');
|
|
107
|
+
assert.strictEqual(r.name, '/file');
|
|
108
|
+
assert.strictEqual(r.arg, 'a/b.js');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('bare /file (no arg) is NOT a command (falls through to the agent)', () => {
|
|
112
|
+
assert.strictEqual(resolveCommand('/file'), null);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('optional-arg command: /model resolves both bare and with an argument', () => {
|
|
116
|
+
assert.strictEqual(resolveCommand('/model').name, '/model');
|
|
117
|
+
assert.strictEqual(resolveCommand('/model').arg, '');
|
|
118
|
+
assert.strictEqual(resolveCommand('/model gpt-4o').name, '/model');
|
|
119
|
+
assert.strictEqual(resolveCommand('/model gpt-4o').arg, 'gpt-4o');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('optional-arg command: /debug resolves bare and with off', () => {
|
|
123
|
+
assert.strictEqual(resolveCommand('/debug').arg, '');
|
|
124
|
+
assert.strictEqual(resolveCommand('/debug off').arg, 'off');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('/models is not shadowed by the /model prefix', () => {
|
|
128
|
+
assert.strictEqual(resolveCommand('/models').name, '/models');
|
|
129
|
+
assert.strictEqual(resolveCommand('/models').arg, '');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('bang shorthand and /shell both resolve to /shell with the command as arg', () => {
|
|
133
|
+
assert.strictEqual(resolveCommand('!ls -la').name, '/shell');
|
|
134
|
+
assert.strictEqual(resolveCommand('!ls -la').arg, 'ls -la');
|
|
135
|
+
assert.strictEqual(resolveCommand('/shell echo hi').name, '/shell');
|
|
136
|
+
assert.strictEqual(resolveCommand('/shell echo hi').arg, 'echo hi');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('bare /shell (no arg) is NOT a command', () => {
|
|
140
|
+
assert.strictEqual(resolveCommand('/shell'), null);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('argument whitespace is trimmed', () => {
|
|
144
|
+
assert.strictEqual(resolveCommand('/file spaced.txt ').arg, 'spaced.txt');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Non-commands fall through
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
test('ordinary messages and unknown slashes are not commands', () => {
|
|
152
|
+
for (const t of ['hello world', '/unknown', '/hel', 'modelx', '', ' ']) {
|
|
153
|
+
assert.strictEqual(resolveCommand(t), null, `${JSON.stringify(t)} should not resolve`);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('non-string input returns null', () => {
|
|
158
|
+
assert.strictEqual(resolveCommand(null), null);
|
|
159
|
+
assert.strictEqual(resolveCommand(undefined), null);
|
|
160
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Compaction tests (Task 2.7): the pure selection/replacement logic, the
|
|
4
|
+
// auto-compaction decision, and the real /compact handler driven through the
|
|
5
|
+
// chat harness with a scripted summary.
|
|
6
|
+
|
|
7
|
+
const { test } = require('node:test');
|
|
8
|
+
const assert = require('node:assert');
|
|
9
|
+
|
|
10
|
+
const {
|
|
11
|
+
selectForCompaction, summarizationRequest, buildCompactedMessages,
|
|
12
|
+
approxTokens, shouldAutoCompact, DEFAULT_KEEP_RECENT,
|
|
13
|
+
} = require('../lib/compact');
|
|
14
|
+
const { startChat } = require('./harness/chat-harness');
|
|
15
|
+
|
|
16
|
+
const est = (s) => Math.ceil((s || '').length / 4);
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Pure selection / replacement
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
test('selectForCompaction keeps the recent tail and summarizes the head', () => {
|
|
23
|
+
const msgs = Array.from({ length: 10 }, (_, i) => ({ role: 'user', content: `m${i}` }));
|
|
24
|
+
const { head, tail, pinned } = selectForCompaction(msgs, { keepRecent: 4 });
|
|
25
|
+
assert.strictEqual(head.length, 6);
|
|
26
|
+
assert.strictEqual(tail.length, 4);
|
|
27
|
+
assert.deepStrictEqual(tail.map((m) => m.content), ['m6', 'm7', 'm8', 'm9']);
|
|
28
|
+
assert.strictEqual(pinned.length, 0);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('selectForCompaction does nothing when history is within keepRecent', () => {
|
|
32
|
+
const msgs = [{ role: 'user', content: 'a' }, { role: 'user', content: 'b' }];
|
|
33
|
+
const { head, tail } = selectForCompaction(msgs, { keepRecent: 6 });
|
|
34
|
+
assert.strictEqual(head.length, 0);
|
|
35
|
+
assert.strictEqual(tail.length, 2);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('pinned messages survive in pinned, not head', () => {
|
|
39
|
+
const msgs = Array.from({ length: 8 }, (_, i) => ({ role: 'user', content: `m${i}`, pin: i === 0 }));
|
|
40
|
+
const { head, pinned } = selectForCompaction(msgs, { keepRecent: 4, isPinned: (m) => m.pin });
|
|
41
|
+
assert.deepStrictEqual(pinned.map((m) => m.content), ['m0']);
|
|
42
|
+
assert.ok(!head.some((m) => m.pin), 'pinned message not in the summarized head');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('buildCompactedMessages = pinned + summary + tail, and drops the head', () => {
|
|
46
|
+
const sel = { pinned: [{ role: 'user', content: 'PIN' }], tail: [{ role: 'user', content: 'recent' }] };
|
|
47
|
+
const out = buildCompactedMessages(sel, 'THE SUMMARY');
|
|
48
|
+
assert.strictEqual(out.length, 3);
|
|
49
|
+
assert.strictEqual(out[0].content, 'PIN');
|
|
50
|
+
assert.ok(/\[Summary of earlier conversation\]/.test(out[1].content));
|
|
51
|
+
assert.ok(/THE SUMMARY/.test(out[1].content));
|
|
52
|
+
assert.strictEqual(out[2].content, 'recent');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('compaction reduces the approximate token count', () => {
|
|
56
|
+
const msgs = Array.from({ length: 12 }, (_, i) => ({ role: 'user', content: 'x'.repeat(400) + i }));
|
|
57
|
+
const before = approxTokens(msgs, est);
|
|
58
|
+
const sel = selectForCompaction(msgs, { keepRecent: 4 });
|
|
59
|
+
const out = buildCompactedMessages(sel, 'short summary');
|
|
60
|
+
const after = approxTokens(out, est);
|
|
61
|
+
assert.ok(after < before, `compaction shrinks context (${before} → ${after})`);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('summarizationRequest produces a system+user pair carrying the transcript', () => {
|
|
65
|
+
const req = summarizationRequest([{ role: 'user', content: 'hello' }, { role: 'assistant', content: 'hi' }]);
|
|
66
|
+
assert.strictEqual(req.length, 2);
|
|
67
|
+
assert.strictEqual(req[0].role, 'system');
|
|
68
|
+
assert.ok(/USER: hello/.test(req[1].content) && /ASSISTANT: hi/.test(req[1].content));
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('shouldAutoCompact triggers only past the ratio with a known limit and enough history', () => {
|
|
72
|
+
assert.strictEqual(shouldAutoCompact(900, 1000, 20), true);
|
|
73
|
+
assert.strictEqual(shouldAutoCompact(500, 1000, 20), false, 'below ratio');
|
|
74
|
+
assert.strictEqual(shouldAutoCompact(900, null, 20), false, 'no known limit');
|
|
75
|
+
assert.strictEqual(shouldAutoCompact(900, 1000, DEFAULT_KEEP_RECENT, 20), false, 'too little history');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// /compact handler (real summarization through the harness)
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
test('/compact summarizes older messages and shrinks the history', async () => {
|
|
83
|
+
const c = await startChat({
|
|
84
|
+
config: { auth_token: 'tok' },
|
|
85
|
+
apiClient: { chatSync: async () => 'SUMMARY_OF_OLD' },
|
|
86
|
+
});
|
|
87
|
+
try {
|
|
88
|
+
for (let i = 0; i < 8; i++) await c.submit(`msg ${i}`); // 8 user messages > keepRecent(6)
|
|
89
|
+
await c.submit('/compact');
|
|
90
|
+
assert.ok(c.chatHistory.find(/Compacted 2 older message\(s\)/), 'before/after report shown');
|
|
91
|
+
|
|
92
|
+
// Next turn must run on the compacted history: summary first, old head gone.
|
|
93
|
+
await c.submit('after compact');
|
|
94
|
+
const last = c.calls.runAgentLoop[c.calls.runAgentLoop.length - 1];
|
|
95
|
+
const contents = last.messages.map((m) => m.content);
|
|
96
|
+
assert.ok(/\[Summary of earlier conversation\]/.test(contents[0]) && /SUMMARY_OF_OLD/.test(contents[0]), 'summary leads the history');
|
|
97
|
+
assert.ok(!contents.some((ct) => ct === 'msg 0' || ct === 'msg 1'), 'oldest messages were summarized away');
|
|
98
|
+
assert.ok(contents.includes('msg 2'), 'recent tail preserved');
|
|
99
|
+
} finally {
|
|
100
|
+
await c.submit('exit'); await c.done; c.cleanup();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('/compact with too little history reports nothing to compact', async () => {
|
|
105
|
+
const c = await startChat({
|
|
106
|
+
config: { auth_token: 'tok' },
|
|
107
|
+
apiClient: { chatSync: async () => 'unused' },
|
|
108
|
+
});
|
|
109
|
+
try {
|
|
110
|
+
await c.submit('only one');
|
|
111
|
+
await c.submit('/compact');
|
|
112
|
+
assert.ok(c.chatHistory.find(/Nothing to compact/), 'no-op message shown');
|
|
113
|
+
} finally {
|
|
114
|
+
await c.submit('exit'); await c.done; c.cleanup();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Lazy completion source (Task 3.1 follow-up). lib/ui/input-field.js used to
|
|
4
|
+
// snapshot completionNames() at module load, so custom commands registered at
|
|
5
|
+
// session start never reached tab-completion. These tests lock in that the
|
|
6
|
+
// completion source is resolved live — both the exported SLASH_CMDS and the
|
|
7
|
+
// InputField's own search sources reflect commands registered after load — while
|
|
8
|
+
// the built-in set is unchanged.
|
|
9
|
+
|
|
10
|
+
const { test } = require('node:test');
|
|
11
|
+
const assert = require('node:assert');
|
|
12
|
+
|
|
13
|
+
const { completionNames, registerCustomCommands, clearCustomCommands } = require('../lib/commands/registry');
|
|
14
|
+
const inputFieldModule = require('../lib/ui/input-field');
|
|
15
|
+
const { InputField } = inputFieldModule;
|
|
16
|
+
|
|
17
|
+
test('built-in completion is unchanged at module load (no customs registered)', () => {
|
|
18
|
+
clearCustomCommands();
|
|
19
|
+
assert.deepStrictEqual(inputFieldModule.SLASH_CMDS, completionNames());
|
|
20
|
+
assert.ok(inputFieldModule.SLASH_CMDS.includes('/help'));
|
|
21
|
+
assert.ok(!inputFieldModule.SLASH_CMDS.includes('/deploy'));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('a custom command registered after module load appears in the completion source', () => {
|
|
25
|
+
clearCustomCommands();
|
|
26
|
+
try {
|
|
27
|
+
registerCustomCommands([{ name: '/deploy', template: 'Deploy $ARGUMENTS', source: 'global' }]);
|
|
28
|
+
|
|
29
|
+
// Exported SLASH_CMDS is a live view, not a load-time snapshot.
|
|
30
|
+
assert.ok(inputFieldModule.SLASH_CMDS.includes('/deploy'), 'export reflects live registration');
|
|
31
|
+
|
|
32
|
+
// The InputField's search sources (driving fuzzy search + tab) include it.
|
|
33
|
+
const field = new InputField(null, null);
|
|
34
|
+
const sources = field._getSearchSources();
|
|
35
|
+
assert.ok(
|
|
36
|
+
sources.some((s) => s.type === 'command' && s.text === '/deploy'),
|
|
37
|
+
'search sources include the custom command',
|
|
38
|
+
);
|
|
39
|
+
// Built-ins are still present alongside the custom.
|
|
40
|
+
assert.ok(sources.some((s) => s.type === 'command' && s.text === '/help'));
|
|
41
|
+
} finally {
|
|
42
|
+
clearCustomCommands();
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('clearing custom commands removes them from the completion source', () => {
|
|
47
|
+
clearCustomCommands();
|
|
48
|
+
registerCustomCommands([{ name: '/temp', template: 't' }]);
|
|
49
|
+
assert.ok(inputFieldModule.SLASH_CMDS.includes('/temp'));
|
|
50
|
+
clearCustomCommands();
|
|
51
|
+
assert.ok(!inputFieldModule.SLASH_CMDS.includes('/temp'), 'completion source drops cleared customs');
|
|
52
|
+
});
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Tests-first for the layered config hierarchy (Task 2.2):
|
|
4
|
+
// user (~/.semalt-ai/config.json) → project (.semalt/config.json, found by
|
|
5
|
+
// walking up to the repo root) → environment variables → CLI flags
|
|
6
|
+
// lowest-to-highest precedence. The merge is factored into pure functions so
|
|
7
|
+
// every layer combination is unit-testable without touching the filesystem.
|
|
8
|
+
|
|
9
|
+
const os = require('node:os');
|
|
10
|
+
const fs = require('node:fs');
|
|
11
|
+
const path = require('node:path');
|
|
12
|
+
|
|
13
|
+
// CONFIG_PATH is computed from os.homedir() at module load — redirect HOME to a
|
|
14
|
+
// temp dir BEFORE requiring any lib module so the user-config layer is isolated.
|
|
15
|
+
const TMP_HOME = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'semalt-cfghome-')));
|
|
16
|
+
const PREV_HOME = process.env.HOME;
|
|
17
|
+
const PREV_USERPROFILE = process.env.USERPROFILE;
|
|
18
|
+
process.env.HOME = TMP_HOME;
|
|
19
|
+
process.env.USERPROFILE = TMP_HOME;
|
|
20
|
+
|
|
21
|
+
const { test, before, after, beforeEach } = require('node:test');
|
|
22
|
+
const assert = require('node:assert');
|
|
23
|
+
|
|
24
|
+
const { DEFAULT_CONFIG, CONFIG_PATH } = require('../lib/constants');
|
|
25
|
+
const {
|
|
26
|
+
mergeConfigLayers,
|
|
27
|
+
envConfigLayer,
|
|
28
|
+
flagsConfigLayer,
|
|
29
|
+
findProjectConfigPath,
|
|
30
|
+
loadProjectConfig,
|
|
31
|
+
loadConfig,
|
|
32
|
+
loadUserConfig,
|
|
33
|
+
configSet,
|
|
34
|
+
userLayerForPersist,
|
|
35
|
+
} = require('../lib/config');
|
|
36
|
+
|
|
37
|
+
after(() => {
|
|
38
|
+
if (PREV_HOME === undefined) delete process.env.HOME; else process.env.HOME = PREV_HOME;
|
|
39
|
+
if (PREV_USERPROFILE === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = PREV_USERPROFILE;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function writeUserConfig(obj) {
|
|
43
|
+
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
|
|
44
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(obj));
|
|
45
|
+
}
|
|
46
|
+
function clearUserConfig() {
|
|
47
|
+
try { fs.unlinkSync(CONFIG_PATH); } catch {}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// mergeConfigLayers — pure, the precedence engine
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
test('mergeConfigLayers: a later layer overrides an earlier one', () => {
|
|
55
|
+
const merged = mergeConfigLayers([
|
|
56
|
+
{ api_base: 'http://user', temperature: 0.1 },
|
|
57
|
+
{ api_base: 'http://project' },
|
|
58
|
+
{ api_base: 'http://env' },
|
|
59
|
+
{ api_base: 'http://flags' },
|
|
60
|
+
]);
|
|
61
|
+
assert.strictEqual(merged.api_base, 'http://flags');
|
|
62
|
+
assert.strictEqual(merged.temperature, 0.1, 'unshadowed lower-layer keys survive');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('mergeConfigLayers: null/undefined layers are skipped', () => {
|
|
66
|
+
const merged = mergeConfigLayers([null, { default_model: 'm' }, undefined, {}]);
|
|
67
|
+
assert.strictEqual(merged.default_model, 'm');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('mergeConfigLayers: undefined values within a layer do not clobber', () => {
|
|
71
|
+
const merged = mergeConfigLayers([
|
|
72
|
+
{ default_model: 'keep' },
|
|
73
|
+
{ default_model: undefined },
|
|
74
|
+
]);
|
|
75
|
+
assert.strictEqual(merged.default_model, 'keep');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('mergeConfigLayers: empty input yields the normalized defaults', () => {
|
|
79
|
+
const merged = mergeConfigLayers([]);
|
|
80
|
+
assert.strictEqual(merged.api_base, DEFAULT_CONFIG.api_base);
|
|
81
|
+
assert.strictEqual(merged.temperature, DEFAULT_CONFIG.temperature);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// mcp config scaffold (Task 3.2) — always normalized to { servers: {}, ... }.
|
|
86
|
+
// Task W.8 adds max_result_tokens (the stricter MCP result cap; default applied).
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
const { DEFAULT_MCP_MAX_RESULT_TOKENS } = require('../lib/constants');
|
|
90
|
+
|
|
91
|
+
test('mcp scaffold: default is an empty servers map (with the default result cap)', () => {
|
|
92
|
+
const merged = mergeConfigLayers([]);
|
|
93
|
+
assert.deepStrictEqual(merged.mcp, { servers: {}, max_result_tokens: DEFAULT_MCP_MAX_RESULT_TOKENS });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('mcp scaffold: a valid servers map is preserved, unknown keys are dropped', () => {
|
|
97
|
+
const merged = mergeConfigLayers([
|
|
98
|
+
{ mcp: { servers: { fs: { command: 'mcp-fs' } }, junk: 1 } },
|
|
99
|
+
]);
|
|
100
|
+
assert.deepStrictEqual(merged.mcp, {
|
|
101
|
+
servers: { fs: { command: 'mcp-fs' } },
|
|
102
|
+
max_result_tokens: DEFAULT_MCP_MAX_RESULT_TOKENS,
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('mcp scaffold: a valid max_result_tokens is preserved', () => {
|
|
107
|
+
const merged = mergeConfigLayers([{ mcp: { servers: {}, max_result_tokens: 4321 } }]);
|
|
108
|
+
assert.strictEqual(merged.mcp.max_result_tokens, 4321);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('mcp scaffold: malformed mcp values coerce to the empty scaffold', () => {
|
|
112
|
+
for (const bad of ['nope', 42, ['a'], null, { servers: 'x' }, { servers: ['a'] }]) {
|
|
113
|
+
assert.deepStrictEqual(mergeConfigLayers([{ mcp: bad }]).mcp, {
|
|
114
|
+
servers: {}, max_result_tokens: DEFAULT_MCP_MAX_RESULT_TOKENS,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('mcp scaffold: a malformed max_result_tokens falls back to the default', () => {
|
|
120
|
+
for (const bad of ['x', 0, -5, null]) {
|
|
121
|
+
assert.strictEqual(
|
|
122
|
+
mergeConfigLayers([{ mcp: { servers: {}, max_result_tokens: bad } }]).mcp.max_result_tokens,
|
|
123
|
+
DEFAULT_MCP_MAX_RESULT_TOKENS,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// envConfigLayer — pure (takes the env object)
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
test('envConfigLayer: maps the supported variables', () => {
|
|
133
|
+
const layer = envConfigLayer({
|
|
134
|
+
SEMALT_API_BASE: 'http://env-base',
|
|
135
|
+
SEMALT_MODEL: 'env-model',
|
|
136
|
+
HTTPS_PROXY: 'http://proxy:8443',
|
|
137
|
+
HTTP_PROXY: 'http://proxy:8080',
|
|
138
|
+
});
|
|
139
|
+
assert.deepStrictEqual(layer, {
|
|
140
|
+
api_base: 'http://env-base',
|
|
141
|
+
default_model: 'env-model',
|
|
142
|
+
https_proxy: 'http://proxy:8443',
|
|
143
|
+
http_proxy: 'http://proxy:8080',
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('envConfigLayer: lowercase proxy variables are honored', () => {
|
|
148
|
+
const layer = envConfigLayer({ https_proxy: 'http://lc:1', http_proxy: 'http://lc:2' });
|
|
149
|
+
assert.strictEqual(layer.https_proxy, 'http://lc:1');
|
|
150
|
+
assert.strictEqual(layer.http_proxy, 'http://lc:2');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('envConfigLayer: unset variables produce no keys', () => {
|
|
154
|
+
assert.deepStrictEqual(envConfigLayer({}), {});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('envConfigLayer: does NOT map SEMALT_API_KEY (owned by secrets.js)', () => {
|
|
158
|
+
const layer = envConfigLayer({ SEMALT_API_KEY: 'sk-secret' });
|
|
159
|
+
assert.ok(!('api_key' in layer), 'api_key sourcing stays in secrets.js (Phase 0)');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// flagsConfigLayer — pure (takes argv)
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
test('flagsConfigLayer: maps the config-bearing flags', () => {
|
|
167
|
+
const layer = flagsConfigLayer([
|
|
168
|
+
'--api-base', 'http://flag-base',
|
|
169
|
+
'--api-key', 'flag-key',
|
|
170
|
+
'--dashboard-url', 'http://flag-dash',
|
|
171
|
+
'--default-model', 'flag-model',
|
|
172
|
+
]);
|
|
173
|
+
assert.deepStrictEqual(layer, {
|
|
174
|
+
api_base: 'http://flag-base',
|
|
175
|
+
api_key: 'flag-key',
|
|
176
|
+
dashboard_url: 'http://flag-dash',
|
|
177
|
+
default_model: 'flag-model',
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('flagsConfigLayer: ignores unrelated flags and a trailing flag with no value', () => {
|
|
182
|
+
assert.deepStrictEqual(flagsConfigLayer(['--model', 'x', '--debug']), {});
|
|
183
|
+
assert.deepStrictEqual(flagsConfigLayer(['--api-base']), {});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('flagsConfigLayer: Task 2.7 flags (reasoning-effort value, prompt-caching boolean)', () => {
|
|
187
|
+
assert.deepStrictEqual(
|
|
188
|
+
flagsConfigLayer(['--reasoning-effort', 'high', '--prompt-caching']),
|
|
189
|
+
{ reasoning_effort: 'high', prompt_caching: true },
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// Project config discovery — upward walk, bounded by the repo root
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
test('findProjectConfigPath: discovers .semalt/config.json from a nested CWD', () => {
|
|
198
|
+
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'semalt-proj-')));
|
|
199
|
+
fs.mkdirSync(path.join(root, '.git'), { recursive: true });
|
|
200
|
+
fs.mkdirSync(path.join(root, '.semalt'), { recursive: true });
|
|
201
|
+
fs.writeFileSync(path.join(root, '.semalt', 'config.json'), '{}');
|
|
202
|
+
const nested = path.join(root, 'a', 'b', 'c');
|
|
203
|
+
fs.mkdirSync(nested, { recursive: true });
|
|
204
|
+
assert.strictEqual(findProjectConfigPath(nested), path.join(root, '.semalt', 'config.json'));
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('findProjectConfigPath: returns null when there is no project config', () => {
|
|
208
|
+
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'semalt-noproj-')));
|
|
209
|
+
fs.mkdirSync(path.join(root, '.git'), { recursive: true }); // repo root, no .semalt
|
|
210
|
+
const nested = path.join(root, 'x');
|
|
211
|
+
fs.mkdirSync(nested, { recursive: true });
|
|
212
|
+
assert.strictEqual(findProjectConfigPath(nested), null);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('findProjectConfigPath: the nearest config wins', () => {
|
|
216
|
+
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'semalt-near-')));
|
|
217
|
+
fs.mkdirSync(path.join(root, '.git'), { recursive: true });
|
|
218
|
+
fs.mkdirSync(path.join(root, '.semalt'), { recursive: true });
|
|
219
|
+
fs.writeFileSync(path.join(root, '.semalt', 'config.json'), JSON.stringify({ default_model: 'root' }));
|
|
220
|
+
const sub = path.join(root, 'pkg');
|
|
221
|
+
fs.mkdirSync(path.join(sub, '.semalt'), { recursive: true });
|
|
222
|
+
fs.writeFileSync(path.join(sub, '.semalt', 'config.json'), JSON.stringify({ default_model: 'pkg' }));
|
|
223
|
+
assert.strictEqual(loadProjectConfig(sub).default_model, 'pkg');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// loadConfig — full precedence integration (real fs + env + argv)
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
const PREV_CWD = process.cwd();
|
|
231
|
+
let PROJ_ROOT;
|
|
232
|
+
let PROJ_NESTED;
|
|
233
|
+
const SAVED_ENV = {};
|
|
234
|
+
const ENV_KEYS = ['SEMALT_API_BASE', 'SEMALT_MODEL', 'HTTPS_PROXY', 'HTTP_PROXY'];
|
|
235
|
+
|
|
236
|
+
before(() => {
|
|
237
|
+
PROJ_ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'semalt-load-')));
|
|
238
|
+
fs.mkdirSync(path.join(PROJ_ROOT, '.git'), { recursive: true });
|
|
239
|
+
PROJ_NESTED = path.join(PROJ_ROOT, 'deep', 'dir');
|
|
240
|
+
fs.mkdirSync(PROJ_NESTED, { recursive: true });
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
beforeEach(() => {
|
|
244
|
+
clearUserConfig();
|
|
245
|
+
for (const k of ENV_KEYS) { SAVED_ENV[k] = process.env[k]; delete process.env[k]; }
|
|
246
|
+
try { fs.rmSync(path.join(PROJ_ROOT, '.semalt'), { recursive: true, force: true }); } catch {}
|
|
247
|
+
process.chdir(PROJ_NESTED);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
after(() => {
|
|
251
|
+
process.chdir(PREV_CWD);
|
|
252
|
+
for (const k of ENV_KEYS) { if (SAVED_ENV[k] === undefined) delete process.env[k]; else process.env[k] = SAVED_ENV[k]; }
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
function writeProjectConfig(obj) {
|
|
256
|
+
fs.mkdirSync(path.join(PROJ_ROOT, '.semalt'), { recursive: true });
|
|
257
|
+
fs.writeFileSync(path.join(PROJ_ROOT, '.semalt', 'config.json'), JSON.stringify(obj));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
test('loadConfig: project config overrides user config', () => {
|
|
261
|
+
writeUserConfig({ api_base: 'http://user', default_model: 'user-model' });
|
|
262
|
+
writeProjectConfig({ default_model: 'project-model' });
|
|
263
|
+
const cfg = loadConfig([]);
|
|
264
|
+
assert.strictEqual(cfg.api_base, 'http://user', 'unshadowed user key survives');
|
|
265
|
+
assert.strictEqual(cfg.default_model, 'project-model', 'project overrides user');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('loadConfig: env overrides both user and project', () => {
|
|
269
|
+
writeUserConfig({ api_base: 'http://user' });
|
|
270
|
+
writeProjectConfig({ api_base: 'http://project' });
|
|
271
|
+
process.env.SEMALT_API_BASE = 'http://env';
|
|
272
|
+
const cfg = loadConfig([]);
|
|
273
|
+
assert.strictEqual(cfg.api_base, 'http://env');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('loadConfig: flags override env', () => {
|
|
277
|
+
writeUserConfig({ api_base: 'http://user' });
|
|
278
|
+
process.env.SEMALT_API_BASE = 'http://env';
|
|
279
|
+
const cfg = loadConfig(['--api-base', 'http://flag']);
|
|
280
|
+
assert.strictEqual(cfg.api_base, 'http://flag');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('loadConfig: proxy env is read and exposed on the merged config', () => {
|
|
284
|
+
writeUserConfig({});
|
|
285
|
+
process.env.HTTPS_PROXY = 'http://corp-proxy:8443';
|
|
286
|
+
const cfg = loadConfig([]);
|
|
287
|
+
assert.strictEqual(cfg.https_proxy, 'http://corp-proxy:8443');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test('loadConfig: with no layers present, returns the normalized defaults', () => {
|
|
291
|
+
const cfg = loadConfig([]);
|
|
292
|
+
assert.strictEqual(cfg.api_base, DEFAULT_CONFIG.api_base);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
// Persistence never bakes higher-layer overrides into the user file
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
test('userLayerForPersist: only caller-changed keys are layered onto the user file', () => {
|
|
300
|
+
const prevMerged = { api_base: 'http://env-override', default_model: 'old', theme: 'dark' };
|
|
301
|
+
const userFile = { api_base: 'http://user-file', default_model: 'old', theme: 'dark' };
|
|
302
|
+
// Caller changes only default_model; it carries the merged api_base along.
|
|
303
|
+
const next = { ...prevMerged, default_model: 'new' };
|
|
304
|
+
const persisted = userLayerForPersist(next, prevMerged, userFile);
|
|
305
|
+
assert.strictEqual(persisted.default_model, 'new', 'the intended change is persisted');
|
|
306
|
+
assert.strictEqual(persisted.api_base, 'http://user-file', 'the env override is NOT baked in');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test('configSet writes against the user file only (no override leakage)', () => {
|
|
310
|
+
writeUserConfig({ api_base: 'http://user', theme: 'dark' });
|
|
311
|
+
writeProjectConfig({ api_base: 'http://project' });
|
|
312
|
+
process.env.SEMALT_API_BASE = 'http://env';
|
|
313
|
+
configSet('theme', 'light');
|
|
314
|
+
const onDisk = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
|
315
|
+
assert.strictEqual(onDisk.theme, 'light', 'the set persisted');
|
|
316
|
+
assert.strictEqual(onDisk.api_base, 'http://user', 'env/project api_base not baked into the file');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('loadUserConfig ignores project/env/flags (user file only)', () => {
|
|
320
|
+
writeUserConfig({ api_base: 'http://user' });
|
|
321
|
+
writeProjectConfig({ api_base: 'http://project' });
|
|
322
|
+
process.env.SEMALT_API_BASE = 'http://env';
|
|
323
|
+
assert.strictEqual(loadUserConfig().api_base, 'http://user');
|
|
324
|
+
});
|