@moxxy/core 0.5.3 → 0.6.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/src/index.ts CHANGED
@@ -68,7 +68,13 @@ export {
68
68
  readEventPage as readSessionEventPage,
69
69
  pageEvents,
70
70
  deleteSession,
71
+ listSessionMetas,
72
+ setSessionTitle,
73
+ setSessionGroup,
74
+ seedSessionMeta,
75
+ SESSION_META_VERSION,
71
76
  type SessionMeta,
77
+ type SessionSource,
72
78
  type SessionPersistenceOpts,
73
79
  type EventPage,
74
80
  } from './sessions/persistence.js';
@@ -59,6 +59,52 @@ function buildSession(): Session {
59
59
  }
60
60
 
61
61
  describe('runTurn turnId filtering', () => {
62
+ it('continues the last resolved session model when no model override is supplied', async () => {
63
+ const models = [{ id: 'noop-default' }, { id: 'noop-sticky' }];
64
+ const session = new Session({ cwd: '/tmp', silent: true });
65
+ session.pluginHost.registerStatic(
66
+ definePlugin({
67
+ name: 'test-sticky-model',
68
+ version: '0.0.0',
69
+ providers: [
70
+ defineProvider({
71
+ name: 'noop',
72
+ models,
73
+ createClient: () => ({
74
+ name: 'noop',
75
+ models,
76
+ stream: async function* () {},
77
+ countTokens: async () => 0,
78
+ }),
79
+ }),
80
+ ],
81
+ modes: [
82
+ defineMode({
83
+ name: 'model-echo',
84
+ run: async function* (ctx: ModeContext): AsyncIterable<MoxxyEvent> {
85
+ await ctx.emit({
86
+ type: 'assistant_message',
87
+ sessionId: ctx.sessionId,
88
+ turnId: ctx.turnId,
89
+ source: 'assistant',
90
+ text: ctx.model,
91
+ });
92
+ },
93
+ }),
94
+ ],
95
+ }),
96
+ );
97
+ session.providers.setActive('noop');
98
+ session.modes.setActive('model-echo');
99
+ session.lastResolvedModel = 'noop-sticky';
100
+
101
+ const events = await collectTurn(session, 'continue');
102
+
103
+ expect(events.find((e) => e.type === 'assistant_message')).toMatchObject({
104
+ text: 'noop-sticky',
105
+ });
106
+ });
107
+
62
108
  it('a single turn surfaces all of its own events', async () => {
63
109
  const session = buildSession();
64
110
  const events = await collectTurn(session, 'hi');
package/src/run-turn.ts CHANGED
@@ -65,7 +65,10 @@ export async function* runTurn(
65
65
  let model: string;
66
66
  try {
67
67
  provider = session.providers.getActive();
68
- const resolvedModel = opts.model ?? provider.models[0]?.id;
68
+ // Sticky model: prefer the explicit per-turn model, then the session's
69
+ // last-resolved model (so a conversation keeps the model it was using
70
+ // across turns), then the active provider's default.
71
+ const resolvedModel = opts.model ?? session.lastResolvedModel ?? provider.models[0]?.id;
69
72
  if (!resolvedModel) {
70
73
  throw new Error(
71
74
  `Active provider '${provider.name}' has no models configured`,
@@ -18,7 +18,7 @@ afterEach(async () => {
18
18
  });
19
19
 
20
20
  async function readMeta(dir: string, id: string): Promise<SessionMeta> {
21
- const raw = await fs.readFile(path.join(dir, `${id}.meta.json`), 'utf8');
21
+ const raw = await fs.readFile(path.join(dir, `${id}.json`), 'utf8');
22
22
  return JSON.parse(raw) as SessionMeta;
23
23
  }
24
24
 
@@ -53,7 +53,7 @@ describe('SessionPersistence one-time setup (no per-flush open/close)', () => {
53
53
  expect(openSpy).toHaveBeenCalledTimes(1);
54
54
 
55
55
  // Sidecar still written correctly.
56
- const raw = await fs.readFile(path.join(dir, `${id}.meta.json`), 'utf8');
56
+ const raw = await fs.readFile(path.join(dir, `${id}.json`), 'utf8');
57
57
  const meta = JSON.parse(raw) as { eventCount: number; firstPrompt: string | null };
58
58
  expect(meta.eventCount).toBe(5);
59
59
  expect(meta.firstPrompt).toBe('msg 0');
@@ -6,6 +6,7 @@ import { EventLog } from '../events/log.js';
6
6
  import type { Logger } from '../logger.js';
7
7
  import {
8
8
  SessionPersistence,
9
+ defaultSessionsDir,
9
10
  deleteSession,
10
11
  readEventPage,
11
12
  readIndex,
@@ -57,17 +58,194 @@ function meta(id: string, eventCount = 0): SessionMeta {
57
58
  }
58
59
 
59
60
  describe('SessionPersistence', () => {
61
+ it('honors MOXXY_HOME for the default sessions directory', async () => {
62
+ const original = process.env.MOXXY_HOME;
63
+ const home = await makeTempDir();
64
+ process.env.MOXXY_HOME = home;
65
+ try {
66
+ expect(defaultSessionsDir()).toBe(path.join(home, 'sessions'));
67
+ } finally {
68
+ if (original === undefined) delete process.env.MOXXY_HOME;
69
+ else process.env.MOXXY_HOME = original;
70
+ }
71
+ });
72
+
60
73
  it('readIndex ignores rows whose event log file is missing', async () => {
61
74
  const dir = await makeTempDir();
62
75
  await fs.mkdir(dir, { recursive: true });
76
+ // `present` has a metadata file AND an event log; `missing` has only the
77
+ // metadata file. readIndex drops the one whose `.jsonl` is gone.
63
78
  await fs.writeFile(path.join(dir, 'present.jsonl'), '', 'utf8');
79
+ await fs.writeFile(path.join(dir, 'present.json'), JSON.stringify(meta('present')), 'utf8');
80
+ await fs.writeFile(path.join(dir, 'missing.json'), JSON.stringify(meta('missing')), 'utf8');
81
+
82
+ await expect(readIndex(dir)).resolves.toEqual([meta('present')]);
83
+ });
84
+
85
+ it('readIndex recovers a missing firstPrompt from the event log', async () => {
86
+ const dir = await makeTempDir();
87
+ await fs.mkdir(dir, { recursive: true });
88
+ const id = '01HYDRATEPROMPT0000000000';
64
89
  await fs.writeFile(
65
- path.join(dir, 'index.json'),
66
- JSON.stringify([meta('missing'), meta('present')], null, 2),
90
+ path.join(dir, `${id}.json`),
91
+ JSON.stringify({ ...meta(id, 3), firstPrompt: null }, null, 2),
92
+ 'utf8',
93
+ );
94
+ await fs.writeFile(
95
+ path.join(dir, `${id}.jsonl`),
96
+ JSON.stringify({
97
+ id: 'e1',
98
+ seq: 0,
99
+ ts: 1,
100
+ sessionId: id,
101
+ turnId: 't1',
102
+ source: 'user',
103
+ type: 'user_prompt',
104
+ text: 'restored from log',
105
+ }) + '\n',
67
106
  'utf8',
68
107
  );
69
108
 
70
- await expect(readIndex(dir)).resolves.toEqual([meta('present')]);
109
+ const [restored] = await readIndex(dir);
110
+
111
+ expect(restored?.firstPrompt).toBe('restored from log');
112
+ });
113
+
114
+ it('readIndex refreshes provider and model from the latest provider event', async () => {
115
+ const dir = await makeTempDir();
116
+ await fs.mkdir(dir, { recursive: true });
117
+ const id = '01HYDRATEPROVIDER00000000';
118
+ await fs.writeFile(
119
+ path.join(dir, `${id}.json`),
120
+ JSON.stringify({ ...meta(id, 2), provider: 'old-provider', model: 'old-model' }, null, 2),
121
+ 'utf8',
122
+ );
123
+ await fs.writeFile(
124
+ path.join(dir, `${id}.jsonl`),
125
+ [
126
+ JSON.stringify({
127
+ id: 'e1',
128
+ seq: 0,
129
+ ts: 1,
130
+ sessionId: id,
131
+ turnId: 't1',
132
+ source: 'system',
133
+ type: 'provider_request',
134
+ provider: 'first-provider',
135
+ model: 'first-model',
136
+ }),
137
+ JSON.stringify({
138
+ id: 'e2',
139
+ seq: 1,
140
+ ts: 2,
141
+ sessionId: id,
142
+ turnId: 't1',
143
+ source: 'system',
144
+ type: 'provider_response',
145
+ provider: 'session-provider',
146
+ model: 'session-model',
147
+ inputTokens: 1,
148
+ outputTokens: 1,
149
+ }),
150
+ ].join('\n') + '\n',
151
+ 'utf8',
152
+ );
153
+
154
+ const [restored] = await readIndex(dir);
155
+
156
+ expect(restored).toMatchObject({
157
+ provider: 'session-provider',
158
+ model: 'session-model',
159
+ });
160
+ });
161
+
162
+ it('readIndex preserves a sidecar firstPrompt when the event log is still empty', async () => {
163
+ const dir = await makeTempDir();
164
+ await fs.mkdir(dir, { recursive: true });
165
+ const id = '01SIDECARPROMPT0000000000';
166
+ await fs.writeFile(
167
+ path.join(dir, `${id}.json`),
168
+ JSON.stringify({ ...meta(id, 2), firstPrompt: 'sidecar title', eventCount: 2 }, null, 2),
169
+ 'utf8',
170
+ );
171
+ await fs.writeFile(path.join(dir, `${id}.jsonl`), '', 'utf8');
172
+
173
+ const [restored] = await readIndex(dir);
174
+
175
+ expect(restored?.firstPrompt).toBe('sidecar title');
176
+ expect(restored?.eventCount).toBe(2);
177
+ });
178
+
179
+ it('readIndex uses only matching-session prompts when hydrating titles', async () => {
180
+ const dir = await makeTempDir();
181
+ await fs.mkdir(dir, { recursive: true });
182
+ const id = '01MATCHPROMPT000000000000';
183
+ await fs.writeFile(
184
+ path.join(dir, `${id}.json`),
185
+ JSON.stringify({ ...meta(id, 2), firstPrompt: 'foreign title', eventCount: 2 }, null, 2),
186
+ 'utf8',
187
+ );
188
+ await fs.writeFile(
189
+ path.join(dir, `${id}.jsonl`),
190
+ [
191
+ {
192
+ id: 'foreign',
193
+ seq: 0,
194
+ ts: 1,
195
+ sessionId: 'other-session',
196
+ turnId: 't1',
197
+ source: 'user',
198
+ type: 'user_prompt',
199
+ text: 'foreign title',
200
+ },
201
+ {
202
+ id: 'matching',
203
+ seq: 1,
204
+ ts: 2,
205
+ sessionId: id,
206
+ turnId: 't2',
207
+ source: 'user',
208
+ type: 'user_prompt',
209
+ text: 'real matching title',
210
+ },
211
+ ].map((event) => JSON.stringify(event)).join('\n') + '\n',
212
+ 'utf8',
213
+ );
214
+
215
+ const [restored] = await readIndex(dir);
216
+
217
+ expect(restored?.firstPrompt).toBe('real matching title');
218
+ expect(restored?.eventCount).toBe(1);
219
+ });
220
+
221
+ it('readIndex does not hydrate a visible title from a foreign-only log', async () => {
222
+ const dir = await makeTempDir();
223
+ await fs.mkdir(dir, { recursive: true });
224
+ const id = '01FOREIGNONLY00000000000';
225
+ await fs.writeFile(
226
+ path.join(dir, `${id}.json`),
227
+ JSON.stringify({ ...meta(id, 1), firstPrompt: 'foreign prompt', eventCount: 1 }, null, 2),
228
+ 'utf8',
229
+ );
230
+ await fs.writeFile(
231
+ path.join(dir, `${id}.jsonl`),
232
+ JSON.stringify({
233
+ id: 'foreign',
234
+ seq: 0,
235
+ ts: 1,
236
+ sessionId: 'other-session',
237
+ turnId: 't1',
238
+ source: 'user',
239
+ type: 'user_prompt',
240
+ text: 'foreign prompt',
241
+ }) + '\n',
242
+ 'utf8',
243
+ );
244
+
245
+ const [restored] = await readIndex(dir);
246
+
247
+ expect(restored?.firstPrompt).toBeNull();
248
+ expect(restored?.eventCount).toBe(0);
71
249
  });
72
250
 
73
251
  it('creates a resumable empty event log when a session is indexed before any events', async () => {
@@ -87,7 +265,7 @@ describe('SessionPersistence', () => {
87
265
  const id = '01SIDECAR00000000000000000';
88
266
  const persistence = new SessionPersistence({ sessionId: id as never, cwd: '/tmp/p', dir });
89
267
  const detach = persistence.attach(new EventLog());
90
- await waitForFile(path.join(dir, `${id}.meta.json`));
268
+ await waitForFile(path.join(dir, `${id}.json`));
91
269
  const ids = (await readIndex(dir)).map((m) => m.id);
92
270
  expect(ids).toContain(id);
93
271
  detach();
@@ -131,6 +309,66 @@ describe('SessionPersistence', () => {
131
309
  detach();
132
310
  });
133
311
 
312
+ it('does not persist foreign-session events into the current session log', async () => {
313
+ const dir = await makeTempDir();
314
+ const id = '01CURRENTSESSION0000000000';
315
+ const log = new EventLog();
316
+ const { logger, lines } = captureLogger();
317
+ const persistence = new SessionPersistence({
318
+ sessionId: id as never,
319
+ cwd: '/tmp/p',
320
+ dir,
321
+ logger,
322
+ });
323
+ const detach = persistence.attach(log);
324
+
325
+ await log.append({
326
+ type: 'user_prompt',
327
+ sessionId: 'other-session' as never,
328
+ turnId: 't1' as never,
329
+ source: 'user',
330
+ text: 'should not persist',
331
+ });
332
+
333
+ await waitForCondition(async () =>
334
+ lines.some((line) => line.level === 'warn' && line.msg.includes('foreign-session')),
335
+ );
336
+ await waitForCondition(async () => (await readIndex(dir))[0]?.id === id);
337
+
338
+ expect(await restoreEvents(id, dir)).toEqual([]);
339
+ expect((await readIndex(dir))[0]).toMatchObject({ eventCount: 0, firstPrompt: null });
340
+
341
+ detach();
342
+ });
343
+
344
+ it('normalizes legacy in-memory events without sessionId to the current session before persisting', async () => {
345
+ const dir = await makeTempDir();
346
+ const id = '01LEGACYNOSESSION00000000';
347
+ const log = new EventLog();
348
+ const persistence = new SessionPersistence({ sessionId: id as never, cwd: '/tmp/p', dir });
349
+ const detach = persistence.attach(log);
350
+
351
+ await log.append({
352
+ type: 'user_prompt',
353
+ turnId: 't1' as never,
354
+ source: 'user',
355
+ text: 'legacy prompt without session id',
356
+ } as never);
357
+
358
+ await waitForCondition(async () => (await restoreEvents(id, dir)).length === 1);
359
+ const [event] = await restoreEvents(id, dir);
360
+ await waitForCondition(async () => (await readIndex(dir))[0]?.eventCount === 1);
361
+
362
+ expect(event?.sessionId).toBe(id);
363
+ expect((await readIndex(dir))[0]).toMatchObject({
364
+ id,
365
+ eventCount: 1,
366
+ firstPrompt: 'legacy prompt without session id',
367
+ });
368
+
369
+ detach();
370
+ });
371
+
134
372
  it('warns once (not per event) on event-log write failure, recovers on success', async () => {
135
373
  const dir = await makeTempDir();
136
374
  const id = '01WRITEFAIL000000000000000';
@@ -233,6 +471,74 @@ describe('SessionPersistence', () => {
233
471
  expect(again.lines).toHaveLength(0);
234
472
  });
235
473
 
474
+ it('restore removes foreign-session events, creates a backup, and re-sequences survivors', async () => {
475
+ const dir = await makeTempDir();
476
+ const id = '01RESTOREFILTER000000000';
477
+ await fs.mkdir(dir, { recursive: true });
478
+ const logPath = path.join(dir, `${id}.jsonl`);
479
+ const events = [
480
+ {
481
+ id: 'foreign',
482
+ seq: 0,
483
+ ts: 1,
484
+ sessionId: 'other-session',
485
+ turnId: 't1',
486
+ source: 'user',
487
+ type: 'user_prompt',
488
+ text: 'foreign prompt',
489
+ },
490
+ {
491
+ id: 'matching',
492
+ seq: 5,
493
+ ts: 2,
494
+ sessionId: id,
495
+ turnId: 't2',
496
+ source: 'user',
497
+ type: 'user_prompt',
498
+ text: 'matching prompt',
499
+ },
500
+ ];
501
+ await fs.writeFile(logPath, events.map((event) => JSON.stringify(event)).join('\n') + '\n', 'utf8');
502
+
503
+ const { logger, lines } = captureLogger();
504
+ const restored = await restoreEvents(id, dir, logger);
505
+
506
+ expect(restored.map((event) => event.id)).toEqual(['matching']);
507
+ expect(restored.map((event) => event.seq)).toEqual([0]);
508
+ expect(lines.some((line) => line.level === 'warn' && line.msg.includes('foreign-session'))).toBe(true);
509
+ await expect(fs.access(`${logPath}.foreign-session.bak`)).resolves.toBeUndefined();
510
+ const repaired = (await fs.readFile(logPath, 'utf8')).trim().split('\n').map((line) => JSON.parse(line));
511
+ expect(repaired.map((event) => event.id)).toEqual(['matching']);
512
+ expect(repaired.map((event) => event.seq)).toEqual([0]);
513
+ });
514
+
515
+ it('restore rewrites a foreign-only log to an empty session with a backup', async () => {
516
+ const dir = await makeTempDir();
517
+ const id = '01RESTOREEMPTY0000000000';
518
+ await fs.mkdir(dir, { recursive: true });
519
+ const logPath = path.join(dir, `${id}.jsonl`);
520
+ await fs.writeFile(
521
+ logPath,
522
+ JSON.stringify({
523
+ id: 'foreign',
524
+ seq: 0,
525
+ ts: 1,
526
+ sessionId: 'other-session',
527
+ turnId: 't1',
528
+ source: 'user',
529
+ type: 'user_prompt',
530
+ text: 'foreign prompt',
531
+ }) + '\n',
532
+ 'utf8',
533
+ );
534
+
535
+ const restored = await restoreEvents(id, dir, captureLogger().logger);
536
+
537
+ expect(restored).toEqual([]);
538
+ expect(await fs.readFile(logPath, 'utf8')).toBe('');
539
+ await expect(fs.access(`${logPath}.foreign-session.bak`)).resolves.toBeUndefined();
540
+ });
541
+
236
542
  it('rejects a path-traversal session id before touching the filesystem', async () => {
237
543
  const dir = await makeTempDir();
238
544
  // A secret file OUTSIDE the sessions dir that a traversal id would target.
@@ -328,8 +634,8 @@ describe('SessionPersistence', () => {
328
634
  const detachB = new SessionPersistence({ sessionId: idB as never, cwd: '/b', dir }).attach(
329
635
  new EventLog(),
330
636
  );
331
- await waitForFile(path.join(dir, `${idA}.meta.json`));
332
- await waitForFile(path.join(dir, `${idB}.meta.json`));
637
+ await waitForFile(path.join(dir, `${idA}.json`));
638
+ await waitForFile(path.join(dir, `${idB}.json`));
333
639
  const ids = (await readIndex(dir)).map((m) => m.id);
334
640
  expect(ids).toContain(idA);
335
641
  expect(ids).toContain(idB);