repoburg 1.3.199 → 1.3.202
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 +0 -0
- 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 +1658 -277
- package/agent-pane/backend/src/claude/claude-provider.test.ts +393 -3
- package/agent-pane/backend/src/claude/claude-provider.ts +53 -50
- package/agent-pane/backend/src/db/index.ts +12 -0
- package/agent-pane/backend/src/index.ts +5 -2
- package/agent-pane/backend/src/integration/threads-claude-fork.test.ts +241 -0
- package/agent-pane/backend/src/providers/index.ts +3 -1
- package/agent-pane/backend/src/providers/runtime-provider.ts +6 -0
- package/agent-pane/backend/src/repoburg/action-mapping.test.ts +122 -0
- package/agent-pane/backend/src/repoburg/action-mapping.ts +294 -0
- package/agent-pane/backend/src/repoburg/agent-runtime-client.test.ts +271 -0
- package/agent-pane/backend/src/repoburg/agent-runtime-client.ts +285 -0
- package/agent-pane/backend/src/repoburg/backend-resolver.test.ts +356 -0
- package/agent-pane/backend/src/repoburg/backend-resolver.ts +261 -0
- package/agent-pane/backend/src/repoburg/repoburg-client.test.ts +180 -0
- package/agent-pane/backend/src/repoburg/repoburg-client.ts +194 -0
- package/agent-pane/backend/src/repoburg/repoburg-provider.test.ts +780 -0
- package/agent-pane/backend/src/repoburg/repoburg-provider.ts +471 -0
- package/agent-pane/backend/src/repoburg/turn-projector.test.ts +621 -0
- package/agent-pane/backend/src/repoburg/turn-projector.ts +637 -0
- package/agent-pane/backend/src/routes/providers.test.ts +18 -1
- package/agent-pane/backend/src/routes/providers.ts +19 -1
- package/agent-pane/backend/src/routes/sessions.test.ts +215 -1
- package/agent-pane/backend/src/routes/sessions.ts +82 -9
- package/agent-pane/backend/src/routes/threads.ts +21 -6
- package/agent-pane/backend/src/routes/turns.test.ts +45 -1
- package/agent-pane/frontend/dist/assets/index-CJxD4YmJ.js +370 -0
- package/agent-pane/frontend/dist/assets/{index-Dwwhx7YN.css → index-r-xwfOKQ.css} +1 -1
- package/agent-pane/frontend/dist/index.html +2 -2
- package/agent-pane/frontend/src/api/sessions.test.ts +1 -0
- package/agent-pane/frontend/src/api/websocket.test.ts +2 -2
- package/agent-pane/frontend/src/api/websocket.ts +3 -1
- package/agent-pane/frontend/src/components/AskUserItem.test.tsx +143 -0
- package/agent-pane/frontend/src/components/AskUserItem.tsx +207 -0
- package/agent-pane/frontend/src/components/CommentableText.test.tsx +196 -0
- package/agent-pane/frontend/src/components/CommentableText.tsx +109 -21
- package/agent-pane/frontend/src/components/IdeWorkspace.test.tsx +39 -0
- package/agent-pane/frontend/src/components/IdeWorkspace.tsx +28 -2
- package/agent-pane/frontend/src/components/ResizeHandle.tsx +29 -0
- package/agent-pane/frontend/src/components/TurnContent.test.tsx +79 -0
- package/agent-pane/frontend/src/components/TurnContent.tsx +49 -3
- package/agent-pane/frontend/src/components/panels/ThreadsPanel.tsx +35 -3
- package/agent-pane/frontend/src/components/panels/TurnsPanel.test.tsx +48 -0
- package/agent-pane/frontend/src/components/panels/TurnsPanel.tsx +7 -2
- package/agent-pane/frontend/src/constants/models.ts +37 -0
- package/agent-pane/frontend/src/hooks/useResizable.test.ts +97 -0
- package/agent-pane/frontend/src/hooks/useResizable.ts +75 -0
- package/agent-pane/frontend/src/store/store.test.ts +54 -0
- package/agent-pane/frontend/src/store/uiSlice.ts +50 -0
- package/agent-pane/frontend/src/store/wsSlice.ts +10 -3
- package/agent-pane/frontend/src/styles/app.css +26 -1
- package/agent-pane/frontend/src/test/component-helpers.ts +2 -0
- package/agent-pane/frontend/src/test/fixtures.ts +1 -0
- package/agent-pane/frontend/src/types/index.ts +2 -0
- package/agent-pane/frontend/src/utils/duration.test.ts +1 -0
- package/agent-pane/shared/types.ts +17 -0
- package/agent-pane-bug-repro-no-agent-reply.png +0 -0
- package/askuser-after-step5-postgres-selected.png +0 -0
- package/askuser-after-step7-answer-sent.png +0 -0
- package/askuser-card-visible.png +0 -0
- package/backend/dist/src/agent-runtime/agent-runtime-event-emitter.d.ts +36 -0
- package/backend/dist/src/agent-runtime/agent-runtime-event-emitter.js +199 -0
- package/backend/dist/src/agent-runtime/agent-runtime-event-emitter.js.map +1 -0
- package/backend/dist/src/agent-runtime/agent-runtime.controller.d.ts +11 -0
- package/backend/dist/src/agent-runtime/agent-runtime.controller.js +91 -0
- package/backend/dist/src/agent-runtime/agent-runtime.controller.js.map +1 -0
- package/backend/dist/src/agent-runtime/agent-runtime.module.d.ts +2 -0
- package/backend/dist/src/agent-runtime/agent-runtime.module.js +26 -0
- package/backend/dist/src/agent-runtime/agent-runtime.module.js.map +1 -0
- package/backend/dist/src/agent-runtime/agent-runtime.types.d.ts +92 -0
- package/backend/dist/src/agent-runtime/agent-runtime.types.js +3 -0
- package/backend/dist/src/agent-runtime/agent-runtime.types.js.map +1 -0
- package/backend/dist/src/app.controller.d.ts +1 -0
- package/backend/dist/src/app.module.js +4 -0
- package/backend/dist/src/app.module.js.map +1 -1
- package/backend/dist/src/app.service.d.ts +1 -0
- package/backend/dist/src/app.service.js +4 -1
- package/backend/dist/src/app.service.js.map +1 -1
- package/backend/dist/src/interactive-chat/chat.module.js +4 -0
- package/backend/dist/src/interactive-chat/chat.module.js.map +1 -1
- package/backend/dist/src/interactive-chat/chat.service.d.ts +7 -1
- package/backend/dist/src/interactive-chat/chat.service.js +54 -7
- package/backend/dist/src/interactive-chat/chat.service.js.map +1 -1
- package/backend/dist/src/llm-orchestration/action-handlers/invoke-subagent.handler.js +12 -1
- package/backend/dist/src/llm-orchestration/action-handlers/invoke-subagent.handler.js.map +1 -1
- package/backend/dist/src/llm-orchestration/hooks/frontend-notification.hook.d.ts +3 -1
- package/backend/dist/src/llm-orchestration/hooks/frontend-notification.hook.js +20 -2
- package/backend/dist/src/llm-orchestration/hooks/frontend-notification.hook.js.map +1 -1
- package/backend/dist/src/llm-orchestration/llm-orchestration.interfaces.d.ts +1 -0
- package/backend/dist/src/llm-orchestration/llm-orchestration.interfaces.js +1 -0
- package/backend/dist/src/llm-orchestration/llm-orchestration.interfaces.js.map +1 -1
- package/backend/dist/src/llm-orchestration/llm-orchestration.module.js +2 -0
- package/backend/dist/src/llm-orchestration/llm-orchestration.module.js.map +1 -1
- package/backend/dist/src/llm-orchestration/llm-turn-processor.service.d.ts +4 -1
- package/backend/dist/src/llm-orchestration/llm-turn-processor.service.js +37 -2
- package/backend/dist/src/llm-orchestration/llm-turn-processor.service.js.map +1 -1
- package/backend/dist/src/llm-provider/ollama-llm.provider.d.ts +1 -0
- package/backend/dist/src/llm-provider/ollama-llm.provider.js +19 -1
- package/backend/dist/src/llm-provider/ollama-llm.provider.js.map +1 -1
- package/backend/dist/src/llm-responses/llm-responses.module.js +4 -0
- package/backend/dist/src/llm-responses/llm-responses.module.js.map +1 -1
- package/backend/dist/src/llm-responses/llm-responses.service.d.ts +4 -1
- package/backend/dist/src/llm-responses/llm-responses.service.js +52 -2
- package/backend/dist/src/llm-responses/llm-responses.service.js.map +1 -1
- package/backend/dist/tsconfig.build.tsbuildinfo +1 -1
- package/daemon/dist/api/services.js +4 -0
- package/daemon/dist/index.js +0 -0
- package/daemon/dist/serviceManager.js +30 -11
- package/daemon/dist/serviceManager.spec.js +26 -0
- package/daemon/dist/workspace-validation.js +63 -0
- package/daemon/dist/workspace-validation.spec.js +63 -0
- package/package.json +1 -1
- package/r2-step4-apple-banana.png +0 -0
- package/r2-step5-after-rewind-apple-last.png +0 -0
- package/r2-step7-apple-cherry.png +0 -0
- package/r2-step8-cherry-menu-no-restore.png +0 -0
- package/r3-step3-live-task-card-running.png +0 -0
- package/r3-step4-after-completion.png +0 -0
- package/r3-step5-persistence-after-reload.png +0 -0
- package/r3-step6-tasks-tab-card.png +0 -0
- package/reverify-answered-state-answer-sent.png +0 -0
- package/reverify-card-visible-after-completion.png +0 -0
- package/rewind-step3-apple-banana-turns.png +0 -0
- package/rewind-step5-ui-unchanged-after-confirmed-rewind.png +0 -0
- package/rewind-step6-apple-menu-no-restore.png +0 -0
- package/rewind-step7-final-turnlist-unchanged.png +0 -0
- package/rewind-step7-negative-error-toast.png +0 -0
- package/rewind-step7-negative-error-toast2.png +0 -0
- package/subagent-r2-step5-turn-after-reload-no-card.png +0 -0
- package/subagent-r2-step6-tasks-panel-card.png +0 -0
- package/subagent-step3-tasks-tab-done-card.png +0 -0
- package/subagent-step4-turn-stuck-no-cards.png +0 -0
- package/agent-pane/frontend/dist/assets/index-BYo-lnxm.js +0 -362
|
@@ -2,14 +2,21 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
|
2
2
|
import Fastify, { type FastifyInstance } from 'fastify';
|
|
3
3
|
import { initDb, getDb } from '../db/index.ts';
|
|
4
4
|
import { registerSessionRoutes } from './sessions.ts';
|
|
5
|
-
// Mock registry to avoid spawning real provider processes
|
|
5
|
+
// Mock registry to avoid spawning real provider processes.
|
|
6
|
+
// rewindRuntime stays OUT of the default provider (only repoburg has it);
|
|
7
|
+
// tests that exercise the runtime-first path opt in via `useRewindRuntime()`.
|
|
6
8
|
const mocks = vi.hoisted(() => ({
|
|
7
9
|
clearThread: vi.fn(),
|
|
10
|
+
rewindRuntime: vi.fn(),
|
|
11
|
+
forkSession: vi.fn(),
|
|
12
|
+
rewindRuntimeActive: false,
|
|
8
13
|
}));
|
|
9
14
|
|
|
10
15
|
vi.mock('../providers/registry.ts', () => ({
|
|
11
16
|
getProvider: vi.fn(() => ({
|
|
12
17
|
clearThread: mocks.clearThread,
|
|
18
|
+
...(mocks.rewindRuntimeActive ? { rewindRuntime: mocks.rewindRuntime } : {}),
|
|
19
|
+
forkSession: mocks.forkSession,
|
|
13
20
|
startTurn: vi.fn(),
|
|
14
21
|
interruptTurn: vi.fn(),
|
|
15
22
|
clearAllThreads: vi.fn(),
|
|
@@ -65,6 +72,7 @@ describe('Session routes', () => {
|
|
|
65
72
|
|
|
66
73
|
beforeEach(async () => {
|
|
67
74
|
vi.clearAllMocks();
|
|
75
|
+
mocks.rewindRuntimeActive = false; // default provider has NO rewindRuntime
|
|
68
76
|
initDb(':memory:');
|
|
69
77
|
app = Fastify();
|
|
70
78
|
await registerSessionRoutes(app);
|
|
@@ -384,6 +392,94 @@ describe('Session routes', () => {
|
|
|
384
392
|
expect(res.statusCode).toBe(409);
|
|
385
393
|
expect(res.json().error).toContain('Cannot switch provider');
|
|
386
394
|
});
|
|
395
|
+
describe('PATCH /api/sessions/:id — cwd (workspace) change', () => {
|
|
396
|
+
it('clears the thread when cwd changes on a repoburg session', async () => {
|
|
397
|
+
const db = getDb();
|
|
398
|
+
insertSession(db, 's1', {
|
|
399
|
+
runtime_type: 'repoburg',
|
|
400
|
+
cwd: '/tmp/workspace-a',
|
|
401
|
+
external_thread_id: 'rs-1',
|
|
402
|
+
});
|
|
403
|
+
const res = await app.inject({
|
|
404
|
+
method: 'PATCH',
|
|
405
|
+
url: '/api/sessions/s1',
|
|
406
|
+
payload: { cwd: '/tmp/workspace-b' },
|
|
407
|
+
});
|
|
408
|
+
expect(res.statusCode).toBe(200);
|
|
409
|
+
const session = res.json();
|
|
410
|
+
expect(session.cwd).toBe('/tmp/workspace-b');
|
|
411
|
+
expect(session.external_thread_id).toBeNull();
|
|
412
|
+
expect(mocks.clearThread).toHaveBeenCalledWith('s1');
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('clears rewind markers along with the thread on a cwd change', async () => {
|
|
416
|
+
const db = getDb();
|
|
417
|
+
insertSession(db, 's1', { runtime_type: 'claude', cwd: '/tmp/a', external_thread_id: 'thread-1' });
|
|
418
|
+
db.prepare('UPDATE sessions SET rewound_at_turn_id = ?, pre_rewind_thread_id = ? WHERE id = ?')
|
|
419
|
+
.run('turn-1', 'orig-1', 's1');
|
|
420
|
+
|
|
421
|
+
const res = await app.inject({ method: 'PATCH', url: '/api/sessions/s1', payload: { cwd: '/tmp/b' } });
|
|
422
|
+
expect(res.statusCode).toBe(200);
|
|
423
|
+
const session = db.prepare(
|
|
424
|
+
'SELECT rewound_at_turn_id, pre_rewind_thread_id, external_thread_id FROM sessions WHERE id = ?',
|
|
425
|
+
).get('s1') as {
|
|
426
|
+
rewound_at_turn_id: string | null;
|
|
427
|
+
pre_rewind_thread_id: string | null;
|
|
428
|
+
external_thread_id: string | null;
|
|
429
|
+
};
|
|
430
|
+
expect(session.rewound_at_turn_id).toBeNull();
|
|
431
|
+
expect(session.pre_rewind_thread_id).toBeNull();
|
|
432
|
+
expect(session.external_thread_id).toBeNull();
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('returns 409 when changing cwd while a turn is running', async () => {
|
|
436
|
+
const db = getDb();
|
|
437
|
+
insertSession(db, 's1', {
|
|
438
|
+
runtime_type: 'repoburg',
|
|
439
|
+
cwd: '/tmp/a',
|
|
440
|
+
external_thread_id: 'rs-1',
|
|
441
|
+
is_running: 1,
|
|
442
|
+
});
|
|
443
|
+
const res = await app.inject({ method: 'PATCH', url: '/api/sessions/s1', payload: { cwd: '/tmp/b' } });
|
|
444
|
+
expect(res.statusCode).toBe(409);
|
|
445
|
+
expect(res.json().error).toContain('Cannot change workspace');
|
|
446
|
+
expect(mocks.clearThread).not.toHaveBeenCalled();
|
|
447
|
+
const session = db.prepare('SELECT cwd, external_thread_id FROM sessions WHERE id = ?').get('s1') as {
|
|
448
|
+
cwd: string | null;
|
|
449
|
+
external_thread_id: string | null;
|
|
450
|
+
};
|
|
451
|
+
expect(session.cwd).toBe('/tmp/a');
|
|
452
|
+
expect(session.external_thread_id).toBe('rs-1');
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('keeps the thread when the cwd is resolve-equal (trailing slash)', async () => {
|
|
456
|
+
const db = getDb();
|
|
457
|
+
insertSession(db, 's1', {
|
|
458
|
+
runtime_type: 'repoburg',
|
|
459
|
+
cwd: '/tmp/workspace-a',
|
|
460
|
+
external_thread_id: 'rs-1',
|
|
461
|
+
});
|
|
462
|
+
const res = await app.inject({
|
|
463
|
+
method: 'PATCH',
|
|
464
|
+
url: '/api/sessions/s1',
|
|
465
|
+
payload: { cwd: '/tmp/workspace-a/' },
|
|
466
|
+
});
|
|
467
|
+
expect(res.statusCode).toBe(200);
|
|
468
|
+
expect(res.json().cwd).toBe('/tmp/workspace-a/');
|
|
469
|
+
expect(res.json().external_thread_id).toBe('rs-1');
|
|
470
|
+
expect(mocks.clearThread).not.toHaveBeenCalled();
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('clears the thread when cwd is cleared to null', async () => {
|
|
474
|
+
const db = getDb();
|
|
475
|
+
insertSession(db, 's1', { runtime_type: 'repoburg', cwd: '/tmp/a', external_thread_id: 'rs-1' });
|
|
476
|
+
const res = await app.inject({ method: 'PATCH', url: '/api/sessions/s1', payload: { cwd: null } });
|
|
477
|
+
expect(res.statusCode).toBe(200);
|
|
478
|
+
expect(res.json().cwd).toBeNull();
|
|
479
|
+
expect(res.json().external_thread_id).toBeNull();
|
|
480
|
+
expect(mocks.clearThread).toHaveBeenCalledWith('s1');
|
|
481
|
+
});
|
|
482
|
+
});
|
|
387
483
|
});
|
|
388
484
|
|
|
389
485
|
describe('POST /api/sessions/:id/rewind', () => {
|
|
@@ -446,6 +542,104 @@ describe('Session routes', () => {
|
|
|
446
542
|
expect(session.pre_rewind_thread_id).toBe('thread-123');
|
|
447
543
|
expect(mocks.clearThread).toHaveBeenCalledWith('s1');
|
|
448
544
|
});
|
|
545
|
+
it('calls provider.rewindRuntime (runtime-first), hard-deletes local turns after anchor, leaves NO rewind marker', async () => {
|
|
546
|
+
const db = getDb();
|
|
547
|
+
mocks.rewindRuntimeActive = true; // repoburg provider
|
|
548
|
+
insertSession(db, 's1', { runtime_type: 'repoburg', external_thread_id: 'rs-1' });
|
|
549
|
+
insertTurn(db, 't1', 's1', '2025-01-01T00:00:00Z');
|
|
550
|
+
insertTurn(db, 't2', 's1', '2025-01-01T01:00:00Z');
|
|
551
|
+
db.prepare('UPDATE turns SET runtime_input_ids = ? WHERE id = ?').run(
|
|
552
|
+
JSON.stringify(['in-1', 'in-2']),
|
|
553
|
+
't1',
|
|
554
|
+
);
|
|
555
|
+
mocks.rewindRuntime.mockResolvedValueOnce(undefined);
|
|
556
|
+
|
|
557
|
+
const res = await app.inject({ method: 'POST', url: '/api/sessions/s1/rewind', payload: { turn_id: 't1' } });
|
|
558
|
+
expect(res.statusCode).toBe(200);
|
|
559
|
+
expect(mocks.rewindRuntime).toHaveBeenCalledTimes(1);
|
|
560
|
+
const [sessionArg, turnArg] = mocks.rewindRuntime.mock.calls[0];
|
|
561
|
+
expect(sessionArg.external_thread_id).toBe('rs-1');
|
|
562
|
+
expect(turnArg.id).toBe('t1');
|
|
563
|
+
|
|
564
|
+
// Local turns after the anchor are hard-deleted (platform parity).
|
|
565
|
+
const turns = db.prepare('SELECT id FROM turns WHERE session_id = ? ORDER BY created_at').all('s1') as { id: string }[];
|
|
566
|
+
expect(turns).toHaveLength(1);
|
|
567
|
+
expect(turns[0].id).toBe('t1');
|
|
568
|
+
|
|
569
|
+
// NO rewind marker — nothing is hidden or restorable for repoburg.
|
|
570
|
+
const session = db.prepare('SELECT rewound_at_turn_id, pre_rewind_thread_id FROM sessions WHERE id = ?').get('s1') as {
|
|
571
|
+
rewound_at_turn_id: string | null;
|
|
572
|
+
pre_rewind_thread_id: string | null;
|
|
573
|
+
};
|
|
574
|
+
expect(session.rewound_at_turn_id).toBeNull();
|
|
575
|
+
expect(session.pre_rewind_thread_id).toBeNull();
|
|
576
|
+
expect(mocks.clearThread).not.toHaveBeenCalled();
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it('repoburg rewind: turns created AFTER the rewind stay visible in GET /:id (regression)', async () => {
|
|
580
|
+
const db = getDb();
|
|
581
|
+
mocks.rewindRuntimeActive = true; // repoburg provider
|
|
582
|
+
insertSession(db, 's1', { runtime_type: 'repoburg', external_thread_id: 'rs-1' });
|
|
583
|
+
insertTurn(db, 't1', 's1', '2025-01-01T00:00:00Z');
|
|
584
|
+
insertTurn(db, 't2', 's1', '2025-01-01T01:00:00Z');
|
|
585
|
+
mocks.rewindRuntime.mockResolvedValue(undefined);
|
|
586
|
+
|
|
587
|
+
const res = await app.inject({ method: 'POST', url: '/api/sessions/s1/rewind', payload: { turn_id: 't1' } });
|
|
588
|
+
expect(res.statusCode).toBe(200);
|
|
589
|
+
|
|
590
|
+
// A NEW turn created after the rewind must appear in the session view —
|
|
591
|
+
// a leftover rewound_at_turn_id marker would filter it out.
|
|
592
|
+
insertTurn(db, 't3', 's1', '2025-01-02T00:00:00Z');
|
|
593
|
+
|
|
594
|
+
const view = await app.inject({ method: 'GET', url: '/api/sessions/s1' });
|
|
595
|
+
expect(view.statusCode).toBe(200);
|
|
596
|
+
const turnIds = (view.json().turns as { id: string }[]).map((t) => t.id);
|
|
597
|
+
expect(turnIds).toEqual(['t1', 't3']);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it('rewindRuntime failure → NO local rewind (error passes through)', async () => {
|
|
601
|
+
const db = getDb();
|
|
602
|
+
mocks.rewindRuntimeActive = true; // repoburg provider
|
|
603
|
+
insertSession(db, 's1', { runtime_type: 'repoburg', external_thread_id: 'rs-1' });
|
|
604
|
+
insertTurn(db, 't1', 's1', '2025-01-01T00:00:00Z');
|
|
605
|
+
db.prepare('UPDATE turns SET runtime_input_ids = ? WHERE id = ?').run(
|
|
606
|
+
JSON.stringify(['in-1']),
|
|
607
|
+
't1',
|
|
608
|
+
);
|
|
609
|
+
mocks.rewindRuntime.mockRejectedValueOnce(
|
|
610
|
+
new Error('DELETE /sessions/rs-1/inputs/in-1/until-here failed: 400 {"message":"Summaries can only be discarded, not deleted."}'),
|
|
611
|
+
);
|
|
612
|
+
|
|
613
|
+
const res = await app.inject({ method: 'POST', url: '/api/sessions/s1/rewind', payload: { turn_id: 't1' } });
|
|
614
|
+
expect(res.statusCode).toBe(500);
|
|
615
|
+
expect(res.json().message).toContain('Summaries can only be discarded');
|
|
616
|
+
|
|
617
|
+
// Local view untouched — no rewind markers, no clearThread
|
|
618
|
+
const session = db.prepare('SELECT rewound_at_turn_id, pre_rewind_thread_id FROM sessions WHERE id = ?').get('s1') as {
|
|
619
|
+
rewound_at_turn_id: string | null;
|
|
620
|
+
pre_rewind_thread_id: string | null;
|
|
621
|
+
};
|
|
622
|
+
expect(session.rewound_at_turn_id).toBeNull();
|
|
623
|
+
expect(session.pre_rewind_thread_id).toBeNull();
|
|
624
|
+
expect(mocks.clearThread).not.toHaveBeenCalled();
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
it('rewindRuntime NOT called when the provider does not implement it (codex)', async () => {
|
|
628
|
+
const db = getDb();
|
|
629
|
+
insertSession(db, 's1', { external_thread_id: 'thread-123' });
|
|
630
|
+
insertTurn(db, 't1', 's1', '2025-01-01T00:00:00Z');
|
|
631
|
+
const { getProvider } = await import('../providers/registry.ts');
|
|
632
|
+
vi.mocked(getProvider).mockReturnValueOnce({ clearThread: mocks.clearThread } as never);
|
|
633
|
+
|
|
634
|
+
const res = await app.inject({ method: 'POST', url: '/api/sessions/s1/rewind', payload: { turn_id: 't1' } });
|
|
635
|
+
expect(res.statusCode).toBe(200);
|
|
636
|
+
expect(mocks.rewindRuntime).not.toHaveBeenCalled();
|
|
637
|
+
|
|
638
|
+
const session = db.prepare('SELECT rewound_at_turn_id FROM sessions WHERE id = ?').get('s1') as {
|
|
639
|
+
rewound_at_turn_id: string | null;
|
|
640
|
+
};
|
|
641
|
+
expect(session.rewound_at_turn_id).toBe('t1');
|
|
642
|
+
});
|
|
449
643
|
|
|
450
644
|
it('GET session filters turns when rewound', async () => {
|
|
451
645
|
const db = getDb();
|
|
@@ -491,6 +685,26 @@ describe('Session routes', () => {
|
|
|
491
685
|
const getRes = await app.inject({ method: 'GET', url: '/api/sessions/s1' });
|
|
492
686
|
expect(getRes.json().turns).toHaveLength(2);
|
|
493
687
|
});
|
|
688
|
+
it('restore returns 409 for repoburg sessions (runtime history permanently deleted)', async () => {
|
|
689
|
+
// Simulate a LEGACY rewound repoburg session (marker set by a
|
|
690
|
+
// pre-hard-delete rewind) — restore must still be refused.
|
|
691
|
+
const db = getDb();
|
|
692
|
+
insertSession(db, 's1', { runtime_type: 'repoburg', external_thread_id: 'rs-1' });
|
|
693
|
+
insertTurn(db, 't1', 's1', '2025-01-01T00:00:00Z');
|
|
694
|
+
db.prepare(
|
|
695
|
+
'UPDATE sessions SET rewound_at_turn_id = ?, pre_rewind_thread_id = ? WHERE id = ?',
|
|
696
|
+
).run('t1', 'rs-1', 's1');
|
|
697
|
+
|
|
698
|
+
const res = await app.inject({ method: 'POST', url: '/api/sessions/s1/restore' });
|
|
699
|
+
expect(res.statusCode).toBe(409);
|
|
700
|
+
expect(res.json().error).toContain('permanently deleted');
|
|
701
|
+
|
|
702
|
+
// Rewind state stays intact
|
|
703
|
+
const session = db.prepare('SELECT rewound_at_turn_id FROM sessions WHERE id = ?').get('s1') as {
|
|
704
|
+
rewound_at_turn_id: string | null;
|
|
705
|
+
};
|
|
706
|
+
expect(session.rewound_at_turn_id).toBe('t1');
|
|
707
|
+
});
|
|
494
708
|
|
|
495
709
|
it('restore returns 409 when not rewound', async () => {
|
|
496
710
|
const db = getDb();
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
import { v4 as uuidv4 } from 'uuid';
|
|
3
4
|
import { getDb } from '../db/index.ts';
|
|
4
5
|
import { getProvider } from '../providers/registry.ts';
|
|
5
6
|
import type { Session, SessionDetail, Turn, Item, DiscussionThread, Attachment } from '../db/schema.ts';
|
|
6
7
|
|
|
7
|
-
/** Raw DB row for turns — token_usage and
|
|
8
|
-
|
|
8
|
+
/** Raw DB row for turns — token_usage, attachments and runtime_input_ids are
|
|
9
|
+
* JSON strings, not parsed; no items field. */
|
|
10
|
+
interface TurnRow extends Omit<Turn, 'token_usage' | 'items' | 'attachments' | 'runtime_input_ids'> {
|
|
9
11
|
token_usage: string | null;
|
|
10
12
|
attachments: string | null;
|
|
13
|
+
runtime_input_ids: string | null;
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
/** Raw DB row for items — data is a JSON string, not parsed. */
|
|
@@ -71,6 +74,7 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
|
|
|
71
74
|
...row,
|
|
72
75
|
token_usage: row.token_usage ? JSON.parse(row.token_usage) : null,
|
|
73
76
|
attachments: row.attachments ? (JSON.parse(row.attachments) as Attachment[]) : null,
|
|
77
|
+
runtime_input_ids: row.runtime_input_ids ? (JSON.parse(row.runtime_input_ids) as string[]) : null,
|
|
74
78
|
items: itemsByTurn.get(row.id) ?? [],
|
|
75
79
|
})),
|
|
76
80
|
};
|
|
@@ -87,6 +91,18 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
|
|
|
87
91
|
}));
|
|
88
92
|
}
|
|
89
93
|
|
|
94
|
+
// Restore persisted context-window usage so the status-bar counter survives reload.
|
|
95
|
+
const cuRow = db.prepare('SELECT context_usage FROM sessions WHERE id = ?').get(id) as
|
|
96
|
+
| { context_usage: string | null }
|
|
97
|
+
| undefined;
|
|
98
|
+
if (cuRow?.context_usage) {
|
|
99
|
+
try {
|
|
100
|
+
detail.contextUsage = JSON.parse(cuRow.context_usage) as SessionDetail['contextUsage'];
|
|
101
|
+
} catch {
|
|
102
|
+
// Corrupt JSON — leave contextUsage unset.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
90
106
|
return detail;
|
|
91
107
|
});
|
|
92
108
|
|
|
@@ -148,7 +164,7 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
|
|
|
148
164
|
return { success: true };
|
|
149
165
|
});
|
|
150
166
|
|
|
151
|
-
// PATCH /api/sessions/:id — update session (title, lifecycle, has_unreviewed, model, model_reasoning_effort, runtime_config)
|
|
167
|
+
// PATCH /api/sessions/:id — update session (title, lifecycle, has_unreviewed, model, model_reasoning_effort, runtime_config, cwd)
|
|
152
168
|
fastify.patch<{
|
|
153
169
|
Params: { id: string };
|
|
154
170
|
Body: {
|
|
@@ -159,6 +175,7 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
|
|
|
159
175
|
model_reasoning_effort?: string | null;
|
|
160
176
|
runtime_config?: string | null;
|
|
161
177
|
runtime_type?: string;
|
|
178
|
+
cwd?: string | null;
|
|
162
179
|
};
|
|
163
180
|
}>('/api/sessions/:id', {
|
|
164
181
|
schema: {
|
|
@@ -172,12 +189,13 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
|
|
|
172
189
|
model_reasoning_effort: { type: ['string', 'null'] },
|
|
173
190
|
runtime_config: { type: ['string', 'null'] },
|
|
174
191
|
runtime_type: { type: 'string' },
|
|
192
|
+
cwd: { type: ['string', 'null'] },
|
|
175
193
|
},
|
|
176
194
|
},
|
|
177
195
|
},
|
|
178
196
|
}, async (request, reply) => {
|
|
179
197
|
const { id } = request.params;
|
|
180
|
-
const { title, lifecycle, has_unreviewed, model, model_reasoning_effort, runtime_config, runtime_type } = request.body || {};
|
|
198
|
+
const { title, lifecycle, has_unreviewed, model, model_reasoning_effort, runtime_config, runtime_type, cwd } = request.body || {};
|
|
181
199
|
const db = getDb();
|
|
182
200
|
|
|
183
201
|
const existing = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as Session | undefined;
|
|
@@ -227,6 +245,23 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
|
|
|
227
245
|
}
|
|
228
246
|
}
|
|
229
247
|
|
|
248
|
+
// Workspace (cwd) change — the runtime thread belongs to the old
|
|
249
|
+
// workspace backend, so it is cleared like a provider switch; the next
|
|
250
|
+
// turn creates a fresh runtime session on the new workspace.
|
|
251
|
+
// Compare resolve-normalized paths — cosmetic differences (trailing
|
|
252
|
+
// slash) are not a workspace change.
|
|
253
|
+
let workspaceChanged = false;
|
|
254
|
+
if (cwd !== undefined) {
|
|
255
|
+
const oldAbs = existing.cwd ? path.resolve(existing.cwd) : null;
|
|
256
|
+
const newAbs = cwd ? path.resolve(cwd) : null;
|
|
257
|
+
workspaceChanged = oldAbs !== newAbs;
|
|
258
|
+
if (workspaceChanged && existing.is_running) {
|
|
259
|
+
return reply.code(409).send({ error: 'Cannot change workspace while a turn is running' });
|
|
260
|
+
}
|
|
261
|
+
updates.push('cwd = ?');
|
|
262
|
+
params.push(cwd);
|
|
263
|
+
}
|
|
264
|
+
|
|
230
265
|
// Model and reasoning changes update DB columns but do NOT clear the thread.
|
|
231
266
|
// The Claude SDK supports query({ resume: sessionId, model: newModel }) —
|
|
232
267
|
// changing the model within the same provider preserves conversation context.
|
|
@@ -257,11 +292,14 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
|
|
|
257
292
|
|
|
258
293
|
db.prepare(`UPDATE sessions SET ${updates.join(', ')} WHERE id = ?`).run(...params, id);
|
|
259
294
|
|
|
260
|
-
//
|
|
295
|
+
// Clear the thread when the PROVIDER or WORKSPACE changes — the runtime
|
|
296
|
+
// session belongs to the old provider/backend and cannot be resumed.
|
|
261
297
|
// Model/reasoning changes within the same provider keep the thread intact.
|
|
262
|
-
if (providerChanged
|
|
263
|
-
|
|
264
|
-
|
|
298
|
+
if (providerChanged || workspaceChanged) {
|
|
299
|
+
if (existing.external_thread_id) {
|
|
300
|
+
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);
|
|
301
|
+
}
|
|
302
|
+
// Drop the in-memory entry even without a persisted thread id (stale state).
|
|
265
303
|
getProvider(existing.runtime_type).clearThread(id);
|
|
266
304
|
}
|
|
267
305
|
|
|
@@ -306,6 +344,35 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
|
|
|
306
344
|
return reply.code(404).send({ error: 'Turn not found' });
|
|
307
345
|
}
|
|
308
346
|
|
|
347
|
+
const provider = getProvider(session.runtime_type);
|
|
348
|
+
|
|
349
|
+
// RUNTIME-FIRST (repoburg in-place truncation): hard-delete the runtime
|
|
350
|
+
// history after the target turn BEFORE any local change. Errors pass
|
|
351
|
+
// through — on failure the local view stays untouched and consistent
|
|
352
|
+
// with the runtime (e.g. 400 summary-in-range from until-here).
|
|
353
|
+
if (provider.rewindRuntime) {
|
|
354
|
+
await provider.rewindRuntime(session, targetTurnFull);
|
|
355
|
+
|
|
356
|
+
// Platform-parity HARD DELETE: the runtime history after the anchor
|
|
357
|
+
// is permanently gone, so mirror it locally — delete the local turns
|
|
358
|
+
// after the target and leave NO rewind marker (nothing to restore).
|
|
359
|
+
// Stale markers are cleared too: a marker would hide every turn
|
|
360
|
+
// created after the rewind (GET /:id filters by rewound_at_turn_id).
|
|
361
|
+
const now2 = new Date().toISOString();
|
|
362
|
+
db.prepare(
|
|
363
|
+
'DELETE FROM items WHERE turn_id IN (SELECT id FROM turns WHERE session_id = ? AND created_at > ?)',
|
|
364
|
+
).run(id, targetTurnFull.created_at);
|
|
365
|
+
db.prepare('DELETE FROM turns WHERE session_id = ? AND created_at > ?').run(
|
|
366
|
+
id,
|
|
367
|
+
targetTurnFull.created_at,
|
|
368
|
+
);
|
|
369
|
+
db.prepare(
|
|
370
|
+
'UPDATE sessions SET rewound_at_turn_id = NULL, pre_rewind_thread_id = NULL, updated_at = ? WHERE id = ?',
|
|
371
|
+
).run(now2, id);
|
|
372
|
+
const truncated = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as Session;
|
|
373
|
+
return truncated;
|
|
374
|
+
}
|
|
375
|
+
|
|
309
376
|
const now = new Date().toISOString();
|
|
310
377
|
|
|
311
378
|
// Mark the session as rewound (non-destructive — turns are NOT deleted, just hidden)
|
|
@@ -324,7 +391,6 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
|
|
|
324
391
|
}
|
|
325
392
|
|
|
326
393
|
// Fork the provider-native session so the provider has conversation context up to the target turn
|
|
327
|
-
const provider = getProvider(session.runtime_type);
|
|
328
394
|
if (provider.forkSession) {
|
|
329
395
|
try {
|
|
330
396
|
const forkedId = await provider.forkSession(session, targetTurnFull);
|
|
@@ -359,6 +425,13 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
|
|
|
359
425
|
if (!session.rewound_at_turn_id) {
|
|
360
426
|
return reply.code(409).send({ error: 'Session is not rewound' });
|
|
361
427
|
}
|
|
428
|
+
// Repoburg rewind = platform-parity HARD DELETE — the runtime history was
|
|
429
|
+
// permanently truncated, so local turns can never be restored.
|
|
430
|
+
if (session.runtime_type === 'repoburg') {
|
|
431
|
+
return reply.code(409).send({
|
|
432
|
+
error: 'Restore is not available for repoburg sessions — the rewind permanently deleted the runtime history.',
|
|
433
|
+
});
|
|
434
|
+
}
|
|
362
435
|
|
|
363
436
|
const now = new Date().toISOString();
|
|
364
437
|
// Restore the original external_thread_id and clear rewind markers
|
|
@@ -72,16 +72,31 @@ export async function registerThreadRoutes(fastify: FastifyInstance): Promise<vo
|
|
|
72
72
|
const provider = getProvider(session.runtime_type);
|
|
73
73
|
if (provider.forkSession) {
|
|
74
74
|
try {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
75
|
+
if (!session.external_thread_id) {
|
|
76
|
+
// No main session to fork from — the thread will start a fresh
|
|
77
|
+
// isolated session (startTurn skips the resume fallback), so the
|
|
78
|
+
// main session is never contaminated.
|
|
79
|
+
console.warn(`[threads] cannot fork for thread ${threadId}: session ${sessionId} has no external_thread_id — thread will start a fresh isolated session`);
|
|
80
|
+
} else {
|
|
81
|
+
const anchorTurn = db.prepare('SELECT * FROM turns WHERE id = ?').get(turnId) as Turn | undefined;
|
|
82
|
+
if (anchorTurn) {
|
|
83
|
+
const forkedSessionId = await provider.forkSession(session, anchorTurn);
|
|
84
|
+
if (forkedSessionId) {
|
|
85
|
+
db.prepare('UPDATE discussion_threads SET external_thread_id = ? WHERE id = ?').run(forkedSessionId, threadId);
|
|
86
|
+
console.log(`[threads] forked to ${forkedSessionId} for thread ${threadId} (session ${sessionId})`);
|
|
87
|
+
} else {
|
|
88
|
+
// forkSession returned null — thread will start a fresh isolated
|
|
89
|
+
// session in startTurn (NOT the main session), so isolation holds.
|
|
90
|
+
console.warn(`[threads] forkSession returned null for thread ${threadId} (external_thread_id=${session.external_thread_id}, upToMessageId=${anchorTurn.external_turn_id}) — thread will start a fresh isolated session`);
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
console.warn(`[threads] anchor turn ${turnId} not found for thread ${threadId} — cannot fork`);
|
|
80
94
|
}
|
|
81
95
|
}
|
|
82
96
|
} catch (err) {
|
|
83
97
|
console.error(`[threads] forkSession failed for thread ${threadId}:`, err);
|
|
84
|
-
// Non-fatal — thread falls back to
|
|
98
|
+
// Non-fatal — thread falls back to a fresh isolated session (NOT the
|
|
99
|
+
// main session) so the main conversation is never contaminated.
|
|
85
100
|
}
|
|
86
101
|
}
|
|
87
102
|
|
|
@@ -54,7 +54,9 @@ describe('Turn routes', () => {
|
|
|
54
54
|
beforeEach(async () => {
|
|
55
55
|
vi.clearAllMocks();
|
|
56
56
|
initDb(':memory:');
|
|
57
|
-
|
|
57
|
+
// Mirror the production Fastify config (index.ts) — bodyLimit must exceed the
|
|
58
|
+
// default 1 MiB so base64 image attachments fit in the JSON body.
|
|
59
|
+
app = Fastify({ bodyLimit: 50 * 1024 * 1024 });
|
|
58
60
|
await registerTurnRoutes(app);
|
|
59
61
|
});
|
|
60
62
|
|
|
@@ -122,6 +124,48 @@ describe('Turn routes', () => {
|
|
|
122
124
|
expect(sessionArg.id).toBe('s1');
|
|
123
125
|
expect(turnArg.user_prompt).toBe('hello');
|
|
124
126
|
});
|
|
127
|
+
|
|
128
|
+
it('accepts a large base64 image attachment (>1 MiB) — regression for 413 Payload Too Large', async () => {
|
|
129
|
+
const db = getDb();
|
|
130
|
+
insertSession(db, 's1');
|
|
131
|
+
// ~2 MB of base64 — exceeds Fastify's default 1 MiB bodyLimit; the production
|
|
132
|
+
// server (index.ts) raises it to 50 MB so image attachments fit.
|
|
133
|
+
const bigBase64 = 'A'.repeat(2 * 1024 * 1024);
|
|
134
|
+
const res = await app.inject({
|
|
135
|
+
method: 'POST',
|
|
136
|
+
url: '/api/sessions/s1/turns',
|
|
137
|
+
payload: {
|
|
138
|
+
user_prompt: 'look at this',
|
|
139
|
+
attachments: [{ name: 'shot.png', media_type: 'image/png', data: bigBase64 }],
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
expect(res.statusCode).toBe(200);
|
|
143
|
+
const turn = res.json();
|
|
144
|
+
expect(turn.attachments).toEqual([{ name: 'shot.png', media_type: 'image/png', data: bigBase64 }]);
|
|
145
|
+
const [, turnArg] = mocks.startTurn.mock.calls[0];
|
|
146
|
+
// startTurn receives the raw DB row — attachments is still a JSON string there.
|
|
147
|
+
expect(typeof turnArg.attachments).toBe('string');
|
|
148
|
+
expect(JSON.parse(turnArg.attachments)).toEqual([{ name: 'shot.png', media_type: 'image/png', data: bigBase64 }]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('returns 413 on the default 1 MiB bodyLimit — documents why index.ts raises it', async () => {
|
|
152
|
+
const db = getDb();
|
|
153
|
+
insertSession(db, 's1');
|
|
154
|
+
const defaultApp = Fastify(); // default bodyLimit = 1 MiB
|
|
155
|
+
await registerTurnRoutes(defaultApp);
|
|
156
|
+
const bigBase64 = 'A'.repeat(2 * 1024 * 1024);
|
|
157
|
+
const res = await defaultApp.inject({
|
|
158
|
+
method: 'POST',
|
|
159
|
+
url: '/api/sessions/s1/turns',
|
|
160
|
+
payload: {
|
|
161
|
+
user_prompt: 'look at this',
|
|
162
|
+
attachments: [{ name: 'shot.png', media_type: 'image/png', data: bigBase64 }],
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
expect(res.statusCode).toBe(413);
|
|
166
|
+
expect(mocks.startTurn).not.toHaveBeenCalled();
|
|
167
|
+
await defaultApp.close();
|
|
168
|
+
});
|
|
125
169
|
});
|
|
126
170
|
|
|
127
171
|
describe('POST /api/sessions/:id/cancel', () => {
|