repoburg 1.3.196 → 1.3.197

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 (71) hide show
  1. package/agent-pane/backend/data.db-shm +0 -0
  2. package/agent-pane/backend/data.db-wal +0 -0
  3. package/agent-pane/backend/dist/index.js +13399 -10390
  4. package/agent-pane/backend/package-lock.json +162 -92
  5. package/agent-pane/backend/scripts/backfill-from-jsonl.ts +206 -0
  6. package/agent-pane/backend/scripts/import-session-from-jsonl.ts +264 -0
  7. package/agent-pane/backend/src/claude/claude-message-mapper.test.ts +116 -8
  8. package/agent-pane/backend/src/claude/claude-message-mapper.ts +114 -9
  9. package/agent-pane/backend/src/claude/claude-provider.ts +918 -84
  10. package/agent-pane/backend/src/claude/claude-types.ts +16 -4
  11. package/agent-pane/backend/src/codex/app-server-client.ts +22 -0
  12. package/agent-pane/backend/src/db/index.ts +102 -0
  13. package/agent-pane/backend/src/db/schema.ts +4 -0
  14. package/agent-pane/backend/src/index.ts +3 -1
  15. package/agent-pane/backend/src/integration/helpers/test-app.ts +2 -0
  16. package/agent-pane/backend/src/integration/session-operations.test.ts +18 -11
  17. package/agent-pane/backend/src/integration/threads.test.ts +309 -0
  18. package/agent-pane/backend/src/providers/runtime-provider.ts +19 -1
  19. package/agent-pane/backend/src/routes/providers.ts +11 -0
  20. package/agent-pane/backend/src/routes/sessions.test.ts +143 -9
  21. package/agent-pane/backend/src/routes/sessions.ts +133 -28
  22. package/agent-pane/backend/src/routes/threads.ts +308 -0
  23. package/agent-pane/backend/src/routes/turns.ts +32 -9
  24. package/agent-pane/frontend/dist/assets/index-BYo-lnxm.js +362 -0
  25. package/agent-pane/frontend/dist/assets/index-Dwwhx7YN.css +1 -0
  26. package/agent-pane/frontend/dist/index.html +2 -2
  27. package/agent-pane/frontend/package-lock.json +51 -9
  28. package/agent-pane/frontend/src/__tests__/ThreadsPanel.test.tsx +192 -0
  29. package/agent-pane/frontend/src/api/providers.ts +6 -1
  30. package/agent-pane/frontend/src/api/sessions.test.ts +4 -0
  31. package/agent-pane/frontend/src/api/sessions.ts +13 -5
  32. package/agent-pane/frontend/src/api/websocket.ts +65 -1
  33. package/agent-pane/frontend/src/components/CollapsibleToolCalls.test.tsx +4 -4
  34. package/agent-pane/frontend/src/components/CollapsibleToolCalls.tsx +13 -4
  35. package/agent-pane/frontend/src/components/CommandInputBar.test.tsx +2 -2
  36. package/agent-pane/frontend/src/components/CommandInputBar.tsx +292 -29
  37. package/agent-pane/frontend/src/components/CommandPalette.test.tsx +0 -4
  38. package/agent-pane/frontend/src/components/CommandPalette.tsx +1 -17
  39. package/agent-pane/frontend/src/components/CommentableText.tsx +291 -0
  40. package/agent-pane/frontend/src/components/RenderArea.tsx +9 -2
  41. package/agent-pane/frontend/src/components/RightPanel.test.tsx +5 -5
  42. package/agent-pane/frontend/src/components/RightPanel.tsx +20 -14
  43. package/agent-pane/frontend/src/components/SessionTabs.tsx +7 -2
  44. package/agent-pane/frontend/src/components/StatusBar.tsx +68 -1
  45. package/agent-pane/frontend/src/components/TurnContent.test.tsx +29 -18
  46. package/agent-pane/frontend/src/components/TurnContent.tsx +199 -28
  47. package/agent-pane/frontend/src/components/input/SlashPopup.tsx +24 -5
  48. package/agent-pane/frontend/src/components/panels/TasksPanel.test.tsx +97 -0
  49. package/agent-pane/frontend/src/components/panels/TasksPanel.tsx +208 -0
  50. package/agent-pane/frontend/src/components/panels/ThreadsPanel.tsx +350 -0
  51. package/agent-pane/frontend/src/components/panels/TurnsPanel.tsx +47 -25
  52. package/agent-pane/frontend/src/hooks/useSessionStatus.test.ts +2 -0
  53. package/agent-pane/frontend/src/store/sessionSlice.ts +29 -6
  54. package/agent-pane/frontend/src/store/store.test.ts +5 -5
  55. package/agent-pane/frontend/src/store/store.ts +4 -1
  56. package/agent-pane/frontend/src/store/threadSlice.ts +136 -0
  57. package/agent-pane/frontend/src/store/wsSlice.ts +72 -3
  58. package/agent-pane/frontend/src/styles/app.css +150 -19
  59. package/agent-pane/frontend/src/test/fixtures.ts +4 -0
  60. package/agent-pane/frontend/src/types/index.ts +5 -2
  61. package/agent-pane/frontend/src/utils/duration.test.ts +4 -0
  62. package/agent-pane/frontend/vite.config.ts +2 -2
  63. package/agent-pane/shared/provider-types.ts +14 -0
  64. package/agent-pane/shared/types.ts +60 -0
  65. package/package.json +1 -1
  66. package/agent-pane/frontend/dist/assets/index-Bg0PF2aV.css +0 -1
  67. package/agent-pane/frontend/dist/assets/index-BlybeX5e.js +0 -281
  68. package/agent-pane/frontend/src/components/panels/LogsPanel.test.tsx +0 -58
  69. package/agent-pane/frontend/src/components/panels/LogsPanel.tsx +0 -25
  70. package/agent-pane/frontend/src/components/panels/MetricsPanel.test.tsx +0 -71
  71. package/agent-pane/frontend/src/components/panels/MetricsPanel.tsx +0 -114
@@ -12,15 +12,27 @@ import type {
12
12
  ModelInfo as SdkModelInfo,
13
13
  SDKControlInitializeResponse,
14
14
  EffortLevel,
15
+ SDKUserMessage,
15
16
  } from '@anthropic-ai/claude-agent-sdk';
16
17
 
17
- export type { Query, SDKMessage, Options, EffortLevel, SdkModelInfo, SDKControlInitializeResponse };
18
+ export type { Query, SDKMessage, Options, EffortLevel, SdkModelInfo, SDKControlInitializeResponse, SDKUserMessage };
18
19
 
19
- /** Per-session Claude query instance + state. */
20
+ /** Structural type for the PromptQueue — avoids circular imports. */
21
+ export interface PromptQueueLike {
22
+ push(message: SDKUserMessage): void;
23
+ close(): void;
24
+ }
25
+
26
+ /** Per-session Claude query instance + streaming-input state. */
20
27
  export interface ClaudeSessionEntry {
21
28
  query: Query;
22
- sessionId: string | null; // Claude session ID from SDKSystemMessage init
23
- turnId: string | null; // current AgentPane turn ID
29
+ sessionId: string | null; // Claude session ID from SDKSystemMessage init
30
+ turnId: string | null; // current AgentPane turn ID
31
+ cwd: string; // session working directory — used to construct Monitor output paths
32
+ promptQueue: PromptQueueLike; // async iterable feeding user messages to the CLI
33
+ messageLoop: Promise<void> | null; // background for-await loop consuming SDK messages
34
+ /** Thread ID if this entry is for a branched thread conversation (null = main session). */
35
+ threadId?: string | null;
24
36
  }
25
37
 
26
38
  /** Map SDK effort values to UI labels. */
@@ -68,6 +68,12 @@ export class AppServerClient {
68
68
  this.proc.on('exit', (code, signal) => {
69
69
  this.onProcessExit(code, signal);
70
70
  });
71
+
72
+ // Handle spawn errors (e.g. binary not found → ENOENT). Without this listener,
73
+ // an unhandled 'error' event crashes the entire process. See onProcessError.
74
+ this.proc.on('error', (err: Error) => {
75
+ this.onProcessError(err);
76
+ });
71
77
  }
72
78
 
73
79
  /** Perform the initialize handshake. Must be called before any other method. */
@@ -221,5 +227,21 @@ export class AppServerClient {
221
227
 
222
228
  this.events.emit('closed', reason);
223
229
  }
230
+
231
+ /** Handle a spawn/runtime error on the child process (e.g. ENOENT when the
232
+ * binary is missing). Rejects pending requests and emits 'closed' so callers
233
+ * can fail gracefully instead of crashing the whole process. */
234
+ private onProcessError(err: Error): void {
235
+ if (this.closed) return;
236
+ this.closed = true;
237
+
238
+ for (const [, entry] of this.pending) {
239
+ clearTimeout(entry.timer);
240
+ entry.reject(err);
241
+ }
242
+ this.pending.clear();
243
+
244
+ this.events.emit('closed', err.message);
245
+ }
224
246
  }
225
247
 
@@ -17,6 +17,7 @@ export function initDb(path: string = 'data.db'): Database.Database {
17
17
  db.pragma('foreign_keys = ON');
18
18
 
19
19
  createTables(db);
20
+ cleanupStaleState(db);
20
21
  return db;
21
22
  }
22
23
 
@@ -33,6 +34,35 @@ export function getDb(): Database.Database {
33
34
  return db;
34
35
  }
35
36
 
37
+ /**
38
+ * Clean up stale running state from a previous backend process that crashed
39
+ * or was killed while a turn was in progress.
40
+ *
41
+ * Resets all sessions with `is_running = 1` to `is_running = 0, status = 'active'`,
42
+ * and marks all turns with `status = 'running'` as `failed` with an explanatory
43
+ * error message. This prevents a deadlock where the frontend sees stale running
44
+ * state and blocks the input bar after a backend restart.
45
+ *
46
+ * No-op if no stale state exists (0 rows affected). Safe for tests (fresh DB).
47
+ */
48
+ function cleanupStaleState(database: Database.Database): void {
49
+ const now = new Date().toISOString();
50
+
51
+ // Mark ALL in_progress items as failed — after a backend restart, no background
52
+ // task can still be running (the CLI process is dead). This includes in_progress
53
+ // background_task items inside completed turns (Monitor/Subagent tasks that
54
+ // outlive their turn via streaming-input mode).
55
+ const staleItems = database.prepare(
56
+ "UPDATE items SET status = 'failed', updated_at = ? WHERE status = 'in_progress'",
57
+ ).run(now);
58
+
59
+ const staleSessions = database.prepare('UPDATE sessions SET is_running = 0, status = ?, updated_at = ? WHERE is_running = 1').run('active', now);
60
+ const staleTurns = database.prepare("UPDATE turns SET status = 'failed', error = ?, updated_at = ? WHERE status = 'running'").run('Backend restarted while turn was running', now);
61
+ if (staleSessions.changes > 0 || staleTurns.changes > 0 || staleItems.changes > 0) {
62
+ console.log(`[db] Cleaned up stale state: ${staleSessions.changes} sessions, ${staleTurns.changes} turns, ${staleItems.changes} items`);
63
+ }
64
+ }
65
+
36
66
  /**
37
67
  * Create all tables and run inline migrations for new columns.
38
68
  *
@@ -92,9 +122,26 @@ function createTables(database: Database.Database): void {
92
122
  created_at TEXT NOT NULL
93
123
  );
94
124
 
125
+ CREATE TABLE IF NOT EXISTS discussion_threads (
126
+ id TEXT PRIMARY KEY,
127
+ session_id TEXT NOT NULL,
128
+ item_id TEXT NOT NULL,
129
+ turn_id TEXT NOT NULL,
130
+ start_offset INTEGER NOT NULL,
131
+ end_offset INTEGER NOT NULL,
132
+ selected_text TEXT NOT NULL,
133
+ status TEXT NOT NULL DEFAULT 'open',
134
+ resolved_at TEXT,
135
+ created_at TEXT NOT NULL,
136
+ updated_at TEXT NOT NULL,
137
+ FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
138
+ );
139
+
95
140
  CREATE INDEX IF NOT EXISTS idx_turns_session_id ON turns(session_id);
96
141
  CREATE INDEX IF NOT EXISTS idx_items_turn_id ON items(turn_id);
97
142
  CREATE INDEX IF NOT EXISTS idx_workspaces_path ON workspaces(path);
143
+ CREATE INDEX IF NOT EXISTS idx_discussion_threads_session_id ON discussion_threads(session_id);
144
+ CREATE INDEX IF NOT EXISTS idx_discussion_threads_item_id ON discussion_threads(item_id);
98
145
  `);
99
146
 
100
147
  // Migration: add 'deleted' column to existing workspaces table if missing
@@ -119,4 +166,59 @@ function createTables(database: Database.Database): void {
119
166
  if (!sessColumns.some((c) => c.name === 'runtime_config')) {
120
167
  database.exec('ALTER TABLE sessions ADD COLUMN runtime_config TEXT');
121
168
  }
169
+
170
+ // Migration: add 'rewound_at_turn_id' + 'pre_rewind_thread_id' columns for non-destructive rewind
171
+ if (!sessColumns.some((c) => c.name === 'rewound_at_turn_id')) {
172
+ database.exec('ALTER TABLE sessions ADD COLUMN rewound_at_turn_id TEXT');
173
+ }
174
+ if (!sessColumns.some((c) => c.name === 'pre_rewind_thread_id')) {
175
+ database.exec('ALTER TABLE sessions ADD COLUMN pre_rewind_thread_id TEXT');
176
+ }
177
+
178
+ // Migration: add 'thread_id' column to turns table (discussion threads feature)
179
+ const turnColumns = database.prepare('PRAGMA table_info(turns)').all() as { name: string }[];
180
+ if (!turnColumns.some((c) => c.name === 'thread_id')) {
181
+ database.exec('ALTER TABLE turns ADD COLUMN thread_id TEXT');
182
+ }
183
+ database.exec('CREATE INDEX IF NOT EXISTS idx_turns_thread_id ON turns(thread_id)');
184
+
185
+ // Migration: add 'attachments' column to turns table (file upload feature)
186
+ if (!turnColumns.some((c) => c.name === 'attachments')) {
187
+ database.exec('ALTER TABLE turns ADD COLUMN attachments TEXT');
188
+ }
189
+
190
+ // Migration: add 'external_thread_id' column to discussion_threads (thread branching)
191
+ const threadColumns = database.prepare('PRAGMA table_info(discussion_threads)').all() as { name: string }[];
192
+ if (!threadColumns.some((c) => c.name === 'external_thread_id')) {
193
+ database.exec('ALTER TABLE discussion_threads ADD COLUMN external_thread_id TEXT');
194
+ }
195
+
196
+ // Migration: drop discussion_threads if it was created with FK constraints on
197
+ // item_id/turn_id (initial version). Those FKs fail when the frontend sends
198
+ // delta-* item IDs from live streaming. Recreate without those FKs.
199
+ const threadTableDef = database
200
+ .prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='discussion_threads'")
201
+ .get() as { sql: string } | undefined;
202
+ if (threadTableDef && threadTableDef.sql.includes('FOREIGN KEY (item_id)')) {
203
+ database.exec('DROP TABLE discussion_threads');
204
+ // Recreate with the new schema (no FKs on item_id/turn_id)
205
+ database.exec(`
206
+ CREATE TABLE IF NOT EXISTS discussion_threads (
207
+ id TEXT PRIMARY KEY,
208
+ session_id TEXT NOT NULL,
209
+ item_id TEXT NOT NULL,
210
+ turn_id TEXT NOT NULL,
211
+ start_offset INTEGER NOT NULL,
212
+ end_offset INTEGER NOT NULL,
213
+ selected_text TEXT NOT NULL,
214
+ status TEXT NOT NULL DEFAULT 'open',
215
+ resolved_at TEXT,
216
+ created_at TEXT NOT NULL,
217
+ updated_at TEXT NOT NULL,
218
+ FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
219
+ );
220
+ CREATE INDEX IF NOT EXISTS idx_discussion_threads_session_id ON discussion_threads(session_id);
221
+ CREATE INDEX IF NOT EXISTS idx_discussion_threads_item_id ON discussion_threads(item_id);
222
+ `);
223
+ }
122
224
  }
@@ -13,6 +13,8 @@ import type {
13
13
  Item,
14
14
  Workspace,
15
15
  SessionDetail,
16
+ DiscussionThread,
17
+ Attachment,
16
18
  } from '@agentpane/shared';
17
19
 
18
20
  // Re-export shared types for backward compatibility with existing imports.
@@ -27,4 +29,6 @@ export type {
27
29
  Item,
28
30
  Workspace,
29
31
  SessionDetail,
32
+ DiscussionThread,
33
+ Attachment,
30
34
  };
@@ -6,6 +6,7 @@ import { initDb } from './db/index.ts';
6
6
  import { initWs, handleUpgrade, getWss } from './ws/index.ts';
7
7
  import { registerSessionRoutes } from './routes/sessions.ts';
8
8
  import { registerTurnRoutes } from './routes/turns.ts';
9
+ import { registerThreadRoutes } from './routes/threads.ts';
9
10
  import { registerWorkspaceRoutes } from './routes/workspaces.ts';
10
11
  import { registerProviderRoutes } from './routes/providers.ts';
11
12
  import { registerModelRoutes } from './routes/models.ts';
@@ -16,7 +17,7 @@ import { registerFileRoutes } from './routes/files.ts';
16
17
  import './providers/index.ts';
17
18
  import { shutdownAllProviders } from './providers/registry.ts';
18
19
 
19
- const PORT = 3001;
20
+ const PORT = 3003;
20
21
 
21
22
  async function main(): Promise<void> {
22
23
  // Initialize database
@@ -31,6 +32,7 @@ async function main(): Promise<void> {
31
32
  // Register routes
32
33
  await registerSessionRoutes(fastify);
33
34
  await registerTurnRoutes(fastify);
35
+ await registerThreadRoutes(fastify);
34
36
  await registerWorkspaceRoutes(fastify);
35
37
  await registerProviderRoutes(fastify);
36
38
  await registerModelRoutes(fastify);
@@ -19,6 +19,7 @@ import { initDb } from '../../db/index.ts';
19
19
  import { initWs } from '../../ws/index.ts';
20
20
  import { registerSessionRoutes } from '../../routes/sessions.ts';
21
21
  import { registerTurnRoutes } from '../../routes/turns.ts';
22
+ import { registerThreadRoutes } from '../../routes/threads.ts';
22
23
  import { registerWorkspaceRoutes } from '../../routes/workspaces.ts';
23
24
  import { clearAllProviders } from '../../providers/registry.ts';
24
25
  import '../../providers/index.ts'; // register providers for integration tests
@@ -42,6 +43,7 @@ export async function createTestApp(): Promise<TestApp> {
42
43
 
43
44
  await registerSessionRoutes(app);
44
45
  await registerTurnRoutes(app);
46
+ await registerThreadRoutes(app);
45
47
  await registerWorkspaceRoutes(app);
46
48
 
47
49
  initWs();
@@ -46,7 +46,7 @@ describe('Integration: session operations', () => {
46
46
  await testApp.cleanup();
47
47
  });
48
48
 
49
- it('rewind deletes turns after target and nulls external_thread_id', async () => {
49
+ it('rewind marks session as rewound (non-destructive) and saves pre_rewind_thread_id', async () => {
50
50
  const db: Database.Database = getDb();
51
51
 
52
52
  insertTestSession(db, 's1', { external_thread_id: 'thread_old' });
@@ -63,7 +63,7 @@ describe('Integration: session operations', () => {
63
63
  VALUES (?, ?, ?, NULL, NULL, NULL, NULL, 'completed', NULL, ?, ?)`,
64
64
  ).run('t2', 's1', 'second', now2, now2);
65
65
 
66
- // Rewind to t1 — deletes t2 and after
66
+ // Rewind to t1 — non-destructive: turns are NOT deleted
67
67
  const res = await testApp.app.inject({
68
68
  method: 'POST',
69
69
  url: '/api/sessions/s1/rewind',
@@ -71,18 +71,24 @@ describe('Integration: session operations', () => {
71
71
  });
72
72
  expect(res.statusCode).toBe(200);
73
73
 
74
- // Only t1 remains
74
+ // Both turns still exist (not deleted)
75
75
  const turns = db.prepare('SELECT id FROM turns WHERE session_id = ?').all('s1') as {
76
76
  id: string;
77
77
  }[];
78
- expect(turns).toHaveLength(1);
79
- expect(turns[0].id).toBe('t1');
78
+ expect(turns).toHaveLength(2);
80
79
 
81
- // external_thread_id nulled
82
- const session = db.prepare('SELECT external_thread_id FROM sessions WHERE id = ?').get('s1') as {
83
- external_thread_id: string | null;
80
+ // Session marked as rewound, pre_rewind_thread_id saved
81
+ const session = db.prepare('SELECT rewound_at_turn_id, pre_rewind_thread_id FROM sessions WHERE id = ?').get('s1') as {
82
+ rewound_at_turn_id: string | null;
83
+ pre_rewind_thread_id: string | null;
84
84
  };
85
- expect(session.external_thread_id).toBeNull();
85
+ expect(session.rewound_at_turn_id).toBe('t1');
86
+ expect(session.pre_rewind_thread_id).toBe('thread_old');
87
+
88
+ // GET session filters turns (only t1 visible)
89
+ const getRes = await testApp.app.inject({ method: 'GET', url: '/api/sessions/s1' });
90
+ expect(getRes.json().turns).toHaveLength(1);
91
+ expect(getRes.json().turns[0].id).toBe('t1');
86
92
  });
87
93
 
88
94
  it('branch creates new session with copied turns and items', async () => {
@@ -128,7 +134,7 @@ describe('Integration: session operations', () => {
128
134
  expect(newItems[0].type).toBe('agent_message');
129
135
  });
130
136
 
131
- it('model change clears thread when external_thread_id exists', async () => {
137
+ it('model change preserves thread when external_thread_id exists (same provider)', async () => {
132
138
  const db: Database.Database = getDb();
133
139
 
134
140
  insertTestSession(db, 's1', { model: 'gpt-4', external_thread_id: 'thread_123' });
@@ -144,7 +150,8 @@ describe('Integration: session operations', () => {
144
150
  external_thread_id: string | null;
145
151
  model: string | null;
146
152
  };
147
- expect(session.external_thread_id).toBeNull();
153
+ // Thread must survive model change within the same provider
154
+ expect(session.external_thread_id).toBe('thread_123');
148
155
  expect(session.model).toBe('o3');
149
156
  });
150
157
 
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Integration test: discussion threads — create, followup, resolve, delete, list.
3
+ *
4
+ * Tests the REST CRUD endpoints and verifies DB state + WS broadcasts.
5
+ * Uses the real codex-provider (mocked AppServerClient) for startTurn calls.
6
+ */
7
+
8
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
9
+ import { readFileSync } from 'node:fs';
10
+ import { resolve } from 'node:path';
11
+ import type Database from 'better-sqlite3';
12
+ import { createAppServerMock, type Fixture } from './helpers/app-server-mock.ts';
13
+ import { clearBroadcasts, capturedBroadcasts } from './helpers/ws-capture.ts';
14
+ import type { TestApp } from './helpers/test-app.ts';
15
+
16
+ const fixture = JSON.parse(readFileSync(resolve(__dirname, '__fixtures__/simple-turn.json'), 'utf-8')) as Fixture;
17
+
18
+ vi.mock('../codex/app-server-client.ts', () => ({
19
+ AppServerClient: createAppServerMock(fixture),
20
+ }));
21
+
22
+ vi.mock('../ws/index.ts', async (importOriginal) => {
23
+ const actual = (await importOriginal()) as Record<string, unknown>;
24
+ return {
25
+ ...actual,
26
+ broadcast: vi.fn((event: string, data: unknown) => {
27
+ capturedBroadcasts.push({ event, data });
28
+ }),
29
+ };
30
+ });
31
+
32
+ const { getDb } = await import('../db/index.ts');
33
+ const { createTestApp, insertTestSession } = await import('./helpers/test-app.ts');
34
+
35
+ describe('Integration: discussion threads', () => {
36
+ let testApp: TestApp;
37
+
38
+ beforeEach(async () => {
39
+ vi.clearAllMocks();
40
+ clearBroadcasts();
41
+ testApp = await createTestApp();
42
+ });
43
+
44
+ afterEach(async () => {
45
+ await testApp.cleanup();
46
+ });
47
+
48
+ it('creates a thread + first AI turn', async () => {
49
+ const db: Database.Database = getDb();
50
+ insertTestSession(db, 's1', { runtime_type: 'codex' });
51
+
52
+ // Insert an anchor turn + item (the agent_message being annotated)
53
+ const now = '2025-01-01T00:00:00Z';
54
+ db.prepare(
55
+ `INSERT INTO turns (id, session_id, user_prompt, agent_response, thoughts, token_usage, external_turn_id, status, error, created_at, updated_at)
56
+ VALUES (?, ?, 'prompt', 'response', NULL, NULL, NULL, 'completed', NULL, ?, ?)`,
57
+ ).run('t1', 's1', now, now);
58
+ db.prepare(
59
+ `INSERT INTO items (id, turn_id, external_item_id, type, data, status, created_at, updated_at)
60
+ VALUES (?, ?, 'ext1', 'agent_message', ?, 'completed', ?, ?)`,
61
+ ).run('i1', 't1', JSON.stringify({ text: 'Some text to discuss' }), now, now);
62
+
63
+ const res = await testApp.app.inject({
64
+ method: 'POST',
65
+ url: '/api/sessions/s1/threads',
66
+ payload: {
67
+ itemId: 'i1',
68
+ turnId: 't1',
69
+ startOffset: 0,
70
+ endOffset: 10,
71
+ selectedText: 'Some text ',
72
+ message: 'What does this mean?',
73
+ },
74
+ });
75
+
76
+ expect(res.statusCode).toBe(200);
77
+ const body = res.json();
78
+ expect(body.thread).toBeDefined();
79
+ expect(body.thread.id).toBeDefined();
80
+ expect(body.thread.status).toBe('open');
81
+ expect(body.thread.selected_text).toBe('Some text ');
82
+ expect(body.turn).toBeDefined();
83
+ expect(body.turn.thread_id).toBe(body.thread.id);
84
+
85
+ // Verify DB state
86
+ const threadRow = db.prepare('SELECT * FROM discussion_threads WHERE id = ?').get(body.thread.id);
87
+ expect(threadRow).toBeDefined();
88
+ expect((threadRow as { status: string }).status).toBe('open');
89
+
90
+ const turnRow = db.prepare('SELECT * FROM turns WHERE id = ?').get(body.turn.id) as { thread_id: string | null; user_prompt: string };
91
+ expect(turnRow.thread_id).toBe(body.thread.id);
92
+ expect(turnRow.user_prompt).toContain('[Quoting from your previous response]');
93
+ expect(turnRow.user_prompt).toContain('Some text ');
94
+ expect(turnRow.user_prompt).toContain('What does this mean?');
95
+
96
+ // Verify broadcast
97
+ const createdEvent = capturedBroadcasts.find((b) => b.event === 'thread:created');
98
+ expect(createdEvent).toBeDefined();
99
+ expect((createdEvent!.data as { sessionId: string }).sessionId).toBe('s1');
100
+ });
101
+
102
+ it('returns 409 when a turn is already running', async () => {
103
+ const db: Database.Database = getDb();
104
+ insertTestSession(db, 's1', { is_running: 1 });
105
+
106
+ const res = await testApp.app.inject({
107
+ method: 'POST',
108
+ url: '/api/sessions/s1/threads',
109
+ payload: {
110
+ itemId: 'i1',
111
+ turnId: 't1',
112
+ startOffset: 0,
113
+ endOffset: 5,
114
+ selectedText: 'text',
115
+ message: 'test',
116
+ },
117
+ });
118
+
119
+ expect(res.statusCode).toBe(409);
120
+ });
121
+
122
+ it('resolves and reopens a thread', async () => {
123
+ const db: Database.Database = getDb();
124
+ insertTestSession(db, 's1', { runtime_type: 'codex' });
125
+
126
+ const now = '2025-01-01T00:00:00Z';
127
+ db.prepare(
128
+ `INSERT INTO turns (id, session_id, user_prompt, agent_response, thoughts, token_usage, external_turn_id, status, error, created_at, updated_at)
129
+ VALUES (?, ?, 'prompt', 'response', NULL, NULL, NULL, 'completed', NULL, ?, ?)`,
130
+ ).run('t1', 's1', now, now);
131
+ db.prepare(
132
+ `INSERT INTO items (id, turn_id, external_item_id, type, data, status, created_at, updated_at)
133
+ VALUES (?, ?, 'ext1', 'agent_message', ?, 'completed', ?, ?)`,
134
+ ).run('i1', 't1', JSON.stringify({ text: 'text' }), now, now);
135
+
136
+ // Manually insert a thread
137
+ db.prepare(
138
+ `INSERT INTO discussion_threads (id, session_id, item_id, turn_id, start_offset, end_offset, selected_text, status, created_at, updated_at)
139
+ VALUES (?, ?, ?, ?, 0, 4, 'text', 'open', ?, ?)`,
140
+ ).run('th1', 's1', 'i1', 't1', now, now);
141
+
142
+ // Resolve
143
+ const resolveRes = await testApp.app.inject({
144
+ method: 'PATCH',
145
+ url: '/api/sessions/s1/threads/th1',
146
+ payload: { status: 'resolved' },
147
+ });
148
+ expect(resolveRes.statusCode).toBe(200);
149
+ expect(resolveRes.json().status).toBe('resolved');
150
+
151
+ const thread = db.prepare('SELECT status, resolved_at FROM discussion_threads WHERE id = ?').get('th1') as { status: string; resolved_at: string | null };
152
+ expect(thread.status).toBe('resolved');
153
+ expect(thread.resolved_at).not.toBeNull();
154
+
155
+ const resolvedEvent = capturedBroadcasts.find((b) => b.event === 'thread:resolved');
156
+ expect(resolvedEvent).toBeDefined();
157
+
158
+ // Reopen
159
+ clearBroadcasts();
160
+ const reopenRes = await testApp.app.inject({
161
+ method: 'PATCH',
162
+ url: '/api/sessions/s1/threads/th1',
163
+ payload: { status: 'open' },
164
+ });
165
+ expect(reopenRes.statusCode).toBe(200);
166
+ expect(reopenRes.json().status).toBe('open');
167
+
168
+ const reopened = db.prepare('SELECT status FROM discussion_threads WHERE id = ?').get('th1') as { status: string };
169
+ expect(reopened.status).toBe('open');
170
+ });
171
+
172
+ it('deletes a thread and its turns + items', async () => {
173
+ const db: Database.Database = getDb();
174
+ insertTestSession(db, 's1', { runtime_type: 'codex' });
175
+
176
+ const now = '2025-01-01T00:00:00Z';
177
+ // Insert anchor turn + item
178
+ db.prepare(
179
+ `INSERT INTO turns (id, session_id, user_prompt, agent_response, thoughts, token_usage, external_turn_id, status, error, created_at, updated_at)
180
+ VALUES (?, ?, 'prompt', 'response', NULL, NULL, NULL, 'completed', NULL, ?, ?)`,
181
+ ).run('t1', 's1', now, now);
182
+ db.prepare(
183
+ `INSERT INTO items (id, turn_id, external_item_id, type, data, status, created_at, updated_at)
184
+ VALUES (?, ?, 'ext1', 'agent_message', ?, 'completed', ?, ?)`,
185
+ ).run('i1', 't1', JSON.stringify({ text: 'text' }), now, now);
186
+
187
+ // Insert thread + thread turn + thread item
188
+ db.prepare(
189
+ `INSERT INTO discussion_threads (id, session_id, item_id, turn_id, start_offset, end_offset, selected_text, status, created_at, updated_at)
190
+ VALUES (?, ?, ?, ?, 0, 4, 'text', 'open', ?, ?)`,
191
+ ).run('th1', 's1', 'i1', 't1', now, now);
192
+ db.prepare(
193
+ `INSERT INTO turns (id, session_id, user_prompt, agent_response, thoughts, token_usage, external_turn_id, status, error, thread_id, created_at, updated_at)
194
+ VALUES (?, ?, 'thread msg', 'thread resp', NULL, NULL, NULL, 'completed', NULL, ?, ?, ?)`,
195
+ ).run('tt1', 's1', 'th1', now, now);
196
+ db.prepare(
197
+ `INSERT INTO items (id, turn_id, external_item_id, type, data, status, created_at, updated_at)
198
+ VALUES (?, ?, 'ext2', 'agent_message', ?, 'completed', ?, ?)`,
199
+ ).run('ti1', 'tt1', JSON.stringify({ text: 'thread response' }), now, now);
200
+
201
+ const res = await testApp.app.inject({
202
+ method: 'DELETE',
203
+ url: '/api/sessions/s1/threads/th1',
204
+ });
205
+ expect(res.statusCode).toBe(200);
206
+ expect(res.json().success).toBe(true);
207
+
208
+ // Thread row deleted
209
+ const thread = db.prepare('SELECT id FROM discussion_threads WHERE id = ?').get('th1');
210
+ expect(thread).toBeUndefined();
211
+
212
+ // Thread turn deleted
213
+ const turn = db.prepare('SELECT id FROM turns WHERE id = ?').get('tt1');
214
+ expect(turn).toBeUndefined();
215
+
216
+ // Thread item deleted
217
+ const item = db.prepare('SELECT id FROM items WHERE id = ?').get('ti1');
218
+ expect(item).toBeUndefined();
219
+
220
+ // Anchor turn + item still exist
221
+ const anchorTurn = db.prepare('SELECT id FROM turns WHERE id = ?').get('t1');
222
+ expect(anchorTurn).toBeDefined();
223
+ const anchorItem = db.prepare('SELECT id FROM items WHERE id = ?').get('i1');
224
+ expect(anchorItem).toBeDefined();
225
+
226
+ const deletedEvent = capturedBroadcasts.find((b) => b.event === 'thread:deleted');
227
+ expect(deletedEvent).toBeDefined();
228
+ });
229
+
230
+ it('lists threads with nested turns', async () => {
231
+ const db: Database.Database = getDb();
232
+ insertTestSession(db, 's1', { runtime_type: 'codex' });
233
+
234
+ const now = '2025-01-01T00:00:00Z';
235
+ // Anchor turn + item
236
+ db.prepare(
237
+ `INSERT INTO turns (id, session_id, user_prompt, agent_response, thoughts, token_usage, external_turn_id, status, error, created_at, updated_at)
238
+ VALUES (?, ?, 'prompt', 'response', NULL, NULL, NULL, 'completed', NULL, ?, ?)`,
239
+ ).run('t1', 's1', now, now);
240
+ db.prepare(
241
+ `INSERT INTO items (id, turn_id, external_item_id, type, data, status, created_at, updated_at)
242
+ VALUES (?, ?, 'ext1', 'agent_message', ?, 'completed', ?, ?)`,
243
+ ).run('i1', 't1', JSON.stringify({ text: 'text' }), now, now);
244
+
245
+ // Thread + thread turn + thread item
246
+ db.prepare(
247
+ `INSERT INTO discussion_threads (id, session_id, item_id, turn_id, start_offset, end_offset, selected_text, status, created_at, updated_at)
248
+ VALUES (?, ?, ?, ?, 0, 4, 'text', 'open', ?, ?)`,
249
+ ).run('th1', 's1', 'i1', 't1', now, now);
250
+ db.prepare(
251
+ `INSERT INTO turns (id, session_id, user_prompt, agent_response, thoughts, token_usage, external_turn_id, status, error, thread_id, created_at, updated_at)
252
+ VALUES (?, ?, 'msg', 'resp', NULL, NULL, NULL, 'completed', NULL, ?, ?, ?)`,
253
+ ).run('tt1', 's1', 'th1', now, now);
254
+ db.prepare(
255
+ `INSERT INTO items (id, turn_id, external_item_id, type, data, status, created_at, updated_at)
256
+ VALUES (?, ?, 'ext2', 'agent_message', ?, 'completed', ?, ?)`,
257
+ ).run('ti1', 'tt1', JSON.stringify({ text: 'response' }), now, now);
258
+
259
+ const res = await testApp.app.inject({
260
+ method: 'GET',
261
+ url: '/api/sessions/s1/threads',
262
+ });
263
+ expect(res.statusCode).toBe(200);
264
+ const threads = res.json();
265
+ expect(threads).toHaveLength(1);
266
+ expect(threads[0].id).toBe('th1');
267
+ expect(threads[0].turns).toHaveLength(1);
268
+ expect(threads[0].turns[0].id).toBe('tt1');
269
+ expect(threads[0].turns[0].items).toHaveLength(1);
270
+ expect(threads[0].turns[0].items[0].id).toBe('ti1');
271
+ });
272
+
273
+ it('GET /api/sessions/:id includes threads', async () => {
274
+ const db: Database.Database = getDb();
275
+ insertTestSession(db, 's1', { runtime_type: 'codex' });
276
+
277
+ const now = '2025-01-01T00:00:00Z';
278
+ db.prepare(
279
+ `INSERT INTO turns (id, session_id, user_prompt, agent_response, thoughts, token_usage, external_turn_id, status, error, created_at, updated_at)
280
+ VALUES (?, ?, 'prompt', 'response', NULL, NULL, NULL, 'completed', NULL, ?, ?)`,
281
+ ).run('t1', 's1', now, now);
282
+ db.prepare(
283
+ `INSERT INTO items (id, turn_id, external_item_id, type, data, status, created_at, updated_at)
284
+ VALUES (?, ?, 'ext1', 'agent_message', ?, 'completed', ?, ?)`,
285
+ ).run('i1', 't1', JSON.stringify({ text: 'text' }), now, now);
286
+
287
+ db.prepare(
288
+ `INSERT INTO discussion_threads (id, session_id, item_id, turn_id, start_offset, end_offset, selected_text, status, created_at, updated_at)
289
+ VALUES (?, ?, ?, ?, 0, 4, 'text', 'open', ?, ?)`,
290
+ ).run('th1', 's1', 'i1', 't1', now, now);
291
+ db.prepare(
292
+ `INSERT INTO turns (id, session_id, user_prompt, agent_response, thoughts, token_usage, external_turn_id, status, error, thread_id, created_at, updated_at)
293
+ VALUES (?, ?, 'msg', 'resp', NULL, NULL, NULL, 'completed', NULL, ?, ?, ?)`,
294
+ ).run('tt1', 's1', 'th1', now, now);
295
+
296
+ const res = await testApp.app.inject({ method: 'GET', url: '/api/sessions/s1' });
297
+ expect(res.statusCode).toBe(200);
298
+ const detail = res.json();
299
+ expect(detail.threads).toBeDefined();
300
+ expect(detail.threads).toHaveLength(1);
301
+ expect(detail.threads[0].id).toBe('th1');
302
+ expect(detail.threads[0].turns).toHaveLength(1);
303
+
304
+ // Thread turns should also be in the main turns array
305
+ expect(detail.turns).toHaveLength(2);
306
+ const threadTurn = detail.turns.find((t: { thread_id: string | null }) => t.thread_id === 'th1');
307
+ expect(threadTurn).toBeDefined();
308
+ });
309
+ });
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { Session, Turn } from '../db/schema.ts';
9
- import type { ModelInfo } from '@agentpane/shared';
9
+ import type { ModelInfo, ProviderSlashCommand } from '@agentpane/shared';
10
10
 
11
11
  /** Runtime provider — turn lifecycle, cleanup, metadata, and shutdown. */
12
12
  export interface RuntimeProvider {
@@ -16,10 +16,28 @@ export interface RuntimeProvider {
16
16
  interruptTurn(sessionId: string): Promise<boolean>;
17
17
  /** Remove session from in-memory state. Used after rewind/branch/model-change. */
18
18
  clearThread(sessionId: string): void;
19
+ /** Remove a specific thread's branched session entry. Used when a thread is deleted. */
20
+ clearThreadBranch?(sessionId: string, threadId: string): void;
19
21
  /** Clear all in-memory state. Used in test cleanup. */
20
22
  clearAllThreads(): void;
21
23
  /** Fetch available models for this provider. */
22
24
  getModels(): Promise<ModelInfo[]>;
25
+ /** Fetch provider-native slash commands (e.g. Claude CLI `/compact`, `/clear`).
26
+ * Optional — providers without a command surface omit it and return `[]`. */
27
+ getCommands?(): Promise<ProviderSlashCommand[]>;
28
+ /** Fork the provider-native session at a branch point.
29
+ * Returns the new provider-native session ID, or null if there is no thread to fork.
30
+ * Optional — providers without fork support omit it. */
31
+ forkSession?(session: Session, targetTurn: Turn): Promise<string | null>;
32
+ /** Change the model for a running session without restarting the CLI process.
33
+ * Only supported in streaming-input mode (Claude). If the session hasn't
34
+ * started yet, the new model is read from the DB on next startTurn.
35
+ * Optional — providers without mid-session model switching omit it. */
36
+ setModel?(sessionId: string, model: string): Promise<void>;
37
+ /** Stop a running background task (e.g. Monitor) by task ID.
38
+ * Emits a task_notification with status 'stopped'.
39
+ * Optional — providers without background task support omit it. */
40
+ stopTask?(sessionId: string, taskId: string): Promise<void>;
23
41
  /** Graceful shutdown — close processes, release resources. */
24
42
  shutdown(): Promise<void>;
25
43
  }