@semalt-ai/code 1.19.0 → 1.20.1

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 (83) hide show
  1. package/.claude/settings.local.json +2 -1
  2. package/ARCHITECTURE.md +6 -95
  3. package/CLAUDE.md +196 -1874
  4. package/README.md +1 -1
  5. package/docs/ARCHITECTURE.md +1321 -0
  6. package/docs/CONFIG.md +340 -0
  7. package/docs/HISTORY.md +245 -0
  8. package/index.js +1 -1
  9. package/lib/agent.js +145 -16
  10. package/lib/api.js +28 -3
  11. package/lib/commands/chat-session.js +188 -4
  12. package/lib/commands/chat-slash.js +16 -0
  13. package/lib/commands/chat-turn.js +319 -52
  14. package/lib/commands/chat.js +12 -8
  15. package/lib/config.js +27 -0
  16. package/lib/constants.js +30 -1
  17. package/lib/headless.js +36 -1
  18. package/lib/images.js +8 -2
  19. package/lib/permissions.js +23 -16
  20. package/lib/prompts.js +15 -3
  21. package/lib/tool_registry.js +357 -53
  22. package/lib/tool_specs.js +42 -8
  23. package/lib/tools.js +80 -19
  24. package/lib/ui/anim.js +86 -0
  25. package/lib/ui/ansi.js +17 -27
  26. package/lib/ui/chat-history.js +253 -71
  27. package/lib/ui/create-ui.js +67 -24
  28. package/lib/ui/diff.js +90 -25
  29. package/lib/ui/file-activity.js +229 -0
  30. package/lib/ui/format.js +173 -28
  31. package/lib/ui/input-field.js +5 -4
  32. package/lib/ui/md-stream.js +234 -0
  33. package/lib/ui/render-operation.js +113 -0
  34. package/lib/ui/select.js +1 -4
  35. package/lib/ui/status-bar.js +99 -57
  36. package/lib/ui/stream.js +20 -13
  37. package/lib/ui/theme.js +190 -45
  38. package/lib/ui/tool-operation.js +190 -0
  39. package/lib/ui/utils.js +9 -5
  40. package/lib/ui/web-activity.js +58 -6
  41. package/lib/ui/writer.js +159 -45
  42. package/lib/ui.js +1 -1
  43. package/package.json +1 -1
  44. package/test/anim-driver.test.js +153 -0
  45. package/test/ask-user-display.test.js +226 -0
  46. package/test/ask-user-gate.test.js +231 -0
  47. package/test/chat-history-nocolor.test.js +155 -0
  48. package/test/chat-relogin.test.js +207 -0
  49. package/test/defer-detail-band.test.js +403 -0
  50. package/test/detail-band-tab-flatten.test.js +242 -0
  51. package/test/exec-diff.test.js +268 -0
  52. package/test/executors.test.js +250 -13
  53. package/test/extract-tool-calls.test.js +37 -3
  54. package/test/file-activity.test.js +542 -0
  55. package/test/grep-path-target.test.js +227 -0
  56. package/test/harness/chat-harness.js +2 -1
  57. package/test/headless.test.js +146 -1
  58. package/test/input-field-ctrl-o.test.js +37 -0
  59. package/test/live-height-physical.test.js +281 -0
  60. package/test/max-iterations.test.js +9 -7
  61. package/test/md-stream.test.js +183 -0
  62. package/test/narration-ordering.test.js +309 -0
  63. package/test/native-dispatch.test.js +53 -0
  64. package/test/native-live-narration.test.js +254 -0
  65. package/test/output-heredoc-leak.test.js +195 -0
  66. package/test/output-preview.test.js +245 -0
  67. package/test/permission-flush.test.js +302 -0
  68. package/test/permissions.test.js +199 -0
  69. package/test/read-paginate.test.js +1 -1
  70. package/test/render-operation.test.js +317 -0
  71. package/test/replay-descriptor-xml.test.js +216 -0
  72. package/test/replay-descriptor.test.js +189 -0
  73. package/test/replay-web-aggregate.test.js +291 -0
  74. package/test/replay-web-persist.test.js +241 -0
  75. package/test/running-glyph-anim.test.js +111 -0
  76. package/test/status-bar-driver.test.js +93 -0
  77. package/test/status-bar-resync.test.js +188 -0
  78. package/test/stream-parser.test.js +24 -0
  79. package/test/theme-palette.test.js +166 -0
  80. package/test/truncate-visible.test.js +78 -0
  81. package/test/view-image.test.js +199 -0
  82. package/test/web-activity-ordering.test.js +12 -3
  83. package/path +0 -1
@@ -0,0 +1,155 @@
1
+ 'use strict';
2
+
3
+ // NO_COLOR sweep for lib/ui/chat-history.js chrome.
4
+ //
5
+ // Two-sided per surface (mirrors theme-palette.test.js): under NO_COLOR=1 (or
6
+ // non-TTY) every chrome site must emit ZERO ANSI, while a colour-ON render must
7
+ // still carry an `\x1b[` escape — proving the gate is CONDITIONAL, not a blanket
8
+ // strip. Glyphs (✓ ✗ ● ⚠ •) are NOT colour and must survive into NO_COLOR.
9
+
10
+ const { test } = require('node:test');
11
+ const assert = require('node:assert');
12
+
13
+ const { ChatHistory } = require('../lib/ui/chat-history');
14
+ const { UI_ICONS } = require('../lib/ui/theme');
15
+
16
+ // Render one message through a ChatHistory, capturing everything that would
17
+ // reach scrollback (incl. any committed detail band). Seams overridden the same
18
+ // way the class documents for tests (_commit / _setDetail / _commitDetail).
19
+ function render(msg, { color }) {
20
+ const origTTY = process.stdout.isTTY;
21
+ const origNoColor = process.env.NO_COLOR;
22
+ if (color) {
23
+ process.stdout.isTTY = true;
24
+ delete process.env.NO_COLOR;
25
+ } else {
26
+ process.stdout.isTTY = true; // TTY on, NO_COLOR forces it off
27
+ process.env.NO_COLOR = '1';
28
+ }
29
+ try {
30
+ const h = new ChatHistory();
31
+ const chunks = [];
32
+ h._commit = (t) => chunks.push(t);
33
+ h._setDetail = () => {};
34
+ h._commitDetail = (t) => { if (t) chunks.push(t); };
35
+ h.addMessage(msg);
36
+ return chunks.join('');
37
+ } finally {
38
+ process.stdout.isTTY = origTTY;
39
+ if (origNoColor === undefined) delete process.env.NO_COLOR;
40
+ else process.env.NO_COLOR = origNoColor;
41
+ }
42
+ }
43
+
44
+ // Assert the gate is conditional for a given message: plain under NO_COLOR,
45
+ // escaped under colour-ON. `glyphs` (if any) must appear in BOTH renders.
46
+ function assertGated(label, msg, glyphs = []) {
47
+ const off = render(msg, { color: false });
48
+ const on = render(msg, { color: true });
49
+ assert.ok(!off.includes('\x1b'), `${label}: zero ANSI under NO_COLOR`);
50
+ assert.ok(on.includes('\x1b['), `${label}: colour-ON still emits ANSI`);
51
+ for (const g of glyphs) {
52
+ assert.ok(off.includes(g), `${label}: glyph ${JSON.stringify(g)} survives NO_COLOR`);
53
+ assert.ok(on.includes(g), `${label}: glyph ${JSON.stringify(g)} present colour-ON`);
54
+ }
55
+ }
56
+
57
+ // (a) user bubble — also the EL (\x1b[K) must be gone, not just the SGR colours.
58
+ test('user bubble: gated, and NO_COLOR carries no stray EL escape', () => {
59
+ const msg = { role: 'user', content: 'hello\nworld', ts: new Date('2026-06-22T10:00:00Z') };
60
+ assertGated('user bubble', msg);
61
+ const off = render(msg, { color: false });
62
+ assert.ok(!off.includes('\x1b[K'), 'no erase-to-EOL leaks under NO_COLOR');
63
+ assert.ok(!off.includes('\x1b'), 'user bubble is byte-clean of every escape');
64
+ const on = render(msg, { color: true });
65
+ assert.ok(on.includes('\x1b[K'), 'EL still present colour-ON (fills bg tint to edge)');
66
+ });
67
+
68
+ // (b) tool summary — both the history (summarizeToolResult) and legacy-header
69
+ // branches. Status glyph must survive.
70
+ test('tool summary (history branch): gated, ✓ glyph preserved', () => {
71
+ assertGated('tool summary history', { role: 'tool', content: 'Read 5 lines' }, [UI_ICONS.success]);
72
+ });
73
+
74
+ test('tool summary (legacy header branch): gated, ✓ glyph + category preserved', () => {
75
+ const msg = { role: 'tool', tag: 'read_file', content: 'foo.js', output: 'line one\n' };
76
+ assertGated('tool summary legacy', msg, [UI_ICONS.success]);
77
+ const off = render(msg, { color: false });
78
+ assert.ok(off.includes('file:'), 'category label still rendered under NO_COLOR');
79
+ });
80
+
81
+ // (c) shell echo.
82
+ test('shell echo: gated', () => {
83
+ assertGated('shell echo', { role: 'shell', cmd: 'ls -la', content: 'file1\nfile2' });
84
+ });
85
+
86
+ // (d) permission message — ⚠ glyph preserved.
87
+ test('permission message: gated, ⚠ glyph preserved', () => {
88
+ assertGated('permission', { role: 'permission', content: 'approve shell?' }, [UI_ICONS.warn]);
89
+ });
90
+
91
+ // (e) system/fallback error — ✗ glyph preserved, multi-line continuation gated.
92
+ test('system/fallback error: gated, ✗ glyph preserved', () => {
93
+ assertGated('system error', { role: 'system', isError: true, content: 'boom\ndetail line' }, [UI_ICONS.error]);
94
+ });
95
+
96
+ // (f) output-preview band (collapsible shell/MCP/subagent body).
97
+ //
98
+ // The band's BODY lines route their content through formatOutputPreview →
99
+ // truncateVisible (lib/ui/utils.js). truncateVisible now appends its defensive
100
+ // `\x1b[0m` ONLY when the (possibly truncated) output already contains an escape
101
+ // (content-conditional), so for escape-free body content the band is fully
102
+ // escape-clean under NO_COLOR — body lines included. We assert that here in
103
+ // addition to this file's own chrome gating (DIM / subtle / FG_DARK wrappers).
104
+ //
105
+ // A tool's output can carry the child process's OWN SGR escapes into the body.
106
+ // formatOutputPreview now strips those under NO_COLOR (a stripAnsi pass gated on
107
+ // colorEnabled, BEFORE truncateVisible) so the body line is byte-clean; with
108
+ // colour on the captured colour is preserved. Asserted two-sided below.
109
+ //
110
+ // NOTE (out of scope, deferred): the strip is SGR-only (the shared stripAnsi
111
+ // regex /\x1b\[[^m]*m/g). A non-SGR escape in the body (EL `\x1b[K`, cursor
112
+ // moves, OSC titles) is NOT removed and would still trip truncateVisible's
113
+ // defensive RST, so such a line would not be fully byte-clean under NO_COLOR.
114
+ // Closing that needs a broader strip with over-stripping/measurement trade-offs.
115
+ const DIM = '\x1b[2m';
116
+ const SUBTLE = '\x1b[38;5;244m';
117
+ const FG_DARK = '\x1b[38;5;240m';
118
+
119
+ test('output-preview band: escape-free body is fully clean under NO_COLOR; chrome gated', () => {
120
+ const output = ['l1', 'l2', 'l3', 'l4', 'l5', 'l6'].join('\n');
121
+ const msg = { role: 'tool', output, previewLines: 2 };
122
+ const off = render(msg, { color: false });
123
+ const on = render(msg, { color: true });
124
+ // None of this file's chrome colours leak when colour is off.
125
+ for (const code of [DIM, SUBTLE, FG_DARK]) {
126
+ assert.ok(!off.includes(code), `band chrome ${JSON.stringify(code)} gated under NO_COLOR`);
127
+ }
128
+ // With escape-free body content the WHOLE band — body lines + hint — is now
129
+ // byte-clean of every escape (no residual truncateVisible RST leak).
130
+ assert.ok(!off.includes('\x1b'), 'whole band is escape-clean under NO_COLOR for escape-free body');
131
+ const bodyLine = off.split('\n').find((l) => l.includes('l1'));
132
+ assert.ok(bodyLine && !bodyLine.includes('\x1b'), 'preview body line carries no trailing RST under NO_COLOR');
133
+ const hint = off.split('\n').find((l) => l.includes('more lines'));
134
+ assert.ok(hint && !hint.includes('\x1b'), 'the "… more lines" hint is escape-clean');
135
+ // Colour-ON still paints the chrome — proving the gate is conditional.
136
+ assert.ok(on.includes(DIM) && on.includes(SUBTLE), 'band chrome present colour-ON');
137
+ });
138
+
139
+ // (g) subprocess ANSI in the body — the leak this fix closes. A child process's
140
+ // own SGR (e.g. a coloured compiler/test line) is stripped under NO_COLOR so the
141
+ // body line is byte-clean, and preserved when colour is on.
142
+ test('output-preview band: subprocess SGR stripped under NO_COLOR, preserved colour-ON', () => {
143
+ const output = ['\x1b[31mred\x1b[0m line', 'plain line'].join('\n');
144
+ const msg = { role: 'tool', output, previewLines: 5 };
145
+ const off = render(msg, { color: false });
146
+ const on = render(msg, { color: true });
147
+ // Under NO_COLOR the body line carrying subprocess SGR is fully escape-clean.
148
+ const offBody = off.split('\n').find((l) => l.includes('red'));
149
+ assert.ok(offBody, 'the subprocess-coloured body line is rendered');
150
+ assert.ok(!offBody.includes('\x1b'), 'subprocess SGR stripped from body under NO_COLOR');
151
+ assert.ok(!off.includes('\x1b'), 'whole band escape-clean under NO_COLOR with subprocess SGR input');
152
+ // Colour-ON preserves the captured command colour in the body.
153
+ const onBody = on.split('\n').find((l) => l.includes('red'));
154
+ assert.ok(onBody && onBody.includes('\x1b[31m'), 'command colour preserved in body colour-ON');
155
+ });
@@ -0,0 +1,207 @@
1
+ 'use strict';
2
+
3
+ // Coverage for the relogin chat-context reset + 404 self-heal on save.
4
+ // Root cause: /logout and /login did NOT reset ctx.currentChatId, so a stale id
5
+ // survived a relogin and suppressed fresh-chat creation; the next save POSTed to
6
+ // a chat the new principal can't see → 404 "Chat not found" → warn-and-drop.
7
+ //
8
+ // The two factories (createChatSession / createSlashHandlers) both take a plain
9
+ // `ctx`; in production cmdChat hands them the SAME ctx. We mirror that by passing
10
+ // one shared ctx, so a slash handler's reset is visible to the session helpers.
11
+
12
+ const test = require('node:test');
13
+ const assert = require('node:assert');
14
+
15
+ const { createChatSession } = require('../lib/commands/chat-session');
16
+ const { createSlashHandlers } = require('../lib/commands/chat-slash');
17
+
18
+ // Build a shared ctx with recording stubs. Only the fields the functions under
19
+ // test touch are real; the rest are inert so the destructures don't throw.
20
+ function makeCtx(overrides = {}) {
21
+ const warns = [];
22
+ const saves = []; // { chatId, count }
23
+ let createSeq = 0;
24
+ const created = [];
25
+
26
+ const config = {
27
+ auth_token: 'tok', dashboard_model_id: 'm1', ...(overrides.config || {}),
28
+ };
29
+
30
+ const ctx = {
31
+ // session state
32
+ messages: overrides.messages || [],
33
+ currentChatId: overrides.currentChatId !== undefined ? overrides.currentChatId : null,
34
+ savedUpTo: overrides.savedUpTo || 0,
35
+ currentModel: 'test-model',
36
+ resolvedTokenLimit: 1000,
37
+
38
+ // collaborators used by chat-session helpers
39
+ msgs: { sysWarn: (m) => warns.push(m) },
40
+ getConfig: () => config,
41
+ setConfig: (c) => Object.assign(config, c),
42
+ storage: { list: () => [], save() {}, load: () => null },
43
+ approxTokens: (s) => Math.ceil((s || '').length / 4),
44
+ PAGE_SIZE: 10, sessionStart: 0, getCols: () => 80,
45
+ FG_GRAY: '', RST: '',
46
+ cleanOrphanedToolMessages: (m) => ({ messages: m, droppedTool: 0, droppedAssistantCalls: 0, droppedAssistantMsgs: 0 }),
47
+ reconstructLoadedMessage: (m) => m,
48
+ resolveTokenLimit: async () => 1000,
49
+
50
+ dashboardCreateChat: overrides.dashboardCreateChat || (async () => {
51
+ const id = `chat-${++createSeq}`; created.push(id); return { chat: { id } };
52
+ }),
53
+ dashboardSaveMessages: overrides.dashboardSaveMessages || (async (chatId, msgs) => {
54
+ saves.push({ chatId, count: msgs.length }); return {};
55
+ }),
56
+ dashboardGetChat: async () => ({ messages: [] }),
57
+ dashboardGetModelForCli: async () => ({ model: null }),
58
+
59
+ // collaborators used by slash handlers
60
+ chatHistory: { addMessage() {} },
61
+ statusBar: { update() {}, setModel() {}, setContextLimit() {} },
62
+ inputField: { setDisabled() {} },
63
+ permissionManager: { clear() { ctx._permClears = (ctx._permClears || 0) + 1; } },
64
+ loginFlow: overrides.loginFlow || (async () => {}),
65
+ ensureDefaultModel: overrides.ensureDefaultModel || (async () => null),
66
+ dashboardLogout: overrides.dashboardLogout || (async () => ({})),
67
+
68
+ _warns: warns, _saves: saves, _created: created,
69
+ };
70
+
71
+ // Note: createChatIfNeeded reads `created` length via the default stub above.
72
+ Object.defineProperty(ctx, '_createdCount', { get: () => created.length });
73
+ return ctx;
74
+ }
75
+
76
+ // (b) /login resets currentChatId and savedUpTo (and mirrors /new: messages + approvals).
77
+ test('/login resets chat context (currentChatId, savedUpTo, messages)', async () => {
78
+ const ctx = makeCtx({ currentChatId: 'stale', savedUpTo: 5, messages: [{ role: 'user', content: 'x' }] });
79
+ const handlers = createSlashHandlers(ctx);
80
+ await handlers['/login']();
81
+ assert.strictEqual(ctx.currentChatId, null);
82
+ assert.strictEqual(ctx.savedUpTo, 0);
83
+ assert.deepStrictEqual(ctx.messages, []);
84
+ assert.strictEqual(ctx._permClears, 1, 'approvals cleared on principal change');
85
+ });
86
+
87
+ // (c) /logout resets them too, even if the dashboardLogout HTTP call failed.
88
+ test('/logout resets chat context even when dashboardLogout HTTP fails', async () => {
89
+ const err503 = Object.assign(new Error('Service Unavailable'), { statusCode: 503 });
90
+ const ctx = makeCtx({
91
+ currentChatId: 'stale', savedUpTo: 5,
92
+ dashboardLogout: async () => { throw err503; },
93
+ });
94
+ const handlers = createSlashHandlers(ctx);
95
+ await handlers['/logout'](); // early-returns on the 503, but reset must still hold
96
+ assert.strictEqual(ctx.currentChatId, null);
97
+ assert.strictEqual(ctx.savedUpTo, 0);
98
+ });
99
+
100
+ // (a) in-process /logout → /login → next turn creates a NEW chat; save targets it.
101
+ test('logout then login: next turn creates a fresh chat and save targets it', async () => {
102
+ const ctx = makeCtx({
103
+ currentChatId: 'chat-old', savedUpTo: 3,
104
+ loginFlow: async () => { ctx.setConfig({ auth_token: 'newtok' }); },
105
+ });
106
+ const session = createChatSession(ctx);
107
+ const handlers = createSlashHandlers(ctx);
108
+
109
+ await handlers['/logout']();
110
+ assert.strictEqual(ctx.currentChatId, null, 'logout dropped the stale id');
111
+ await handlers['/login']();
112
+ assert.strictEqual(ctx.currentChatId, null, 'login left it null for lazy create');
113
+
114
+ // Next turn.
115
+ ctx.messages.push({ role: 'user', content: 'hello' }, { role: 'assistant', content: 'hi' });
116
+ await session.createChatIfNeeded('hello');
117
+ await session.saveTurnToDashboard();
118
+
119
+ assert.strictEqual(ctx._createdCount, 1, 'exactly one fresh chat created');
120
+ assert.strictEqual(ctx._saves.length, 1);
121
+ assert.strictEqual(ctx._saves[0].chatId, 'chat-1', 'save went to the new chat, not chat-old');
122
+ assert.deepStrictEqual(ctx._warns, [], 'no "Chat not found" warning');
123
+ });
124
+
125
+ // (d) 404 on save → self-heal: new chat, re-save same slice, advance savedUpTo after success.
126
+ test('saveTurnToDashboard self-heals on 404 (recreate + re-save, once)', async () => {
127
+ let createSeq = 0;
128
+ const saves = [];
129
+ const ctx = makeCtx({
130
+ currentChatId: 'stale', savedUpTo: 0,
131
+ messages: [{ role: 'user', content: 'hi' }, { role: 'assistant', content: 'yo' }],
132
+ dashboardCreateChat: async () => ({ chat: { id: `fresh-${++createSeq}` } }),
133
+ dashboardSaveMessages: async (chatId, msgs) => {
134
+ saves.push({ chatId, count: msgs.length });
135
+ if (chatId === 'stale') throw Object.assign(new Error('Chat not found'), { statusCode: 404 });
136
+ return {};
137
+ },
138
+ });
139
+ const session = createChatSession(ctx);
140
+ await session.saveTurnToDashboard();
141
+
142
+ assert.strictEqual(createSeq, 1, 'exactly one new chat created (no retry loop)');
143
+ assert.strictEqual(ctx.currentChatId, 'fresh-1');
144
+ assert.deepStrictEqual(saves.map((s) => s.chatId), ['stale', 'fresh-1'], 'retried on the fresh chat');
145
+ assert.strictEqual(saves[1].count, 2, 're-saved the SAME pending slice');
146
+ assert.strictEqual(ctx.savedUpTo, 2, 'savedUpTo advanced only after re-save succeeded');
147
+ assert.ok(!ctx._warns.some((w) => /history save failed/.test(w)), 'no failure warning on success');
148
+ });
149
+
150
+ // (e) non-404 (503) → does NOT recreate; warns; savedUpTo unchanged.
151
+ test('saveTurnToDashboard does not recover on non-404 (503)', async () => {
152
+ let creates = 0;
153
+ const ctx = makeCtx({
154
+ currentChatId: 'stale', savedUpTo: 0,
155
+ messages: [{ role: 'user', content: 'hi' }, { role: 'assistant', content: 'yo' }],
156
+ dashboardCreateChat: async () => { creates++; return { chat: { id: 'x' } }; },
157
+ dashboardSaveMessages: async () => { throw Object.assign(new Error('Service Unavailable'), { statusCode: 503 }); },
158
+ });
159
+ const session = createChatSession(ctx);
160
+ await session.saveTurnToDashboard();
161
+
162
+ assert.strictEqual(creates, 0, 'transient error must NOT spawn a new chat');
163
+ assert.strictEqual(ctx.currentChatId, 'stale', 'chat id untouched');
164
+ assert.strictEqual(ctx.savedUpTo, 0, 'savedUpTo unadvanced so a later turn retries');
165
+ assert.ok(ctx._warns.some((w) => /history save failed/.test(w)), 'warned');
166
+ });
167
+
168
+ // (f) createChatIfNeeded creation failure is surfaced, not swallowed.
169
+ test('createChatIfNeeded surfaces a creation failure (not silent)', async () => {
170
+ const ctx = makeCtx({
171
+ currentChatId: null,
172
+ dashboardCreateChat: async () => { throw new Error('boom'); },
173
+ });
174
+ const session = createChatSession(ctx);
175
+ await session.createChatIfNeeded('hello');
176
+ assert.strictEqual(ctx.currentChatId, null, 'still no chat (non-fatal)');
177
+ assert.ok(ctx._warns.some((w) => /could not create dashboard chat/.test(w)), 'failure surfaced');
178
+ });
179
+
180
+ // (g) regression: normal session and --resume session save to the right chat.
181
+ test('regression: normal session lazily creates and saves; --resume saves to resumed id', async () => {
182
+ // Normal session: currentChatId starts null → lazy create → save.
183
+ const a = makeCtx({ currentChatId: null, savedUpTo: 0, messages: [{ role: 'user', content: 'q' }, { role: 'assistant', content: 'a' }] });
184
+ const sa = createChatSession(a);
185
+ await sa.createChatIfNeeded('q');
186
+ await sa.saveTurnToDashboard();
187
+ assert.strictEqual(a._created.length, 1);
188
+ assert.strictEqual(a._saves[0].chatId, 'chat-1');
189
+ assert.strictEqual(a.savedUpTo, 2);
190
+ assert.deepStrictEqual(a._warns, []);
191
+
192
+ // --resume session: currentChatId set, savedUpTo at resumed length; a new turn
193
+ // saves the incremental slice to the SAME chat (no reset, no new chat).
194
+ const b = makeCtx({
195
+ currentChatId: 'resumed-id', savedUpTo: 2,
196
+ messages: [{ role: 'user', content: 'q1' }, { role: 'assistant', content: 'a1' }],
197
+ });
198
+ const sb = createChatSession(b);
199
+ await sb.createChatIfNeeded('ignored'); // no-op: currentChatId already set
200
+ b.messages.push({ role: 'user', content: 'q2' }, { role: 'assistant', content: 'a2' });
201
+ await sb.saveTurnToDashboard();
202
+ assert.strictEqual(b._created.length, 0, '--resume must not create a new chat');
203
+ assert.strictEqual(b._saves.length, 1);
204
+ assert.strictEqual(b._saves[0].chatId, 'resumed-id');
205
+ assert.strictEqual(b._saves[0].count, 2, 'only the incremental slice saved');
206
+ assert.strictEqual(b.savedUpTo, 4);
207
+ });