repoburg 1.3.196 → 1.3.198
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/agent-pane/backend/data.db-shm +0 -0
- package/agent-pane/backend/data.db-wal +0 -0
- package/agent-pane/backend/dist/index.js +13399 -10390
- package/agent-pane/backend/package-lock.json +162 -92
- package/agent-pane/backend/scripts/backfill-from-jsonl.ts +206 -0
- package/agent-pane/backend/scripts/import-session-from-jsonl.ts +264 -0
- package/agent-pane/backend/src/claude/claude-message-mapper.test.ts +116 -8
- package/agent-pane/backend/src/claude/claude-message-mapper.ts +114 -9
- package/agent-pane/backend/src/claude/claude-provider.ts +918 -84
- package/agent-pane/backend/src/claude/claude-types.ts +16 -4
- package/agent-pane/backend/src/codex/app-server-client.ts +22 -0
- package/agent-pane/backend/src/db/index.ts +102 -0
- package/agent-pane/backend/src/db/schema.ts +4 -0
- package/agent-pane/backend/src/index.ts +3 -1
- package/agent-pane/backend/src/integration/helpers/test-app.ts +2 -0
- package/agent-pane/backend/src/integration/session-operations.test.ts +18 -11
- package/agent-pane/backend/src/integration/threads.test.ts +309 -0
- package/agent-pane/backend/src/providers/runtime-provider.ts +19 -1
- package/agent-pane/backend/src/routes/providers.ts +11 -0
- package/agent-pane/backend/src/routes/sessions.test.ts +143 -9
- package/agent-pane/backend/src/routes/sessions.ts +133 -28
- package/agent-pane/backend/src/routes/threads.ts +308 -0
- package/agent-pane/backend/src/routes/turns.ts +32 -9
- package/agent-pane/frontend/dist/assets/index-BYo-lnxm.js +362 -0
- package/agent-pane/frontend/dist/assets/index-Dwwhx7YN.css +1 -0
- package/agent-pane/frontend/dist/index.html +2 -2
- package/agent-pane/frontend/package-lock.json +51 -9
- package/agent-pane/frontend/src/__tests__/ThreadsPanel.test.tsx +192 -0
- package/agent-pane/frontend/src/api/providers.ts +6 -1
- package/agent-pane/frontend/src/api/sessions.test.ts +4 -0
- package/agent-pane/frontend/src/api/sessions.ts +13 -5
- package/agent-pane/frontend/src/api/websocket.ts +65 -1
- package/agent-pane/frontend/src/components/CollapsibleToolCalls.test.tsx +4 -4
- package/agent-pane/frontend/src/components/CollapsibleToolCalls.tsx +13 -4
- package/agent-pane/frontend/src/components/CommandInputBar.test.tsx +2 -2
- package/agent-pane/frontend/src/components/CommandInputBar.tsx +292 -29
- package/agent-pane/frontend/src/components/CommandPalette.test.tsx +0 -4
- package/agent-pane/frontend/src/components/CommandPalette.tsx +1 -17
- package/agent-pane/frontend/src/components/CommentableText.tsx +291 -0
- package/agent-pane/frontend/src/components/RenderArea.tsx +9 -2
- package/agent-pane/frontend/src/components/RightPanel.test.tsx +5 -5
- package/agent-pane/frontend/src/components/RightPanel.tsx +20 -14
- package/agent-pane/frontend/src/components/SessionTabs.tsx +7 -2
- package/agent-pane/frontend/src/components/StatusBar.tsx +68 -1
- package/agent-pane/frontend/src/components/TurnContent.test.tsx +29 -18
- package/agent-pane/frontend/src/components/TurnContent.tsx +199 -28
- package/agent-pane/frontend/src/components/input/SlashPopup.tsx +24 -5
- package/agent-pane/frontend/src/components/panels/TasksPanel.test.tsx +97 -0
- package/agent-pane/frontend/src/components/panels/TasksPanel.tsx +208 -0
- package/agent-pane/frontend/src/components/panels/ThreadsPanel.tsx +350 -0
- package/agent-pane/frontend/src/components/panels/TurnsPanel.tsx +47 -25
- package/agent-pane/frontend/src/hooks/useSessionStatus.test.ts +2 -0
- package/agent-pane/frontend/src/store/sessionSlice.ts +29 -6
- package/agent-pane/frontend/src/store/store.test.ts +5 -5
- package/agent-pane/frontend/src/store/store.ts +4 -1
- package/agent-pane/frontend/src/store/threadSlice.ts +136 -0
- package/agent-pane/frontend/src/store/wsSlice.ts +72 -3
- package/agent-pane/frontend/src/styles/app.css +150 -19
- package/agent-pane/frontend/src/test/fixtures.ts +4 -0
- package/agent-pane/frontend/src/types/index.ts +5 -2
- package/agent-pane/frontend/src/utils/duration.test.ts +4 -0
- package/agent-pane/frontend/vite.config.ts +2 -2
- package/agent-pane/shared/provider-types.ts +14 -0
- package/agent-pane/shared/types.ts +60 -0
- package/backend/dist/src/llm-provider/ollama-llm.provider.js +1 -0
- package/backend/dist/src/llm-provider/ollama-llm.provider.js.map +1 -1
- package/backend/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/agent-pane/frontend/dist/assets/index-Bg0PF2aV.css +0 -1
- package/agent-pane/frontend/dist/assets/index-BlybeX5e.js +0 -281
- package/agent-pane/frontend/src/components/panels/LogsPanel.test.tsx +0 -58
- package/agent-pane/frontend/src/components/panels/LogsPanel.tsx +0 -25
- package/agent-pane/frontend/src/components/panels/MetricsPanel.test.tsx +0 -71
- package/agent-pane/frontend/src/components/panels/MetricsPanel.tsx +0 -114
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { FastifyInstance } from 'fastify';
|
|
2
2
|
import type { ProviderDefinition } from '@agentpane/shared';
|
|
3
|
+
import { hasProvider, getProvider } from '../providers/registry.ts';
|
|
3
4
|
|
|
4
5
|
/** Codex provider definition — all capabilities enabled. */
|
|
5
6
|
const CODEX_PROVIDER: ProviderDefinition = {
|
|
@@ -54,4 +55,14 @@ export async function registerProviderRoutes(fastify: FastifyInstance): Promise<
|
|
|
54
55
|
}
|
|
55
56
|
return provider;
|
|
56
57
|
});
|
|
58
|
+
|
|
59
|
+
// GET /api/providers/:id/commands — provider-native slash commands (dynamic, e.g. Claude CLI)
|
|
60
|
+
fastify.get<{ Params: { id: string } }>('/api/providers/:id/commands', async (request, reply) => {
|
|
61
|
+
const id = request.params.id;
|
|
62
|
+
if (!hasProvider(id)) {
|
|
63
|
+
return reply.code(404).send({ error: 'Provider not found' });
|
|
64
|
+
}
|
|
65
|
+
const provider = getProvider(id);
|
|
66
|
+
return provider.getCommands ? await provider.getCommands() : [];
|
|
67
|
+
});
|
|
57
68
|
}
|
|
@@ -243,16 +243,94 @@ describe('Session routes', () => {
|
|
|
243
243
|
expect(res.json().has_unreviewed).toBe(1);
|
|
244
244
|
});
|
|
245
245
|
|
|
246
|
-
it('
|
|
246
|
+
it('preserves thread when model changes (same provider)', async () => {
|
|
247
247
|
const db = getDb();
|
|
248
248
|
insertSession(db, 's1', { model: 'gpt-4', external_thread_id: 'thread-123' });
|
|
249
249
|
const res = await app.inject({ method: 'PATCH', url: '/api/sessions/s1', payload: { model: 'claude-3' } });
|
|
250
250
|
expect(res.statusCode).toBe(200);
|
|
251
|
-
expect(mocks.clearThread).
|
|
252
|
-
const session = db.prepare('SELECT external_thread_id FROM sessions WHERE id = ?').get('s1') as {
|
|
251
|
+
expect(mocks.clearThread).not.toHaveBeenCalled();
|
|
252
|
+
const session = db.prepare('SELECT external_thread_id, model FROM sessions WHERE id = ?').get('s1') as {
|
|
253
253
|
external_thread_id: string | null;
|
|
254
|
+
model: string | null;
|
|
254
255
|
};
|
|
255
|
-
expect(session.external_thread_id).
|
|
256
|
+
expect(session.external_thread_id).toBe('thread-123');
|
|
257
|
+
expect(session.model).toBe('claude-3');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('preserves thread when reasoning effort changes (same provider)', async () => {
|
|
261
|
+
const db = getDb();
|
|
262
|
+
insertSession(db, 's1', { model: 'claude-sonnet-4', model_reasoning_effort: 'medium', external_thread_id: 'thread-123' });
|
|
263
|
+
const res = await app.inject({
|
|
264
|
+
method: 'PATCH',
|
|
265
|
+
url: '/api/sessions/s1',
|
|
266
|
+
payload: { model: 'claude-sonnet-4', model_reasoning_effort: 'high' },
|
|
267
|
+
});
|
|
268
|
+
expect(res.statusCode).toBe(200);
|
|
269
|
+
expect(mocks.clearThread).not.toHaveBeenCalled();
|
|
270
|
+
const session = db.prepare('SELECT external_thread_id, model_reasoning_effort FROM sessions WHERE id = ?').get('s1') as {
|
|
271
|
+
external_thread_id: string | null;
|
|
272
|
+
model_reasoning_effort: string | null;
|
|
273
|
+
};
|
|
274
|
+
expect(session.external_thread_id).toBe('thread-123');
|
|
275
|
+
expect(session.model_reasoning_effort).toBe('high');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('preserves thread when setting model from null to a value', async () => {
|
|
279
|
+
const db = getDb();
|
|
280
|
+
insertSession(db, 's1', { model: null, external_thread_id: 'thread-123' });
|
|
281
|
+
const res = await app.inject({ method: 'PATCH', url: '/api/sessions/s1', payload: { model: 'claude-sonnet-4' } });
|
|
282
|
+
expect(res.statusCode).toBe(200);
|
|
283
|
+
expect(mocks.clearThread).not.toHaveBeenCalled();
|
|
284
|
+
const session = db.prepare('SELECT external_thread_id, model FROM sessions WHERE id = ?').get('s1') as {
|
|
285
|
+
external_thread_id: string | null;
|
|
286
|
+
model: string | null;
|
|
287
|
+
};
|
|
288
|
+
expect(session.external_thread_id).toBe('thread-123');
|
|
289
|
+
expect(session.model).toBe('claude-sonnet-4');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('preserves thread and rewind state when model changes on rewound session', async () => {
|
|
293
|
+
const db = getDb();
|
|
294
|
+
insertSession(db, 's1', { model: 'claude-sonnet-4', external_thread_id: 'forked-123' });
|
|
295
|
+
// Simulate a rewound session
|
|
296
|
+
db.prepare('UPDATE sessions SET rewound_at_turn_id = ?, pre_rewind_thread_id = ? WHERE id = ?')
|
|
297
|
+
.run('turn-1', 'original-123', 's1');
|
|
298
|
+
|
|
299
|
+
const res = await app.inject({
|
|
300
|
+
method: 'PATCH',
|
|
301
|
+
url: '/api/sessions/s1',
|
|
302
|
+
payload: { model: 'claude-opus-4' },
|
|
303
|
+
});
|
|
304
|
+
expect(res.statusCode).toBe(200);
|
|
305
|
+
expect(mocks.clearThread).not.toHaveBeenCalled();
|
|
306
|
+
const session = db.prepare('SELECT external_thread_id, rewound_at_turn_id, pre_rewind_thread_id, model FROM sessions WHERE id = ?').get('s1') as {
|
|
307
|
+
external_thread_id: string | null;
|
|
308
|
+
rewound_at_turn_id: string | null;
|
|
309
|
+
pre_rewind_thread_id: string | null;
|
|
310
|
+
model: string | null;
|
|
311
|
+
};
|
|
312
|
+
expect(session.external_thread_id).toBe('forked-123');
|
|
313
|
+
expect(session.rewound_at_turn_id).toBe('turn-1');
|
|
314
|
+
expect(session.pre_rewind_thread_id).toBe('original-123');
|
|
315
|
+
expect(session.model).toBe('claude-opus-4');
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('preserves thread when model changes on finished session', async () => {
|
|
319
|
+
const db = getDb();
|
|
320
|
+
insertSession(db, 's1', { model: 'claude-sonnet-4', external_thread_id: 'thread-123', lifecycle: 'finished' });
|
|
321
|
+
const res = await app.inject({
|
|
322
|
+
method: 'PATCH',
|
|
323
|
+
url: '/api/sessions/s1',
|
|
324
|
+
payload: { model: 'claude-opus-4' },
|
|
325
|
+
});
|
|
326
|
+
expect(res.statusCode).toBe(200);
|
|
327
|
+
expect(mocks.clearThread).not.toHaveBeenCalled();
|
|
328
|
+
const session = db.prepare('SELECT external_thread_id, model FROM sessions WHERE id = ?').get('s1') as {
|
|
329
|
+
external_thread_id: string | null;
|
|
330
|
+
model: string | null;
|
|
331
|
+
};
|
|
332
|
+
expect(session.external_thread_id).toBe('thread-123');
|
|
333
|
+
expect(session.model).toBe('claude-opus-4');
|
|
256
334
|
});
|
|
257
335
|
|
|
258
336
|
it('does not clear thread when model is unchanged', async () => {
|
|
@@ -342,7 +420,7 @@ describe('Session routes', () => {
|
|
|
342
420
|
expect(res.json().error).toBe('Turn not found');
|
|
343
421
|
});
|
|
344
422
|
|
|
345
|
-
it('
|
|
423
|
+
it('marks session as rewound (non-destructive) and saves pre_rewind_thread_id', async () => {
|
|
346
424
|
const db = getDb();
|
|
347
425
|
insertSession(db, 's1', { external_thread_id: 'thread-123' });
|
|
348
426
|
insertTurn(db, 't1', 's1', '2025-01-01T00:00:00Z');
|
|
@@ -352,18 +430,74 @@ describe('Session routes', () => {
|
|
|
352
430
|
const res = await app.inject({ method: 'POST', url: '/api/sessions/s1/rewind', payload: { turn_id: 't1' } });
|
|
353
431
|
expect(res.statusCode).toBe(200);
|
|
354
432
|
|
|
433
|
+
// Turns are NOT deleted — all 3 still exist
|
|
355
434
|
const turns = db.prepare('SELECT id FROM turns WHERE session_id = ? ORDER BY created_at').all('s1') as {
|
|
356
435
|
id: string;
|
|
357
436
|
}[];
|
|
358
|
-
expect(turns).toHaveLength(
|
|
359
|
-
expect(turns[0].id).toBe('t1');
|
|
437
|
+
expect(turns).toHaveLength(3);
|
|
360
438
|
|
|
361
|
-
|
|
439
|
+
// Session is marked as rewound
|
|
440
|
+
const session = db.prepare('SELECT rewound_at_turn_id, pre_rewind_thread_id, external_thread_id FROM sessions WHERE id = ?').get('s1') as {
|
|
441
|
+
rewound_at_turn_id: string | null;
|
|
442
|
+
pre_rewind_thread_id: string | null;
|
|
362
443
|
external_thread_id: string | null;
|
|
363
444
|
};
|
|
364
|
-
expect(session.
|
|
445
|
+
expect(session.rewound_at_turn_id).toBe('t1');
|
|
446
|
+
expect(session.pre_rewind_thread_id).toBe('thread-123');
|
|
365
447
|
expect(mocks.clearThread).toHaveBeenCalledWith('s1');
|
|
366
448
|
});
|
|
449
|
+
|
|
450
|
+
it('GET session filters turns when rewound', async () => {
|
|
451
|
+
const db = getDb();
|
|
452
|
+
insertSession(db, 's1');
|
|
453
|
+
insertTurn(db, 't1', 's1', '2025-01-01T00:00:00Z');
|
|
454
|
+
insertTurn(db, 't2', 's1', '2025-01-01T01:00:00Z');
|
|
455
|
+
insertTurn(db, 't3', 's1', '2025-01-01T02:00:00Z');
|
|
456
|
+
|
|
457
|
+
// Rewind to t2
|
|
458
|
+
await app.inject({ method: 'POST', url: '/api/sessions/s1/rewind', payload: { turn_id: 't2' } });
|
|
459
|
+
|
|
460
|
+
// GET should only show t1 and t2 (not t3)
|
|
461
|
+
const res = await app.inject({ method: 'GET', url: '/api/sessions/s1' });
|
|
462
|
+
expect(res.statusCode).toBe(200);
|
|
463
|
+
expect(res.json().turns).toHaveLength(2);
|
|
464
|
+
expect(res.json().turns[0].id).toBe('t1');
|
|
465
|
+
expect(res.json().turns[1].id).toBe('t2');
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('restore undoes rewind and shows all turns', async () => {
|
|
469
|
+
const db = getDb();
|
|
470
|
+
insertSession(db, 's1', { external_thread_id: 'thread-123' });
|
|
471
|
+
insertTurn(db, 't1', 's1', '2025-01-01T00:00:00Z');
|
|
472
|
+
insertTurn(db, 't2', 's1', '2025-01-01T01:00:00Z');
|
|
473
|
+
|
|
474
|
+
// Rewind
|
|
475
|
+
await app.inject({ method: 'POST', url: '/api/sessions/s1/rewind', payload: { turn_id: 't1' } });
|
|
476
|
+
|
|
477
|
+
// Restore
|
|
478
|
+
const res = await app.inject({ method: 'POST', url: '/api/sessions/s1/restore' });
|
|
479
|
+
expect(res.statusCode).toBe(200);
|
|
480
|
+
|
|
481
|
+
const session = db.prepare('SELECT rewound_at_turn_id, pre_rewind_thread_id, external_thread_id FROM sessions WHERE id = ?').get('s1') as {
|
|
482
|
+
rewound_at_turn_id: string | null;
|
|
483
|
+
pre_rewind_thread_id: string | null;
|
|
484
|
+
external_thread_id: string | null;
|
|
485
|
+
};
|
|
486
|
+
expect(session.rewound_at_turn_id).toBeNull();
|
|
487
|
+
expect(session.pre_rewind_thread_id).toBeNull();
|
|
488
|
+
expect(session.external_thread_id).toBe('thread-123');
|
|
489
|
+
|
|
490
|
+
// GET shows all turns again
|
|
491
|
+
const getRes = await app.inject({ method: 'GET', url: '/api/sessions/s1' });
|
|
492
|
+
expect(getRes.json().turns).toHaveLength(2);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it('restore returns 409 when not rewound', async () => {
|
|
496
|
+
const db = getDb();
|
|
497
|
+
insertSession(db, 's1');
|
|
498
|
+
const res = await app.inject({ method: 'POST', url: '/api/sessions/s1/restore' });
|
|
499
|
+
expect(res.statusCode).toBe(409);
|
|
500
|
+
});
|
|
367
501
|
});
|
|
368
502
|
|
|
369
503
|
describe('POST /api/sessions/:id/branch', () => {
|
|
@@ -2,11 +2,12 @@ import type { FastifyInstance } from 'fastify';
|
|
|
2
2
|
import { v4 as uuidv4 } from 'uuid';
|
|
3
3
|
import { getDb } from '../db/index.ts';
|
|
4
4
|
import { getProvider } from '../providers/registry.ts';
|
|
5
|
-
import type { Session, SessionDetail, Turn, Item } from '../db/schema.ts';
|
|
5
|
+
import type { Session, SessionDetail, Turn, Item, DiscussionThread, Attachment } from '../db/schema.ts';
|
|
6
6
|
|
|
7
|
-
/** Raw DB row for turns — token_usage
|
|
8
|
-
interface TurnRow extends Omit<Turn, 'token_usage' | 'items'> {
|
|
7
|
+
/** Raw DB row for turns — token_usage and attachments are JSON strings, not parsed; no items field. */
|
|
8
|
+
interface TurnRow extends Omit<Turn, 'token_usage' | 'items' | 'attachments'> {
|
|
9
9
|
token_usage: string | null;
|
|
10
|
+
attachments: string | null;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
/** Raw DB row for items — data is a JSON string, not parsed. */
|
|
@@ -31,14 +32,26 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
|
|
|
31
32
|
return reply.code(404).send({ error: 'Session not found' });
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
.
|
|
35
|
+
// If the session is rewound, only show turns up to and including the rewind point
|
|
36
|
+
const turnFilter = session.rewound_at_turn_id
|
|
37
|
+
? db.prepare('SELECT created_at FROM turns WHERE id = ?').get(session.rewound_at_turn_id) as { created_at: string } | undefined
|
|
38
|
+
: null;
|
|
39
|
+
|
|
40
|
+
const turnRows = turnFilter
|
|
41
|
+
? db
|
|
42
|
+
.prepare('SELECT * FROM turns WHERE session_id = ? AND created_at <= ? ORDER BY created_at ASC')
|
|
43
|
+
.all(id, turnFilter.created_at) as TurnRow[]
|
|
44
|
+
: db
|
|
45
|
+
.prepare('SELECT * FROM turns WHERE session_id = ? ORDER BY created_at ASC')
|
|
46
|
+
.all(id) as TurnRow[];
|
|
47
|
+
|
|
37
48
|
const itemRows = db
|
|
38
49
|
.prepare(
|
|
39
|
-
'SELECT * FROM items WHERE turn_id IN (SELECT id FROM turns WHERE session_id = ?
|
|
50
|
+
'SELECT * FROM items WHERE turn_id IN (SELECT id FROM turns WHERE session_id = ?' +
|
|
51
|
+
(turnFilter ? ' AND created_at <= ?' : '') +
|
|
52
|
+
') ORDER BY created_at ASC',
|
|
40
53
|
)
|
|
41
|
-
.all(id) as ItemRow[];
|
|
54
|
+
.all(...(turnFilter ? [id, turnFilter.created_at] : [id])) as ItemRow[];
|
|
42
55
|
|
|
43
56
|
// Parse item data from JSON string and group by turn_id
|
|
44
57
|
const itemsByTurn = new Map<string, Item[]>();
|
|
@@ -57,10 +70,23 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
|
|
|
57
70
|
turns: turnRows.map((row) => ({
|
|
58
71
|
...row,
|
|
59
72
|
token_usage: row.token_usage ? JSON.parse(row.token_usage) : null,
|
|
73
|
+
attachments: row.attachments ? (JSON.parse(row.attachments) as Attachment[]) : null,
|
|
60
74
|
items: itemsByTurn.get(row.id) ?? [],
|
|
61
75
|
})),
|
|
62
76
|
};
|
|
63
77
|
|
|
78
|
+
// Fetch discussion threads for this session and attach to the response
|
|
79
|
+
const threadRows = db
|
|
80
|
+
.prepare('SELECT * FROM discussion_threads WHERE session_id = ? ORDER BY created_at ASC')
|
|
81
|
+
.all(id) as DiscussionThread[];
|
|
82
|
+
|
|
83
|
+
if (threadRows.length > 0) {
|
|
84
|
+
detail.threads = threadRows.map((tr) => ({
|
|
85
|
+
...tr,
|
|
86
|
+
turns: detail.turns.filter((t) => t.thread_id === tr.id),
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
|
|
64
90
|
return detail;
|
|
65
91
|
});
|
|
66
92
|
|
|
@@ -168,7 +194,7 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
|
|
|
168
194
|
const updates: string[] = ['updated_at = ?'];
|
|
169
195
|
const params: (string | number | null)[] = [now];
|
|
170
196
|
|
|
171
|
-
let
|
|
197
|
+
let providerChanged = false;
|
|
172
198
|
|
|
173
199
|
if (title !== undefined) {
|
|
174
200
|
updates.push('title = ?');
|
|
@@ -185,7 +211,7 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
|
|
|
185
211
|
|
|
186
212
|
// Provider switch — clear model/reasoning if not explicitly provided
|
|
187
213
|
if (runtime_type !== undefined && runtime_type !== existing.runtime_type) {
|
|
188
|
-
|
|
214
|
+
providerChanged = true; // different provider can't resume the old thread
|
|
189
215
|
updates.push('runtime_type = ?');
|
|
190
216
|
params.push(runtime_type);
|
|
191
217
|
|
|
@@ -201,15 +227,28 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
|
|
|
201
227
|
}
|
|
202
228
|
}
|
|
203
229
|
|
|
230
|
+
// Model and reasoning changes update DB columns but do NOT clear the thread.
|
|
231
|
+
// The Claude SDK supports query({ resume: sessionId, model: newModel }) —
|
|
232
|
+
// changing the model within the same provider preserves conversation context.
|
|
233
|
+
// In streaming-input mode, also call query.setModel() on the live session
|
|
234
|
+
// so the CLI switches models without restarting.
|
|
204
235
|
if (model !== undefined) {
|
|
205
236
|
updates.push('model = ?');
|
|
206
237
|
params.push(model);
|
|
207
|
-
|
|
238
|
+
// If the provider supports mid-session model switching and the model
|
|
239
|
+
// actually changed (same provider), update the live query.
|
|
240
|
+
if (model !== existing.model && !providerChanged) {
|
|
241
|
+
const provider = getProvider(existing.runtime_type);
|
|
242
|
+
if (provider.setModel) {
|
|
243
|
+
provider.setModel(id, model ?? '').catch((err) => {
|
|
244
|
+
console.error(`[patch] setModel failed for session ${id}:`, err);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
208
248
|
}
|
|
209
249
|
if (model_reasoning_effort !== undefined) {
|
|
210
250
|
updates.push('model_reasoning_effort = ?');
|
|
211
251
|
params.push(model_reasoning_effort);
|
|
212
|
-
if (model_reasoning_effort !== existing.model_reasoning_effort) modelChanged = true;
|
|
213
252
|
}
|
|
214
253
|
if (runtime_config !== undefined) {
|
|
215
254
|
updates.push('runtime_config = ?');
|
|
@@ -218,9 +257,10 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
|
|
|
218
257
|
|
|
219
258
|
db.prepare(`UPDATE sessions SET ${updates.join(', ')} WHERE id = ?`).run(...params, id);
|
|
220
259
|
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
260
|
+
// Only clear the thread when the PROVIDER changes (different provider can't resume).
|
|
261
|
+
// Model/reasoning changes within the same provider keep the thread intact.
|
|
262
|
+
if (providerChanged && existing.external_thread_id) {
|
|
263
|
+
db.prepare('UPDATE sessions SET external_thread_id = NULL, rewound_at_turn_id = NULL, pre_rewind_thread_id = NULL, updated_at = ? WHERE id = ?').run(now, id);
|
|
224
264
|
// Use old provider to clear its in-memory thread state
|
|
225
265
|
getProvider(existing.runtime_type).clearThread(id);
|
|
226
266
|
}
|
|
@@ -229,7 +269,7 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
|
|
|
229
269
|
return session;
|
|
230
270
|
});
|
|
231
271
|
|
|
232
|
-
// POST /api/sessions/:id/rewind —
|
|
272
|
+
// POST /api/sessions/:id/rewind — non-destructive rewind: hide turns after target, fork provider session
|
|
233
273
|
fastify.post<{ Params: { id: string }; Body: { turn_id: string } }>(
|
|
234
274
|
'/api/sessions/:id/rewind',
|
|
235
275
|
{
|
|
@@ -259,27 +299,80 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
|
|
|
259
299
|
return reply.code(409).send({ error: 'Cannot rewind while a turn is running' });
|
|
260
300
|
}
|
|
261
301
|
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
302
|
+
const targetTurnFull = db.prepare('SELECT * FROM turns WHERE id = ? AND session_id = ?').get(turn_id, id) as
|
|
303
|
+
| Turn
|
|
304
|
+
| undefined;
|
|
305
|
+
if (!targetTurnFull) {
|
|
265
306
|
return reply.code(404).send({ error: 'Turn not found' });
|
|
266
307
|
}
|
|
267
308
|
|
|
268
|
-
// Delete turns after the target (cascade deletes items via FK)
|
|
269
|
-
db.prepare('DELETE FROM turns WHERE session_id = ? AND created_at > ?').run(id, targetTurn.created_at);
|
|
270
|
-
|
|
271
|
-
// Null out external_thread_id — next turn creates a fresh Codex thread
|
|
272
309
|
const now = new Date().toISOString();
|
|
273
|
-
db.prepare('UPDATE sessions SET external_thread_id = NULL, updated_at = ? WHERE id = ?').run(now, id);
|
|
274
310
|
|
|
275
|
-
//
|
|
276
|
-
|
|
311
|
+
// Mark the session as rewound (non-destructive — turns are NOT deleted, just hidden)
|
|
312
|
+
// Save the original external_thread_id only on the first rewind (don't overwrite on subsequent rewinds)
|
|
313
|
+
// Use explicit two-step logic instead of COALESCE to avoid any evaluation-order ambiguity
|
|
314
|
+
if (!session.pre_rewind_thread_id && session.external_thread_id) {
|
|
315
|
+
db.prepare(
|
|
316
|
+
'UPDATE sessions SET rewound_at_turn_id = ?, pre_rewind_thread_id = ?, updated_at = ? WHERE id = ?',
|
|
317
|
+
).run(turn_id, session.external_thread_id, now, id);
|
|
318
|
+
console.log(`[rewind] saved pre_rewind_thread_id=${session.external_thread_id} for session ${id}`);
|
|
319
|
+
} else {
|
|
320
|
+
db.prepare(
|
|
321
|
+
'UPDATE sessions SET rewound_at_turn_id = ?, updated_at = ? WHERE id = ?',
|
|
322
|
+
).run(turn_id, now, id);
|
|
323
|
+
console.log(`[rewind] pre_rewind_thread_id already set (${session.pre_rewind_thread_id}) or external_thread_id is null for session ${id}`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Fork the provider-native session so the provider has conversation context up to the target turn
|
|
327
|
+
const provider = getProvider(session.runtime_type);
|
|
328
|
+
if (provider.forkSession) {
|
|
329
|
+
try {
|
|
330
|
+
const forkedId = await provider.forkSession(session, targetTurnFull);
|
|
331
|
+
if (forkedId) {
|
|
332
|
+
db.prepare('UPDATE sessions SET external_thread_id = ?, updated_at = ? WHERE id = ?')
|
|
333
|
+
.run(forkedId, now, id);
|
|
334
|
+
console.log(`[rewind] forked to ${forkedId} for session ${id}`);
|
|
335
|
+
} else {
|
|
336
|
+
console.warn(`[rewind] forkSession returned null for session ${id} (external_thread_id=${session.external_thread_id}, upToMessageId=${targetTurnFull.external_turn_id})`);
|
|
337
|
+
}
|
|
338
|
+
} catch (err) {
|
|
339
|
+
console.error(`[rewind] forkSession threw for session ${id}:`, err);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Clear in-memory thread entry (the Claude session ID changed)
|
|
344
|
+
provider.clearThread(id);
|
|
277
345
|
|
|
278
346
|
const updated = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as Session;
|
|
279
347
|
return updated;
|
|
280
348
|
},
|
|
281
349
|
);
|
|
282
350
|
|
|
351
|
+
// POST /api/sessions/:id/restore — undo rewind: show all turns again, restore original thread
|
|
352
|
+
fastify.post<{ Params: { id: string } }>('/api/sessions/:id/restore', async (request, reply) => {
|
|
353
|
+
const { id } = request.params;
|
|
354
|
+
const db = getDb();
|
|
355
|
+
const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as Session | undefined;
|
|
356
|
+
if (!session) {
|
|
357
|
+
return reply.code(404).send({ error: 'Session not found' });
|
|
358
|
+
}
|
|
359
|
+
if (!session.rewound_at_turn_id) {
|
|
360
|
+
return reply.code(409).send({ error: 'Session is not rewound' });
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const now = new Date().toISOString();
|
|
364
|
+
// Restore the original external_thread_id and clear rewind markers
|
|
365
|
+
db.prepare(
|
|
366
|
+
'UPDATE sessions SET rewound_at_turn_id = NULL, pre_rewind_thread_id = NULL, external_thread_id = COALESCE(pre_rewind_thread_id, external_thread_id), updated_at = ? WHERE id = ?',
|
|
367
|
+
).run(now, id);
|
|
368
|
+
|
|
369
|
+
// Clear in-memory thread entry (the Claude session ID changed back)
|
|
370
|
+
getProvider(session.runtime_type).clearThread(id);
|
|
371
|
+
|
|
372
|
+
const updated = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as Session;
|
|
373
|
+
return updated;
|
|
374
|
+
});
|
|
375
|
+
|
|
283
376
|
// POST /api/sessions/:id/branch — create new session with turns up to and including target turn
|
|
284
377
|
fastify.post<{ Params: { id: string }; Body: { turn_id: string; title?: string } }>(
|
|
285
378
|
'/api/sessions/:id/branch',
|
|
@@ -342,8 +435,8 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
|
|
|
342
435
|
for (const turn of turnRows) {
|
|
343
436
|
const newTurnId = uuidv4();
|
|
344
437
|
db.prepare(
|
|
345
|
-
`INSERT INTO turns (id, session_id, user_prompt, agent_response, thoughts, token_usage, external_turn_id, status, error, created_at, updated_at)
|
|
346
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
438
|
+
`INSERT INTO turns (id, session_id, user_prompt, agent_response, thoughts, token_usage, external_turn_id, status, error, attachments, created_at, updated_at)
|
|
439
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
347
440
|
).run(
|
|
348
441
|
newTurnId,
|
|
349
442
|
newSessionId,
|
|
@@ -354,6 +447,7 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
|
|
|
354
447
|
turn.external_turn_id,
|
|
355
448
|
turn.status,
|
|
356
449
|
turn.error,
|
|
450
|
+
turn.attachments,
|
|
357
451
|
turn.created_at,
|
|
358
452
|
turn.updated_at,
|
|
359
453
|
);
|
|
@@ -380,6 +474,17 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
|
|
|
380
474
|
}
|
|
381
475
|
}
|
|
382
476
|
|
|
477
|
+
// Fork the provider-native session so the provider has conversation context
|
|
478
|
+
const provider = getProvider(session.runtime_type);
|
|
479
|
+
if (provider.forkSession) {
|
|
480
|
+
const targetTurnFull = db.prepare('SELECT * FROM turns WHERE id = ?').get(turn_id) as Turn;
|
|
481
|
+
const forkedId = await provider.forkSession(session, targetTurnFull);
|
|
482
|
+
if (forkedId) {
|
|
483
|
+
db.prepare('UPDATE sessions SET external_thread_id = ?, updated_at = ? WHERE id = ?')
|
|
484
|
+
.run(forkedId, now, newSessionId);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
383
488
|
const newSession = db.prepare('SELECT * FROM sessions WHERE id = ?').get(newSessionId) as Session;
|
|
384
489
|
return newSession;
|
|
385
490
|
},
|