repoburg 1.3.198 → 1.3.201
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 +1425 -272
- 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 +6 -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/repoburg/action-mapping.ts +216 -0
- package/agent-pane/backend/src/repoburg/agent-runtime-client.test.ts +271 -0
- package/agent-pane/backend/src/repoburg/agent-runtime-client.ts +262 -0
- package/agent-pane/backend/src/repoburg/backend-resolver.test.ts +271 -0
- package/agent-pane/backend/src/repoburg/backend-resolver.ts +246 -0
- package/agent-pane/backend/src/repoburg/repoburg-client.test.ts +159 -0
- package/agent-pane/backend/src/repoburg/repoburg-client.ts +181 -0
- package/agent-pane/backend/src/repoburg/repoburg-provider.test.ts +723 -0
- package/agent-pane/backend/src/repoburg/repoburg-provider.ts +422 -0
- package/agent-pane/backend/src/repoburg/turn-projector.test.ts +475 -0
- package/agent-pane/backend/src/repoburg/turn-projector.ts +537 -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.ts +12 -0
- 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-Dwwhx7YN.css → index-34WaZYsW.css} +1 -1
- package/agent-pane/frontend/dist/assets/index-y7xlT86e.js +362 -0
- package/agent-pane/frontend/dist/index.html +2 -2
- 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/panels/ThreadsPanel.tsx +35 -3
- 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 +35 -0
- package/agent-pane/frontend/src/store/uiSlice.ts +50 -0
- package/agent-pane/frontend/src/styles/app.css +10 -1
- package/agent-pane/frontend/src/test/component-helpers.ts +2 -0
- package/agent-pane-bug-repro-no-agent-reply.png +0 -0
- package/backend/dist/src/agent-runtime/agent-runtime-event-emitter.d.ts +20 -0
- package/backend/dist/src/agent-runtime/agent-runtime-event-emitter.js +112 -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 +77 -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.module.js +4 -0
- package/backend/dist/src/app.module.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 +3 -1
- package/backend/dist/src/interactive-chat/chat.service.js +33 -6
- package/backend/dist/src/interactive-chat/chat.service.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.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 +35 -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 +20 -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/index.js +0 -0
- package/package.json +1 -1
- package/agent-pane/frontend/dist/assets/index-BYo-lnxm.js +0 -362
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
2
|
|
|
3
3
|
// Mock the SDK query function
|
|
4
4
|
vi.mock('@anthropic-ai/claude-agent-sdk', () => ({
|
|
@@ -7,13 +7,29 @@ vi.mock('@anthropic-ai/claude-agent-sdk', () => ({
|
|
|
7
7
|
|
|
8
8
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
9
9
|
|
|
10
|
-
//
|
|
11
|
-
|
|
10
|
+
// Capture broadcasts without touching a real WebSocket server.
|
|
11
|
+
const captured: Array<{ event: string; data: unknown }> = [];
|
|
12
|
+
vi.mock('../ws/index.ts', async (importOriginal) => {
|
|
13
|
+
const actual = (await importOriginal()) as Record<string, unknown>;
|
|
14
|
+
return {
|
|
15
|
+
...actual,
|
|
16
|
+
broadcast: vi.fn((event: string, data: unknown) => {
|
|
17
|
+
captured.push({ event, data });
|
|
18
|
+
}),
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Import after mocks are set up
|
|
23
|
+
import { claudeProvider, clearAllThreads, handleResultMessage, handleMessage } from './claude-provider.ts';
|
|
24
|
+
import { initDb } from '../db/index.ts';
|
|
25
|
+
import type Database from 'better-sqlite3';
|
|
26
|
+
import type { ClaudeSessionEntry } from './claude-types.ts';
|
|
12
27
|
|
|
13
28
|
describe('claudeProvider', () => {
|
|
14
29
|
beforeEach(() => {
|
|
15
30
|
vi.clearAllMocks();
|
|
16
31
|
clearAllThreads();
|
|
32
|
+
captured.length = 0;
|
|
17
33
|
});
|
|
18
34
|
|
|
19
35
|
it('implements all RuntimeProvider methods', () => {
|
|
@@ -62,4 +78,378 @@ describe('claudeProvider', () => {
|
|
|
62
78
|
it('shutdown does not throw', async () => {
|
|
63
79
|
await expect(claudeProvider.shutdown()).resolves.not.toThrow();
|
|
64
80
|
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/** Build a mock ClaudeSessionEntry whose query.getContextUsage() returns the given snapshot. */
|
|
84
|
+
function makeEntry(getContextUsage: ReturnType<typeof vi.fn>): ClaudeSessionEntry {
|
|
85
|
+
return {
|
|
86
|
+
query: {
|
|
87
|
+
getContextUsage,
|
|
88
|
+
interrupt: vi.fn(),
|
|
89
|
+
setModel: vi.fn(),
|
|
90
|
+
close: vi.fn(),
|
|
91
|
+
stopTask: vi.fn(),
|
|
92
|
+
initializationResult: vi.fn(),
|
|
93
|
+
[Symbol.asyncIterator]: async function* () {},
|
|
94
|
+
} as never,
|
|
95
|
+
sessionId: 'cs_test',
|
|
96
|
+
turnId: null,
|
|
97
|
+
cwd: '/test',
|
|
98
|
+
promptQueue: { push: vi.fn(), close: vi.fn() } as never,
|
|
99
|
+
messageLoop: null,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Build a result/success SDK message with the given (accumulated) token usage. */
|
|
104
|
+
function makeResultMessage(usage: {
|
|
105
|
+
input_tokens: number;
|
|
106
|
+
cache_read_input_tokens: number;
|
|
107
|
+
cache_creation_input_tokens: number;
|
|
108
|
+
output_tokens: number;
|
|
109
|
+
}): unknown {
|
|
110
|
+
return {
|
|
111
|
+
type: 'result',
|
|
112
|
+
subtype: 'success',
|
|
113
|
+
usage,
|
|
114
|
+
modelUsage: {},
|
|
115
|
+
result: 'done',
|
|
116
|
+
is_error: false,
|
|
117
|
+
num_turns: 1,
|
|
118
|
+
stop_reason: 'end_turn',
|
|
119
|
+
duration_ms: 100,
|
|
120
|
+
duration_api_ms: 100,
|
|
121
|
+
total_cost_usd: 0,
|
|
122
|
+
permission_denials: [],
|
|
123
|
+
uuid: 'u1',
|
|
124
|
+
session_id: 'cs_test',
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
describe('handleResultMessage — context-window usage', () => {
|
|
129
|
+
let db: Database.Database;
|
|
130
|
+
|
|
131
|
+
beforeEach(() => {
|
|
132
|
+
db = initDb(':memory:');
|
|
133
|
+
// Session with a non-null title so syncThreadTitle short-circuits (no title query).
|
|
134
|
+
db.prepare(
|
|
135
|
+
`INSERT INTO sessions (id, title, runtime_type, status, is_running, lifecycle, created_at, updated_at)
|
|
136
|
+
VALUES ('s1', 'Test', 'claude', 'running', 1, 'working', ?, ?)`,
|
|
137
|
+
).run(new Date().toISOString(), new Date().toISOString());
|
|
138
|
+
db.prepare(
|
|
139
|
+
`INSERT INTO turns (id, session_id, user_prompt, status, created_at, updated_at)
|
|
140
|
+
VALUES ('t1', 's1', 'hi', 'running', ?, ?)`,
|
|
141
|
+
).run(new Date().toISOString(), new Date().toISOString());
|
|
142
|
+
captured.length = 0;
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
afterEach(() => {
|
|
146
|
+
db.close();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('uses SDK getContextUsage() real fill, NOT the accumulated result.usage sum', async () => {
|
|
150
|
+
// Old buggy behavior: result.usage sums to 800K (accumulated). getContextUsage reports the real 130K.
|
|
151
|
+
const getContextUsage = vi.fn().mockResolvedValue({
|
|
152
|
+
totalTokens: 130000,
|
|
153
|
+
maxTokens: 1000000,
|
|
154
|
+
rawMaxTokens: 1000000,
|
|
155
|
+
percentage: 13,
|
|
156
|
+
categories: [],
|
|
157
|
+
gridRows: [],
|
|
158
|
+
model: 'claude-sonnet',
|
|
159
|
+
memoryFiles: [],
|
|
160
|
+
mcpTools: [],
|
|
161
|
+
});
|
|
162
|
+
const entry = makeEntry(getContextUsage);
|
|
163
|
+
|
|
164
|
+
await handleResultMessage(
|
|
165
|
+
makeResultMessage({
|
|
166
|
+
input_tokens: 300000,
|
|
167
|
+
cache_read_input_tokens: 400000,
|
|
168
|
+
cache_creation_input_tokens: 100000, // sum = 800K (the wrong accumulated value)
|
|
169
|
+
output_tokens: 500,
|
|
170
|
+
}) as never,
|
|
171
|
+
's1',
|
|
172
|
+
't1',
|
|
173
|
+
entry,
|
|
174
|
+
db,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// turn:contextUsage must carry the SDK's real fill (130K), not the 800K sum.
|
|
178
|
+
const ctx = captured.find((b) => b.event === 'turn:contextUsage');
|
|
179
|
+
expect(ctx).toBeDefined();
|
|
180
|
+
expect((ctx!.data as { totalTokens: number }).totalTokens).toBe(130000);
|
|
181
|
+
expect((ctx!.data as { contextWindow: number }).contextWindow).toBe(1000000);
|
|
182
|
+
expect((ctx!.data as { percentage: number }).percentage).toBe(13);
|
|
183
|
+
|
|
184
|
+
// Persisted to the session row so the counter survives reload.
|
|
185
|
+
const row = db.prepare('SELECT context_usage FROM sessions WHERE id = ?').get('s1') as
|
|
186
|
+
| { context_usage: string | null }
|
|
187
|
+
| undefined;
|
|
188
|
+
expect(row?.context_usage).toBeTruthy();
|
|
189
|
+
expect(JSON.parse(row!.context_usage!).totalTokens).toBe(130000);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('does not broadcast a bad value when getContextUsage() rejects', async () => {
|
|
193
|
+
const getContextUsage = vi.fn().mockRejectedValue(new Error('not available'));
|
|
194
|
+
const entry = makeEntry(getContextUsage);
|
|
195
|
+
|
|
196
|
+
await handleResultMessage(
|
|
197
|
+
makeResultMessage({
|
|
198
|
+
input_tokens: 100,
|
|
199
|
+
cache_read_input_tokens: 0,
|
|
200
|
+
cache_creation_input_tokens: 0,
|
|
201
|
+
output_tokens: 10,
|
|
202
|
+
}) as never,
|
|
203
|
+
's1',
|
|
204
|
+
't1',
|
|
205
|
+
entry,
|
|
206
|
+
db,
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
expect(captured.find((b) => b.event === 'turn:contextUsage')).toBeUndefined();
|
|
210
|
+
// The turn still completes normally.
|
|
211
|
+
expect(captured.find((b) => b.event === 'turn:completed')).toBeDefined();
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe('handleMessage — system/init must not corrupt main session for thread entries', () => {
|
|
216
|
+
let db: Database.Database;
|
|
217
|
+
|
|
218
|
+
beforeEach(() => {
|
|
219
|
+
db = initDb(':memory:');
|
|
220
|
+
// Main session whose Claude session id is 'main-session-1'.
|
|
221
|
+
db.prepare(
|
|
222
|
+
`INSERT INTO sessions (id, title, runtime_type, status, is_running, lifecycle, external_thread_id, created_at, updated_at)
|
|
223
|
+
VALUES ('s1', 'Test', 'claude', 'active', 0, 'working', 'main-session-1', ?, ?)`,
|
|
224
|
+
).run(new Date().toISOString(), new Date().toISOString());
|
|
225
|
+
captured.length = 0;
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
afterEach(() => {
|
|
229
|
+
db.close();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
/** Build a minimal entry for init-handler tests (getContextUsage unused). */
|
|
233
|
+
function makeInitEntry(): ClaudeSessionEntry {
|
|
234
|
+
return makeEntry(vi.fn());
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Build a system/init SDK message with the given Claude session id. */
|
|
238
|
+
function makeInitMessage(claudeSessionId: string): unknown {
|
|
239
|
+
return { type: 'system', subtype: 'init', session_id: claudeSessionId };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
it('thread entry: sets entry.sessionId but does NOT overwrite the main session external_thread_id', async () => {
|
|
243
|
+
// Regression: the system/init handler previously wrote the forked session's
|
|
244
|
+
// id into the MAIN session's external_thread_id for ALL entries, because
|
|
245
|
+
// handleMessage is always called with the main session id and entry.threadId
|
|
246
|
+
// was never populated. After a restart/rewind the main session would resume
|
|
247
|
+
// the fork and "remember" the thread's discussion.
|
|
248
|
+
const entry = makeInitEntry();
|
|
249
|
+
entry.threadId = 'thread-1';
|
|
250
|
+
|
|
251
|
+
await handleMessage(
|
|
252
|
+
makeInitMessage('fork-session-9') as never,
|
|
253
|
+
's1', // main session id — passed to handleMessage for ALL entries
|
|
254
|
+
't1',
|
|
255
|
+
entry,
|
|
256
|
+
db,
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
// entry.sessionId is captured (needed for Monitor output-path construction).
|
|
260
|
+
expect(entry.sessionId).toBe('fork-session-9');
|
|
261
|
+
// The main session's resume id is NOT corrupted with the fork's id.
|
|
262
|
+
const row = db.prepare('SELECT external_thread_id FROM sessions WHERE id = ?').get('s1') as
|
|
263
|
+
| { external_thread_id: string | null }
|
|
264
|
+
| undefined;
|
|
265
|
+
expect(row?.external_thread_id).toBe('main-session-1');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('main entry: still persists external_thread_id (preserves existing behavior)', async () => {
|
|
269
|
+
const entry = makeInitEntry();
|
|
270
|
+
entry.threadId = null; // main session entry (not a thread/fork)
|
|
271
|
+
|
|
272
|
+
await handleMessage(
|
|
273
|
+
makeInitMessage('fork-session-9') as never,
|
|
274
|
+
's1',
|
|
275
|
+
't1',
|
|
276
|
+
entry,
|
|
277
|
+
db,
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
expect(entry.sessionId).toBe('fork-session-9');
|
|
281
|
+
const row = db.prepare('SELECT external_thread_id FROM sessions WHERE id = ?').get('s1') as
|
|
282
|
+
| { external_thread_id: string | null }
|
|
283
|
+
| undefined;
|
|
284
|
+
expect(row?.external_thread_id).toBe('fork-session-9');
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
// startTurn — thread fork isolation (BUG 1 + BUG 2 regression tests)
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
/** Build a mock `Query` whose async iterator yields the given SDK messages in
|
|
293
|
+
* order, then ends. getContextUsage resolves an empty snapshot (0/0) so the
|
|
294
|
+
* result/success handler completes the turn without emitting contextUsage. */
|
|
295
|
+
function createClaudeQueryMock(messages: unknown[]): unknown {
|
|
296
|
+
return {
|
|
297
|
+
getContextUsage: vi.fn().mockResolvedValue({
|
|
298
|
+
totalTokens: 0, maxTokens: 0, rawMaxTokens: 0, percentage: 0,
|
|
299
|
+
categories: [], gridRows: [], model: '', memoryFiles: [], mcpTools: [],
|
|
300
|
+
}),
|
|
301
|
+
interrupt: vi.fn(),
|
|
302
|
+
setModel: vi.fn(),
|
|
303
|
+
close: vi.fn(),
|
|
304
|
+
stopTask: vi.fn(),
|
|
305
|
+
initializationResult: vi.fn(),
|
|
306
|
+
[Symbol.asyncIterator]: async function* () {
|
|
307
|
+
for (const m of messages) yield m;
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** A `system/init` SDK message carrying the given Claude session id. */
|
|
313
|
+
function initMsg(claudeSessionId: string): unknown {
|
|
314
|
+
return { type: 'system', subtype: 'init', session_id: claudeSessionId };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** A `result/success` SDK message that completes a turn. */
|
|
318
|
+
function resultSuccessMsg(claudeSessionId: string): unknown {
|
|
319
|
+
return {
|
|
320
|
+
type: 'result', subtype: 'success',
|
|
321
|
+
usage: { input_tokens: 10, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, output_tokens: 5 },
|
|
322
|
+
modelUsage: {}, result: 'done', is_error: false, num_turns: 1,
|
|
323
|
+
stop_reason: 'end_turn', duration_ms: 1, duration_api_ms: 1, total_cost_usd: 0,
|
|
324
|
+
permission_denials: [], uuid: 'u1', session_id: claudeSessionId,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Poll the DB until the given turn reaches the expected status. */
|
|
329
|
+
async function waitForTurnStatus(db: Database.Database, turnId: string, status: string, timeoutMs = 2000): Promise<void> {
|
|
330
|
+
const start = Date.now();
|
|
331
|
+
while (Date.now() - start < timeoutMs) {
|
|
332
|
+
const row = db.prepare('SELECT status FROM turns WHERE id = ?').get(turnId) as { status: string } | undefined;
|
|
333
|
+
if (row?.status === status) return;
|
|
334
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
335
|
+
}
|
|
336
|
+
throw new Error(`turn ${turnId} did not reach status '${status}' within ${timeoutMs}ms`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
describe('startTurn — thread fork isolation', () => {
|
|
340
|
+
let db: Database.Database;
|
|
341
|
+
|
|
342
|
+
beforeEach(() => {
|
|
343
|
+
vi.clearAllMocks();
|
|
344
|
+
clearAllThreads();
|
|
345
|
+
captured.length = 0;
|
|
346
|
+
db = initDb(':memory:');
|
|
347
|
+
// Main Claude session with a known resume id. Title set so syncThreadTitle
|
|
348
|
+
// short-circuits (no title-generation query).
|
|
349
|
+
db.prepare(
|
|
350
|
+
`INSERT INTO sessions (id, title, runtime_type, status, is_running, lifecycle, external_thread_id, created_at, updated_at)
|
|
351
|
+
VALUES ('s1', 'Test', 'claude', 'active', 0, 'working', 'main-1', ?, ?)`,
|
|
352
|
+
).run(new Date().toISOString(), new Date().toISOString());
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
afterEach(() => {
|
|
356
|
+
clearAllThreads();
|
|
357
|
+
db.close();
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('main session resumes from its own persisted external_thread_id', async () => {
|
|
361
|
+
// Seed a MAIN turn (no thread_id).
|
|
362
|
+
const now = new Date().toISOString();
|
|
363
|
+
db.prepare(
|
|
364
|
+
`INSERT INTO turns (id, session_id, user_prompt, status, created_at, updated_at)
|
|
365
|
+
VALUES ('t1', 's1', 'hi', 'pending', ?, ?)`,
|
|
366
|
+
).run(now, now);
|
|
367
|
+
|
|
368
|
+
vi.mocked(query).mockReturnValue(createClaudeQueryMock([initMsg('main-1'), resultSuccessMsg('main-1')]) as never);
|
|
369
|
+
|
|
370
|
+
const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get('s1') as never;
|
|
371
|
+
const turn = db.prepare('SELECT * FROM turns WHERE id = ?').get('t1') as never;
|
|
372
|
+
await claudeProvider.startTurn(session, turn);
|
|
373
|
+
|
|
374
|
+
await waitForTurnStatus(db, 't1', 'completed');
|
|
375
|
+
|
|
376
|
+
// The main session's query was started with resume = its persisted external_thread_id.
|
|
377
|
+
const opts = (vi.mocked(query).mock.calls[0][0] as { options: { resume?: string } }).options;
|
|
378
|
+
expect(opts.resume).toBe('main-1');
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('thread turn resumes from its OWN fork id and does NOT corrupt the main session (BUG 1 + BUG 2)', async () => {
|
|
382
|
+
const now = new Date().toISOString();
|
|
383
|
+
// Anchor turn + a discussion thread forked to 'fork-9'.
|
|
384
|
+
db.prepare(
|
|
385
|
+
`INSERT INTO turns (id, session_id, user_prompt, agent_response, status, created_at, updated_at)
|
|
386
|
+
VALUES ('t1', 's1', 'hi', 'resp', 'completed', ?, ?)`,
|
|
387
|
+
).run(now, now);
|
|
388
|
+
db.prepare(
|
|
389
|
+
`INSERT INTO discussion_threads (id, session_id, item_id, turn_id, start_offset, end_offset, selected_text, external_thread_id, status, created_at, updated_at)
|
|
390
|
+
VALUES ('th1', 's1', 'i1', 't1', 0, 4, 'resp', 'fork-9', 'open', ?, ?)`,
|
|
391
|
+
).run(now, now);
|
|
392
|
+
// Thread turn.
|
|
393
|
+
db.prepare(
|
|
394
|
+
`INSERT INTO turns (id, session_id, user_prompt, status, thread_id, created_at, updated_at)
|
|
395
|
+
VALUES ('tt1', 's1', 'discuss this', 'pending', 'th1', ?, ?)`,
|
|
396
|
+
).run(now, now);
|
|
397
|
+
|
|
398
|
+
// The forked query emits init with the FORK's session id, then completes.
|
|
399
|
+
vi.mocked(query).mockReturnValue(createClaudeQueryMock([initMsg('fork-9'), resultSuccessMsg('fork-9')]) as never);
|
|
400
|
+
|
|
401
|
+
const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get('s1') as never;
|
|
402
|
+
const turn = db.prepare('SELECT * FROM turns WHERE id = ?').get('tt1') as never;
|
|
403
|
+
await claudeProvider.startTurn(session, turn);
|
|
404
|
+
|
|
405
|
+
await waitForTurnStatus(db, 'tt1', 'completed');
|
|
406
|
+
|
|
407
|
+
// BUG 2: the thread resumed its OWN fork ('fork-9'), not the main session.
|
|
408
|
+
const opts = (vi.mocked(query).mock.calls[0][0] as { options: { resume?: string } }).options;
|
|
409
|
+
expect(opts.resume).toBe('fork-9');
|
|
410
|
+
|
|
411
|
+
// BUG 1: the main session's external_thread_id was NOT overwritten with the
|
|
412
|
+
// fork's id (the system/init handler skipped the DB write for thread entries).
|
|
413
|
+
const row = db.prepare('SELECT external_thread_id FROM sessions WHERE id = ?').get('s1') as
|
|
414
|
+
| { external_thread_id: string | null }
|
|
415
|
+
| undefined;
|
|
416
|
+
expect(row?.external_thread_id).toBe('main-1');
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('thread turn with a FAILED fork starts a fresh isolated session (resume undefined), never the main session (BUG 2)', async () => {
|
|
420
|
+
const now = new Date().toISOString();
|
|
421
|
+
db.prepare(
|
|
422
|
+
`INSERT INTO turns (id, session_id, user_prompt, agent_response, status, created_at, updated_at)
|
|
423
|
+
VALUES ('t1', 's1', 'hi', 'resp', 'completed', ?, ?)`,
|
|
424
|
+
).run(now, now);
|
|
425
|
+
// Thread with NO external_thread_id (fork failed / not created).
|
|
426
|
+
db.prepare(
|
|
427
|
+
`INSERT INTO discussion_threads (id, session_id, item_id, turn_id, start_offset, end_offset, selected_text, status, created_at, updated_at)
|
|
428
|
+
VALUES ('th1', 's1', 'i1', 't1', 0, 4, 'resp', 'open', ?, ?)`,
|
|
429
|
+
).run(now, now);
|
|
430
|
+
db.prepare(
|
|
431
|
+
`INSERT INTO turns (id, session_id, user_prompt, status, thread_id, created_at, updated_at)
|
|
432
|
+
VALUES ('tt1', 's1', 'discuss this', 'pending', 'th1', ?, ?)`,
|
|
433
|
+
).run(now, now);
|
|
434
|
+
|
|
435
|
+
// Fresh session emits its own init id, then completes.
|
|
436
|
+
vi.mocked(query).mockReturnValue(createClaudeQueryMock([initMsg('fresh-1'), resultSuccessMsg('fresh-1')]) as never);
|
|
437
|
+
|
|
438
|
+
const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get('s1') as never;
|
|
439
|
+
const turn = db.prepare('SELECT * FROM turns WHERE id = ?').get('tt1') as never;
|
|
440
|
+
await claudeProvider.startTurn(session, turn);
|
|
441
|
+
|
|
442
|
+
await waitForTurnStatus(db, 'tt1', 'completed');
|
|
443
|
+
|
|
444
|
+
// The thread started a FRESH session: no `resume` option (must NOT fall back
|
|
445
|
+
// to the main session's 'main-1').
|
|
446
|
+
const opts = (vi.mocked(query).mock.calls[0][0] as { options: { resume?: string } }).options;
|
|
447
|
+
expect(opts.resume).toBeUndefined();
|
|
448
|
+
|
|
449
|
+
// The main session is untouched.
|
|
450
|
+
const row = db.prepare('SELECT external_thread_id FROM sessions WHERE id = ?').get('s1') as
|
|
451
|
+
| { external_thread_id: string | null }
|
|
452
|
+
| undefined;
|
|
453
|
+
expect(row?.external_thread_id).toBe('main-1');
|
|
454
|
+
});
|
|
65
455
|
});
|
|
@@ -159,7 +159,12 @@ async function ensureSessionStarted(
|
|
|
159
159
|
): Promise<ClaudeSessionEntry> {
|
|
160
160
|
const db = getDb();
|
|
161
161
|
const key = mapKey ?? session.id;
|
|
162
|
-
|
|
162
|
+
// Main session (no mapKey) falls back to its persisted external_thread_id so it
|
|
163
|
+
// resumes the existing conversation. Thread (forked) entries must NOT fall back
|
|
164
|
+
// to the main session's external_thread_id — that would append the thread's
|
|
165
|
+
// messages to the MAIN session transcript (resume appends to the same file).
|
|
166
|
+
// An explicit undefined resume for a thread starts a fresh isolated session.
|
|
167
|
+
const resumeId = mapKey !== undefined ? resumeSessionId : (resumeSessionId ?? session.external_thread_id ?? undefined);
|
|
163
168
|
|
|
164
169
|
// Build options — same as the old per-turn options, but applied once per session.
|
|
165
170
|
const options: Options = {
|
|
@@ -281,12 +286,14 @@ async function startTurn(session: Session, turn: Turn): Promise<void> {
|
|
|
281
286
|
if (!entry) {
|
|
282
287
|
try {
|
|
283
288
|
if (turn.thread_id) {
|
|
284
|
-
// Thread turn — resume from the thread's forked session
|
|
289
|
+
// Thread turn — resume from the thread's OWN forked session id. Do NOT
|
|
290
|
+
// fall back to the main session's external_thread_id: if the fork failed
|
|
291
|
+
// (thread has no external_thread_id), start a FRESH isolated session
|
|
292
|
+
// instead, so the thread never appends to the main session transcript.
|
|
285
293
|
const thread = db
|
|
286
294
|
.prepare('SELECT external_thread_id FROM discussion_threads WHERE id = ?')
|
|
287
295
|
.get(turn.thread_id) as { external_thread_id: string | null } | undefined;
|
|
288
|
-
|
|
289
|
-
entry = await ensureSessionStarted(session, mapKey, resumeId);
|
|
296
|
+
entry = await ensureSessionStarted(session, mapKey, thread?.external_thread_id ?? undefined);
|
|
290
297
|
} else {
|
|
291
298
|
entry = await ensureSessionStarted(session);
|
|
292
299
|
}
|
|
@@ -300,6 +307,11 @@ async function startTurn(session: Session, turn: Turn): Promise<void> {
|
|
|
300
307
|
}
|
|
301
308
|
}
|
|
302
309
|
|
|
310
|
+
// Tag thread entries so the system/init handler can avoid corrupting the
|
|
311
|
+
// main session's external_thread_id with the fork's session id. Set on every
|
|
312
|
+
// startTurn (covers both freshly-created and reused sessionMap entries).
|
|
313
|
+
entry.threadId = turn.thread_id ?? null;
|
|
314
|
+
|
|
303
315
|
// Complete previous turn if still running (e.g. Monitor was active).
|
|
304
316
|
// All messages between user prompts belong to one AgentPane turn —
|
|
305
317
|
// Monitor-triggered internal turns do NOT create separate AgentPane turns.
|
|
@@ -369,7 +381,7 @@ async function startTurn(session: Session, turn: Turn): Promise<void> {
|
|
|
369
381
|
// ---------------------------------------------------------------------------
|
|
370
382
|
|
|
371
383
|
/** Handle a single SDK message — dispatch to the correct handler. */
|
|
372
|
-
async function handleMessage(
|
|
384
|
+
export async function handleMessage(
|
|
373
385
|
message: SDKMessage,
|
|
374
386
|
sessionId: string,
|
|
375
387
|
turnId: string,
|
|
@@ -381,12 +393,21 @@ async function handleMessage(
|
|
|
381
393
|
if ('subtype' in message) {
|
|
382
394
|
const sub = (message as { subtype: string }).subtype;
|
|
383
395
|
if (sub === 'init') {
|
|
384
|
-
//
|
|
396
|
+
// Always capture the Claude session id (needed for Monitor output-path
|
|
397
|
+
// construction). Idempotent — Monitor triggers multiple inits.
|
|
385
398
|
entry.sessionId = (message as { session_id: string }).session_id;
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
399
|
+
// Only persist the resume id for the MAIN session. For thread (forked)
|
|
400
|
+
// entries, the fork's session id already lives in
|
|
401
|
+
// discussion_threads.external_thread_id; writing it here would corrupt
|
|
402
|
+
// the MAIN session's external_thread_id, causing the main session to
|
|
403
|
+
// resume the fork after a restart/rewind and "remember" the thread's
|
|
404
|
+
// discussion.
|
|
405
|
+
if (!entry.threadId) {
|
|
406
|
+
const now = new Date().toISOString();
|
|
407
|
+
db.prepare('UPDATE sessions SET external_thread_id = ?, updated_at = ? WHERE id = ?').run(
|
|
408
|
+
(message as { session_id: string }).session_id, now, sessionId,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
390
411
|
} else if (sub === 'compact_boundary') {
|
|
391
412
|
handleCompactBoundary(message as SDKMessage & { type: 'system'; subtype: 'compact_boundary' }, sessionId, turnId, db);
|
|
392
413
|
} else if (sub === 'task_started') {
|
|
@@ -420,7 +441,7 @@ async function handleMessage(
|
|
|
420
441
|
}
|
|
421
442
|
|
|
422
443
|
case 'result': {
|
|
423
|
-
handleResultMessage(message, sessionId, turnId, db);
|
|
444
|
+
handleResultMessage(message, sessionId, turnId, entry, db);
|
|
424
445
|
break;
|
|
425
446
|
}
|
|
426
447
|
|
|
@@ -861,18 +882,17 @@ function handleBackgroundTasksChanged(
|
|
|
861
882
|
};
|
|
862
883
|
broadcast('background_tasks', { sessionId, tasks: m.tasks ?? [] });
|
|
863
884
|
}
|
|
864
|
-
|
|
865
885
|
/** Handle SDKResultMessage — accumulate usage across multi-result turns (Monitor).
|
|
866
886
|
* Does NOT complete the turn on intermediate results — the turn completes when
|
|
867
887
|
* the next user message is pushed (in startTurn) or when the PromptQueue is
|
|
868
888
|
* closed (in the message loop's generator-end block). Only error results abort early. */
|
|
869
|
-
function handleResultMessage(
|
|
889
|
+
export async function handleResultMessage(
|
|
870
890
|
message: SDKMessage & { type: 'result' },
|
|
871
891
|
sessionId: string,
|
|
872
892
|
turnId: string,
|
|
893
|
+
entry: ClaudeSessionEntry,
|
|
873
894
|
db: Database.Database,
|
|
874
|
-
): void {
|
|
875
|
-
// Subagent result — skip entirely. Prevents subagent token/context data from
|
|
895
|
+
): Promise<void> { // Subagent result — skip entirely. Prevents subagent token/context data from
|
|
876
896
|
// overwriting the main session's token_usage and contextUsage.
|
|
877
897
|
// SDKResultSuccess doesn't declare parent_tool_use_id in its type, but the SDK
|
|
878
898
|
// may set it at runtime (same pattern as handleAssistantMessage/handleUserMessage).
|
|
@@ -892,45 +912,28 @@ function handleResultMessage(
|
|
|
892
912
|
);
|
|
893
913
|
broadcast('turn:tokenUsage', { sessionId, turnId, usage });
|
|
894
914
|
|
|
895
|
-
//
|
|
896
|
-
//
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
const session = db.prepare('SELECT model FROM sessions WHERE id = ?').get(sessionId) as
|
|
904
|
-
| { model: string | null }
|
|
905
|
-
| undefined;
|
|
906
|
-
const sessionModel = session?.model ?? null;
|
|
907
|
-
|
|
908
|
-
let modelEntry = undefined;
|
|
909
|
-
if (modelUsage) {
|
|
910
|
-
if (sessionModel && modelUsage[sessionModel]) {
|
|
911
|
-
modelEntry = modelUsage[sessionModel];
|
|
912
|
-
} else {
|
|
913
|
-
// Fallback: find an entry whose canonicalModel matches, else first entry
|
|
914
|
-
modelEntry = Object.values(modelUsage).find(
|
|
915
|
-
(e) => sessionModel && e.canonicalModel === sessionModel,
|
|
916
|
-
) ?? Object.values(modelUsage)[0];
|
|
917
|
-
}
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
if (modelEntry && modelEntry.contextWindow > 0) {
|
|
921
|
-
const totalTokens =
|
|
922
|
-
result.usage.input_tokens +
|
|
923
|
-
(result.usage.cache_read_input_tokens ?? 0) +
|
|
924
|
-
(result.usage.cache_creation_input_tokens ?? 0);
|
|
925
|
-
|
|
926
|
-
if (totalTokens > 0) {
|
|
915
|
+
// Context-window usage: use the SDK's own /context computation (Query.getContextUsage),
|
|
916
|
+
// NOT the accumulated result.usage input/cache sum. The hand formula double-counts /
|
|
917
|
+
// accumulates in streaming-input mode (e.g. shows 800K while /context shows the real
|
|
918
|
+
// 130K). getContextUsage returns the same structured data as the /context command —
|
|
919
|
+
// the authoritative current in-window fill, computed locally by the CLI.
|
|
920
|
+
try {
|
|
921
|
+
const ctx = await entry.query.getContextUsage();
|
|
922
|
+
if (ctx.totalTokens > 0 && ctx.maxTokens > 0) {
|
|
927
923
|
const contextUsage: ContextUsage = {
|
|
928
|
-
totalTokens,
|
|
929
|
-
contextWindow:
|
|
930
|
-
percentage: Math.round(
|
|
924
|
+
totalTokens: ctx.totalTokens,
|
|
925
|
+
contextWindow: ctx.maxTokens,
|
|
926
|
+
percentage: Math.round(ctx.percentage),
|
|
931
927
|
};
|
|
932
928
|
broadcast('turn:contextUsage', { sessionId, ...contextUsage });
|
|
929
|
+
// Persist so the status-bar counter survives page reload.
|
|
930
|
+
const cuNow = new Date().toISOString();
|
|
931
|
+
db.prepare('UPDATE sessions SET context_usage = ?, updated_at = ? WHERE id = ?').run(
|
|
932
|
+
JSON.stringify(contextUsage), cuNow, sessionId,
|
|
933
|
+
);
|
|
933
934
|
}
|
|
935
|
+
} catch {
|
|
936
|
+
// getContextUsage unavailable (older CLI) or failed — leave contextUsage unchanged.
|
|
934
937
|
}
|
|
935
938
|
|
|
936
939
|
completeTurn(db, sessionId, turnId, usage);
|
|
@@ -175,6 +175,12 @@ function createTables(database: Database.Database): void {
|
|
|
175
175
|
database.exec('ALTER TABLE sessions ADD COLUMN pre_rewind_thread_id TEXT');
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
+
// Migration: add 'context_usage' column — persisted last-known context-window usage
|
|
179
|
+
// (JSON of ContextUsage) so the status-bar counter survives page reload.
|
|
180
|
+
if (!sessColumns.some((c) => c.name === 'context_usage')) {
|
|
181
|
+
database.exec('ALTER TABLE sessions ADD COLUMN context_usage TEXT');
|
|
182
|
+
}
|
|
183
|
+
|
|
178
184
|
// Migration: add 'thread_id' column to turns table (discussion threads feature)
|
|
179
185
|
const turnColumns = database.prepare('PRAGMA table_info(turns)').all() as { name: string }[];
|
|
180
186
|
if (!turnColumns.some((c) => c.name === 'thread_id')) {
|
|
@@ -23,8 +23,11 @@ async function main(): Promise<void> {
|
|
|
23
23
|
// Initialize database
|
|
24
24
|
initDb('data.db');
|
|
25
25
|
|
|
26
|
-
// Create Fastify server
|
|
27
|
-
|
|
26
|
+
// Create Fastify server.
|
|
27
|
+
// bodyLimit: default is 1 MiB, which rejects image attachments (sent as base64
|
|
28
|
+
// in JSON) larger than ~780 KB with 413 Payload Too Large. 50 MB covers the
|
|
29
|
+
// largest payloads Claude accepts (Anthropic: ~32 MB max request, 5 MB/image).
|
|
30
|
+
const fastify = Fastify({ logger: true, bodyLimit: 50 * 1024 * 1024 });
|
|
28
31
|
|
|
29
32
|
// Register CORS (allow Vite dev server)
|
|
30
33
|
await fastify.register(cors, { origin: true });
|