bingocode 1.0.29 → 1.0.31

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 (52) hide show
  1. package/adapters/common/__tests__/chat-queue.test.ts +61 -0
  2. package/adapters/common/__tests__/format.test.ts +148 -0
  3. package/adapters/common/__tests__/http-client.test.ts +105 -0
  4. package/adapters/common/__tests__/message-buffer.test.ts +84 -0
  5. package/adapters/common/__tests__/message-dedup.test.ts +57 -0
  6. package/adapters/common/__tests__/session-store.test.ts +62 -0
  7. package/adapters/common/__tests__/ws-bridge.test.ts +177 -0
  8. package/adapters/common/attachment/__tests__/attachment-limits.test.ts +52 -0
  9. package/adapters/common/attachment/__tests__/attachment-store.test.ts +108 -0
  10. package/adapters/common/attachment/__tests__/image-block-watcher.test.ts +115 -0
  11. package/adapters/feishu/__tests__/card-errors.test.ts +194 -0
  12. package/adapters/feishu/__tests__/cardkit.test.ts +295 -0
  13. package/adapters/feishu/__tests__/extract-payload.test.ts +77 -0
  14. package/adapters/feishu/__tests__/feishu.test.ts +907 -0
  15. package/adapters/feishu/__tests__/flush-controller.test.ts +290 -0
  16. package/adapters/feishu/__tests__/markdown-style.test.ts +353 -0
  17. package/adapters/feishu/__tests__/media.test.ts +120 -0
  18. package/adapters/feishu/__tests__/streaming-card.test.ts +914 -0
  19. package/adapters/telegram/__tests__/media.test.ts +86 -0
  20. package/adapters/telegram/__tests__/telegram.test.ts +115 -0
  21. package/bin/bingo-win.cjs +26 -0
  22. package/bin/bingocode-win.cjs +55 -3
  23. package/bin/claude-win.cjs +55 -3
  24. package/package.json +1 -1
  25. package/src/entrypoints/cli.tsx +4 -2
  26. package/src/manager/CliMenuManager.tsx +48 -17
  27. package/src/server/__tests__/conversation-service.test.ts +173 -0
  28. package/src/server/__tests__/conversations.test.ts +458 -0
  29. package/src/server/__tests__/cron-scheduler.test.ts +575 -0
  30. package/src/server/__tests__/e2e/business-flow.test.ts +841 -0
  31. package/src/server/__tests__/e2e/full-flow.test.ts +357 -0
  32. package/src/server/__tests__/fixtures/mock-sdk-cli.ts +123 -0
  33. package/src/server/__tests__/haha-oauth-api.test.ts +146 -0
  34. package/src/server/__tests__/haha-oauth-service.test.ts +185 -0
  35. package/src/server/__tests__/providers-real.test.ts +244 -0
  36. package/src/server/__tests__/providers.test.ts +579 -0
  37. package/src/server/__tests__/proxy-streaming.test.ts +317 -0
  38. package/src/server/__tests__/proxy-transform.test.ts +469 -0
  39. package/src/server/__tests__/real-llm-test.ts +526 -0
  40. package/src/server/__tests__/scheduled-tasks.test.ts +371 -0
  41. package/src/server/__tests__/sessions.test.ts +786 -0
  42. package/src/server/__tests__/settings.test.ts +376 -0
  43. package/src/server/__tests__/skills.test.ts +125 -0
  44. package/src/server/__tests__/tasks.test.ts +171 -0
  45. package/src/server/__tests__/team-watcher.test.ts +400 -0
  46. package/src/server/__tests__/teams.test.ts +627 -0
  47. package/src/server/ensureSingletonLocalServer.ts +1 -1
  48. package/src/server/middleware/cors.test.ts +27 -0
  49. package/src/utils/__tests__/cronFrequency.test.ts +153 -0
  50. package/src/utils/__tests__/cronTasks.test.ts +204 -0
  51. package/src/utils/computerUse/permissions.test.ts +44 -0
  52. package/src/utils/config.ts +15 -0
@@ -0,0 +1,786 @@
1
+ /**
2
+ * Unit tests for SessionService and Sessions API
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
6
+ import * as fs from 'node:fs/promises'
7
+ import * as path from 'node:path'
8
+ import * as os from 'node:os'
9
+ import { SessionService } from '../services/sessionService.js'
10
+ import { clearCommandsCache } from '../../commands.js'
11
+ import { sanitizePath } from '../../utils/sessionStoragePortable.js'
12
+
13
+ // ============================================================================
14
+ // Test helpers
15
+ // ============================================================================
16
+
17
+ let tmpDir: string
18
+ let service: SessionService
19
+
20
+ /** Create a temporary config dir and configure the service to use it. */
21
+ async function setupTmpConfigDir(): Promise<string> {
22
+ tmpDir = path.join(os.tmpdir(), `claude-test-${Date.now()}-${Math.random().toString(36).slice(2)}`)
23
+ await fs.mkdir(path.join(tmpDir, 'projects'), { recursive: true })
24
+ process.env.CLAUDE_CONFIG_DIR = tmpDir
25
+ return tmpDir
26
+ }
27
+
28
+ async function cleanupTmpDir(): Promise<void> {
29
+ if (tmpDir) {
30
+ await fs.rm(tmpDir, { recursive: true, force: true })
31
+ }
32
+ delete process.env.CLAUDE_CONFIG_DIR
33
+ }
34
+
35
+ /** Write a JSONL session file with given entries. */
36
+ async function writeSessionFile(
37
+ projectDir: string,
38
+ sessionId: string,
39
+ entries: Record<string, unknown>[]
40
+ ): Promise<string> {
41
+ const dir = path.join(tmpDir, 'projects', projectDir)
42
+ await fs.mkdir(dir, { recursive: true })
43
+ const filePath = path.join(dir, `${sessionId}.jsonl`)
44
+ const content = entries.map((e) => JSON.stringify(e)).join('\n') + '\n'
45
+ await fs.writeFile(filePath, content, 'utf-8')
46
+ return filePath
47
+ }
48
+
49
+ async function writeSkill(
50
+ rootDir: string,
51
+ skillName: string,
52
+ description: string,
53
+ ): Promise<void> {
54
+ const skillDir = path.join(rootDir, skillName)
55
+ await fs.mkdir(skillDir, { recursive: true })
56
+ await fs.writeFile(
57
+ path.join(skillDir, 'SKILL.md'),
58
+ ['---', `description: ${description}`, '---', '', `# ${skillName}`].join('\n'),
59
+ 'utf-8',
60
+ )
61
+ }
62
+
63
+ // Sample entries matching real CLI format
64
+ function makeSnapshotEntry(): Record<string, unknown> {
65
+ return {
66
+ type: 'file-history-snapshot',
67
+ messageId: crypto.randomUUID(),
68
+ snapshot: {
69
+ messageId: crypto.randomUUID(),
70
+ trackedFileBackups: {},
71
+ timestamp: '2026-01-01T00:00:00.000Z',
72
+ },
73
+ isSnapshotUpdate: false,
74
+ }
75
+ }
76
+
77
+ function makeUserEntry(content: string, uuid?: string): Record<string, unknown> {
78
+ return {
79
+ parentUuid: null,
80
+ isSidechain: false,
81
+ type: 'user',
82
+ message: { role: 'user', content },
83
+ uuid: uuid || crypto.randomUUID(),
84
+ timestamp: '2026-01-01T00:01:00.000Z',
85
+ userType: 'external',
86
+ cwd: '/tmp/test',
87
+ sessionId: 'test-session',
88
+ }
89
+ }
90
+
91
+ function makeAssistantEntry(content: string, parentUuid?: string): Record<string, unknown> {
92
+ return {
93
+ parentUuid: parentUuid || null,
94
+ isSidechain: false,
95
+ type: 'assistant',
96
+ message: {
97
+ model: 'claude-opus-4-7',
98
+ id: `msg_${crypto.randomUUID().slice(0, 20)}`,
99
+ type: 'message',
100
+ role: 'assistant',
101
+ content: [{ type: 'text', text: content }],
102
+ },
103
+ uuid: crypto.randomUUID(),
104
+ timestamp: '2026-01-01T00:02:00.000Z',
105
+ }
106
+ }
107
+
108
+ function makeMetaUserEntry(): Record<string, unknown> {
109
+ return {
110
+ parentUuid: null,
111
+ isSidechain: false,
112
+ type: 'user',
113
+ message: { role: 'user', content: '<local-command-caveat>internal</local-command-caveat>' },
114
+ isMeta: true,
115
+ uuid: crypto.randomUUID(),
116
+ timestamp: '2026-01-01T00:00:30.000Z',
117
+ }
118
+ }
119
+
120
+ function makeSessionMetaEntry(workDir: string): Record<string, unknown> {
121
+ return {
122
+ type: 'session-meta',
123
+ isMeta: true,
124
+ workDir,
125
+ timestamp: '2026-01-01T00:00:00.000Z',
126
+ }
127
+ }
128
+
129
+ // ============================================================================
130
+ // SessionService tests
131
+ // ============================================================================
132
+
133
+ describe('SessionService', () => {
134
+ beforeEach(async () => {
135
+ await setupTmpConfigDir()
136
+ service = new SessionService()
137
+ })
138
+
139
+ afterEach(async () => {
140
+ clearCommandsCache()
141
+ await cleanupTmpDir()
142
+ })
143
+
144
+ // --------------------------------------------------------------------------
145
+ // listSessions
146
+ // --------------------------------------------------------------------------
147
+
148
+ it('should return empty list when no sessions exist', async () => {
149
+ const result = await service.listSessions()
150
+ expect(result.sessions).toEqual([])
151
+ expect(result.total).toBe(0)
152
+ })
153
+
154
+ it('should list sessions from JSONL files', async () => {
155
+ const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
156
+ await writeSessionFile('-tmp-testproject', sessionId, [
157
+ makeSnapshotEntry(),
158
+ makeUserEntry('Hello Claude'),
159
+ makeAssistantEntry('Hi there!'),
160
+ ])
161
+
162
+ const result = await service.listSessions()
163
+ expect(result.total).toBe(1)
164
+ expect(result.sessions).toHaveLength(1)
165
+
166
+ const session = result.sessions[0]!
167
+ expect(session.id).toBe(sessionId)
168
+ expect(session.title).toBe('Hello Claude')
169
+ expect(session.messageCount).toBe(2) // 1 user + 1 assistant
170
+ expect(session.projectPath).toBe('-tmp-testproject')
171
+ })
172
+
173
+ it('should paginate results with limit and offset', async () => {
174
+ // Create 3 sessions
175
+ for (let i = 0; i < 3; i++) {
176
+ const id = `0000000${i}-bbbb-cccc-dddd-eeeeeeeeeeee`
177
+ await writeSessionFile('-tmp-test', id, [
178
+ makeSnapshotEntry(),
179
+ makeUserEntry(`Message ${i}`),
180
+ ])
181
+ }
182
+
183
+ const page1 = await service.listSessions({ limit: 2, offset: 0 })
184
+ expect(page1.total).toBe(3)
185
+ expect(page1.sessions).toHaveLength(2)
186
+
187
+ const page2 = await service.listSessions({ limit: 2, offset: 2 })
188
+ expect(page2.total).toBe(3)
189
+ expect(page2.sessions).toHaveLength(1)
190
+ })
191
+
192
+ it('should filter sessions by project', async () => {
193
+ const id1 = 'aaaaaaaa-1111-cccc-dddd-eeeeeeeeeeee'
194
+ const id2 = 'aaaaaaaa-2222-cccc-dddd-eeeeeeeeeeee'
195
+
196
+ await writeSessionFile('-project-a', id1, [makeSnapshotEntry(), makeUserEntry('In A')])
197
+ await writeSessionFile('-project-b', id2, [makeSnapshotEntry(), makeUserEntry('In B')])
198
+
199
+ const resultA = await service.listSessions({ project: '/project/a' })
200
+ expect(resultA.total).toBe(1)
201
+ expect(resultA.sessions[0]!.id).toBe(id1)
202
+ })
203
+
204
+ // --------------------------------------------------------------------------
205
+ // getSession
206
+ // --------------------------------------------------------------------------
207
+
208
+ it('should return null for non-existent session', async () => {
209
+ const result = await service.getSession('00000000-0000-0000-0000-000000000000')
210
+ expect(result).toBeNull()
211
+ })
212
+
213
+ it('should return session detail with messages', async () => {
214
+ const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
215
+ const userUuid = crypto.randomUUID()
216
+ await writeSessionFile('-tmp-project', sessionId, [
217
+ makeSnapshotEntry(),
218
+ makeUserEntry('Tell me a joke', userUuid),
219
+ makeAssistantEntry('Why did the chicken cross the road?', userUuid),
220
+ ])
221
+
222
+ const detail = await service.getSession(sessionId)
223
+ expect(detail).not.toBeNull()
224
+ expect(detail!.id).toBe(sessionId)
225
+ expect(detail!.title).toBe('Tell me a joke')
226
+ expect(detail!.messages).toHaveLength(2)
227
+ expect(detail!.messages[0]!.type).toBe('user')
228
+ expect(detail!.messages[1]!.type).toBe('assistant')
229
+ })
230
+
231
+ it('should skip meta entries in messages', async () => {
232
+ const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
233
+ await writeSessionFile('-tmp-project', sessionId, [
234
+ makeSnapshotEntry(),
235
+ makeMetaUserEntry(),
236
+ makeUserEntry('Real message'),
237
+ ])
238
+
239
+ const detail = await service.getSession(sessionId)
240
+ expect(detail!.messages).toHaveLength(1)
241
+ expect(detail!.messages[0]!.content).toBe('Real message')
242
+ })
243
+
244
+ // --------------------------------------------------------------------------
245
+ // getSessionMessages
246
+ // --------------------------------------------------------------------------
247
+
248
+ it('should throw for non-existent session messages', async () => {
249
+ expect(
250
+ service.getSessionMessages('00000000-0000-0000-0000-000000000000')
251
+ ).rejects.toThrow('Session not found')
252
+ })
253
+
254
+ it('should return messages only', async () => {
255
+ const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
256
+ await writeSessionFile('-tmp-project', sessionId, [
257
+ makeSnapshotEntry(),
258
+ makeUserEntry('Hello'),
259
+ makeAssistantEntry('World'),
260
+ ])
261
+
262
+ const messages = await service.getSessionMessages(sessionId)
263
+ expect(messages).toHaveLength(2)
264
+ })
265
+
266
+ it('should reconstruct parent agent tool linkage from parentUuid chains', async () => {
267
+ const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
268
+ const userUuid = crypto.randomUUID()
269
+ const agentAssistantUuid = crypto.randomUUID()
270
+ const childAssistantUuid = crypto.randomUUID()
271
+
272
+ await writeSessionFile('-tmp-project', sessionId, [
273
+ makeSnapshotEntry(),
274
+ makeUserEntry('Inspect the codebase', userUuid),
275
+ {
276
+ parentUuid: userUuid,
277
+ isSidechain: false,
278
+ type: 'assistant',
279
+ message: {
280
+ model: 'claude-opus-4-7',
281
+ id: `msg_${crypto.randomUUID().slice(0, 20)}`,
282
+ type: 'message',
283
+ role: 'assistant',
284
+ content: [
285
+ {
286
+ type: 'tool_use',
287
+ name: 'Agent',
288
+ id: 'agent-tool-1',
289
+ input: { description: 'Inspect src/components' },
290
+ },
291
+ ],
292
+ },
293
+ uuid: agentAssistantUuid,
294
+ timestamp: '2026-01-01T00:02:00.000Z',
295
+ },
296
+ {
297
+ parentUuid: agentAssistantUuid,
298
+ isSidechain: true,
299
+ type: 'assistant',
300
+ message: {
301
+ model: 'claude-opus-4-7',
302
+ id: `msg_${crypto.randomUUID().slice(0, 20)}`,
303
+ type: 'message',
304
+ role: 'assistant',
305
+ content: [
306
+ {
307
+ type: 'tool_use',
308
+ name: 'Read',
309
+ id: 'read-tool-1',
310
+ input: { file_path: 'src/components/App.tsx' },
311
+ },
312
+ ],
313
+ },
314
+ uuid: childAssistantUuid,
315
+ timestamp: '2026-01-01T00:02:30.000Z',
316
+ },
317
+ {
318
+ parentUuid: childAssistantUuid,
319
+ isSidechain: true,
320
+ type: 'user',
321
+ message: {
322
+ role: 'user',
323
+ content: [
324
+ {
325
+ type: 'tool_result',
326
+ tool_use_id: 'read-tool-1',
327
+ content: 'ok',
328
+ is_error: false,
329
+ },
330
+ ],
331
+ },
332
+ uuid: crypto.randomUUID(),
333
+ timestamp: '2026-01-01T00:03:00.000Z',
334
+ userType: 'external',
335
+ cwd: '/tmp/test',
336
+ sessionId: 'test-session',
337
+ },
338
+ ])
339
+
340
+ const messages = await service.getSessionMessages(sessionId)
341
+
342
+ expect(messages[1]).toMatchObject({
343
+ type: 'tool_use',
344
+ parentToolUseId: undefined,
345
+ })
346
+ expect(messages[2]).toMatchObject({
347
+ type: 'tool_use',
348
+ parentToolUseId: 'agent-tool-1',
349
+ })
350
+ expect(messages[3]).toMatchObject({
351
+ type: 'tool_result',
352
+ parentToolUseId: 'agent-tool-1',
353
+ })
354
+ })
355
+
356
+ it('should recover workDir from session-meta entries', async () => {
357
+ const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
358
+ await writeSessionFile('-tmp-project', sessionId, [
359
+ makeSnapshotEntry(),
360
+ makeSessionMetaEntry('/tmp/from-meta'),
361
+ makeUserEntry('Hello'),
362
+ ])
363
+
364
+ const workDir = await service.getSessionWorkDir(sessionId)
365
+ expect(workDir).toBe('/tmp/from-meta')
366
+ })
367
+
368
+ it('should recover workDir from transcript cwd when session-meta is missing', async () => {
369
+ const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
370
+ await writeSessionFile('-tmp-project', sessionId, [
371
+ makeSnapshotEntry(),
372
+ {
373
+ ...makeUserEntry('Hello'),
374
+ cwd: '/tmp/from-cwd',
375
+ },
376
+ ])
377
+
378
+ const workDir = await service.getSessionWorkDir(sessionId)
379
+ expect(workDir).toBe('/tmp/from-cwd')
380
+ })
381
+
382
+ // --------------------------------------------------------------------------
383
+ // createSession
384
+ // --------------------------------------------------------------------------
385
+
386
+ it('should create a new session file', async () => {
387
+ const workDir = path.join(tmpDir, 'workspace', 'my-project')
388
+ await fs.mkdir(workDir, { recursive: true })
389
+ const { sessionId } = await service.createSession(workDir)
390
+ expect(sessionId).toMatch(
391
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
392
+ )
393
+
394
+ // Verify the file was created
395
+ const sanitized = sanitizePath(workDir)
396
+ const filePath = path.join(tmpDir, 'projects', sanitized, `${sessionId}.jsonl`)
397
+ const stat = await fs.stat(filePath)
398
+ expect(stat.isFile()).toBe(true)
399
+
400
+ // Verify the file starts with the initial snapshot entry
401
+ const content = await fs.readFile(filePath, 'utf-8')
402
+ const entry = JSON.parse(content.trim().split('\n')[0]!)
403
+ expect(entry.type).toBe('file-history-snapshot')
404
+ })
405
+
406
+ it('should create a Windows-safe project directory name', async () => {
407
+ if (process.platform !== 'win32') return
408
+
409
+ const workDir = process.cwd()
410
+ const { sessionId } = await service.createSession(workDir)
411
+ const sanitized = sanitizePath(workDir)
412
+ const projectDir = path.join(tmpDir, 'projects', sanitized)
413
+
414
+ expect(sanitized.includes(':')).toBe(false)
415
+ const stat = await fs.stat(path.join(projectDir, `${sessionId}.jsonl`))
416
+ expect(stat.isFile()).toBe(true)
417
+ })
418
+
419
+ it('should default to the user home directory when workDir is missing', async () => {
420
+ const { sessionId } = await service.createSession('')
421
+ const filePath = path.join(
422
+ tmpDir,
423
+ 'projects',
424
+ sanitizePath(os.homedir()),
425
+ `${sessionId}.jsonl`,
426
+ )
427
+
428
+ const stat = await fs.stat(filePath)
429
+ expect(stat.isFile()).toBe(true)
430
+ })
431
+
432
+ it('should throw when workDir does not exist', async () => {
433
+ expect(service.createSession('/tmp/definitely-missing-claude-code-haha')).rejects.toThrow(
434
+ 'Working directory does not exist'
435
+ )
436
+ })
437
+
438
+ // --------------------------------------------------------------------------
439
+ // deleteSession
440
+ // --------------------------------------------------------------------------
441
+
442
+ it('should delete an existing session', async () => {
443
+ const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
444
+ const filePath = await writeSessionFile('-tmp-project', sessionId, [makeSnapshotEntry()])
445
+
446
+ await service.deleteSession(sessionId)
447
+
448
+ // File should no longer exist
449
+ expect(fs.access(filePath)).rejects.toThrow()
450
+ })
451
+
452
+ it('should throw when deleting non-existent session', async () => {
453
+ expect(
454
+ service.deleteSession('00000000-0000-0000-0000-000000000000')
455
+ ).rejects.toThrow('Session not found')
456
+ })
457
+
458
+ // --------------------------------------------------------------------------
459
+ // renameSession
460
+ // --------------------------------------------------------------------------
461
+
462
+ it('should rename a session by appending custom-title entry', async () => {
463
+ const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
464
+ const filePath = await writeSessionFile('-tmp-project', sessionId, [
465
+ makeSnapshotEntry(),
466
+ makeUserEntry('Original message'),
467
+ ])
468
+
469
+ await service.renameSession(sessionId, 'My Custom Title')
470
+
471
+ // Read the file and check the last entry
472
+ const content = await fs.readFile(filePath, 'utf-8')
473
+ const lines = content.trim().split('\n')
474
+ const lastEntry = JSON.parse(lines[lines.length - 1]!)
475
+ expect(lastEntry.type).toBe('custom-title')
476
+ expect(lastEntry.customTitle).toBe('My Custom Title')
477
+
478
+ // Verify the title is now returned in list
479
+ const detail = await service.getSession(sessionId)
480
+ expect(detail!.title).toBe('My Custom Title')
481
+ })
482
+
483
+ it('should throw when renaming non-existent session', async () => {
484
+ expect(
485
+ service.renameSession('00000000-0000-0000-0000-000000000000', 'Title')
486
+ ).rejects.toThrow('Session not found')
487
+ })
488
+
489
+ // --------------------------------------------------------------------------
490
+ // Title extraction
491
+ // --------------------------------------------------------------------------
492
+
493
+ it('should use first user message as title when no custom title', async () => {
494
+ const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
495
+ await writeSessionFile('-tmp-project', sessionId, [
496
+ makeSnapshotEntry(),
497
+ makeMetaUserEntry(),
498
+ makeUserEntry('This is my first real question'),
499
+ ])
500
+
501
+ const detail = await service.getSession(sessionId)
502
+ expect(detail!.title).toBe('This is my first real question')
503
+ })
504
+
505
+ it('should truncate long titles to 80 chars', async () => {
506
+ const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
507
+ const longMessage = 'A'.repeat(120)
508
+ await writeSessionFile('-tmp-project', sessionId, [
509
+ makeSnapshotEntry(),
510
+ makeUserEntry(longMessage),
511
+ ])
512
+
513
+ const detail = await service.getSession(sessionId)
514
+ expect(detail!.title.length).toBe(83) // 80 + '...'
515
+ expect(detail!.title.endsWith('...')).toBe(true)
516
+ })
517
+
518
+ it('should fall back to "Untitled Session" when no user message', async () => {
519
+ const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
520
+ await writeSessionFile('-tmp-project', sessionId, [makeSnapshotEntry()])
521
+
522
+ const detail = await service.getSession(sessionId)
523
+ expect(detail!.title).toBe('Untitled Session')
524
+ })
525
+
526
+ it('should detect placeholder launch info for desktop-created sessions', async () => {
527
+ const { sessionId } = await service.createSession(os.tmpdir())
528
+
529
+ const launchInfo = await service.getSessionLaunchInfo(sessionId)
530
+ expect(launchInfo).not.toBeNull()
531
+ expect(launchInfo!.workDir).toBe(os.tmpdir())
532
+ expect(launchInfo!.transcriptMessageCount).toBe(0)
533
+ expect(launchInfo!.customTitle).toBeNull()
534
+ })
535
+
536
+ it('should detect resumable launch info for transcript sessions', async () => {
537
+ const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
538
+ const userUuid = crypto.randomUUID()
539
+ await writeSessionFile('-tmp-project', sessionId, [
540
+ makeSnapshotEntry(),
541
+ { type: 'session-meta', isMeta: true, workDir: '/tmp/project', timestamp: '2026-01-01T00:00:00.000Z' },
542
+ makeUserEntry('Hello again', userUuid),
543
+ makeAssistantEntry('Welcome back', userUuid),
544
+ { type: 'custom-title', customTitle: 'Saved chat', timestamp: '2026-01-01T00:03:00.000Z' },
545
+ ])
546
+
547
+ const launchInfo = await service.getSessionLaunchInfo(sessionId)
548
+ expect(launchInfo).not.toBeNull()
549
+ expect(launchInfo!.workDir).toBe('/tmp/project')
550
+ expect(launchInfo!.transcriptMessageCount).toBe(2)
551
+ expect(launchInfo!.customTitle).toBe('Saved chat')
552
+ })
553
+ })
554
+
555
+ // ============================================================================
556
+ // Sessions API integration tests
557
+ // ============================================================================
558
+
559
+ describe('Sessions API', () => {
560
+ let baseUrl: string
561
+ let server: ReturnType<typeof Bun.serve> | null = null
562
+
563
+ beforeEach(async () => {
564
+ await setupTmpConfigDir()
565
+ service = new SessionService()
566
+
567
+ // Import and start a minimal test server
568
+ const { handleSessionsApi } = await import('../api/sessions.js')
569
+ const { handleConversationsApi } = await import('../api/conversations.js')
570
+
571
+ const port = 30000 + Math.floor(Math.random() * 10000)
572
+ baseUrl = `http://127.0.0.1:${port}`
573
+
574
+ server = Bun.serve({
575
+ port,
576
+ hostname: '127.0.0.1',
577
+
578
+ async fetch(req) {
579
+ const url = new URL(req.url)
580
+ const segments = url.pathname.split('/').filter(Boolean)
581
+
582
+ if (segments[0] === 'api' && segments[1] === 'sessions') {
583
+ // Route chat sub-resource to conversations handler
584
+ if (segments[3] === 'chat') {
585
+ return handleConversationsApi(req, url, segments)
586
+ }
587
+ return handleSessionsApi(req, url, segments)
588
+ }
589
+
590
+ return new Response('Not Found', { status: 404 })
591
+ },
592
+ })
593
+ })
594
+
595
+ afterEach(async () => {
596
+ if (server) {
597
+ server.stop(true)
598
+ server = null
599
+ }
600
+ await cleanupTmpDir()
601
+ })
602
+
603
+ it('GET /api/sessions should return empty list', async () => {
604
+ const res = await fetch(`${baseUrl}/api/sessions`)
605
+ expect(res.status).toBe(200)
606
+
607
+ const body = (await res.json()) as { sessions: unknown[]; total: number }
608
+ expect(body.sessions).toEqual([])
609
+ expect(body.total).toBe(0)
610
+ })
611
+
612
+ it('POST /api/sessions should create a session', async () => {
613
+ const workDir = await fs.mkdtemp(path.join(tmpDir, 'api-session-'))
614
+ const res = await fetch(`${baseUrl}/api/sessions`, {
615
+ method: 'POST',
616
+ headers: { 'Content-Type': 'application/json' },
617
+ body: JSON.stringify({ workDir }),
618
+ })
619
+ expect(res.status).toBe(201)
620
+
621
+ const body = (await res.json()) as { sessionId: string }
622
+ expect(body.sessionId).toMatch(
623
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
624
+ )
625
+ })
626
+
627
+ it('POST /api/sessions should create a session when workDir is omitted', async () => {
628
+ const res = await fetch(`${baseUrl}/api/sessions`, {
629
+ method: 'POST',
630
+ headers: { 'Content-Type': 'application/json' },
631
+ body: JSON.stringify({}),
632
+ })
633
+ expect(res.status).toBe(201)
634
+
635
+ const body = (await res.json()) as { sessionId: string }
636
+ expect(body.sessionId).toMatch(
637
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
638
+ )
639
+ })
640
+
641
+ it('GET /api/sessions/:id should return session detail', async () => {
642
+ // Create a session file
643
+ const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
644
+ await writeSessionFile('-tmp-api-test', sessionId, [
645
+ makeSnapshotEntry(),
646
+ makeUserEntry('API test message'),
647
+ makeAssistantEntry('API test response'),
648
+ ])
649
+
650
+ const res = await fetch(`${baseUrl}/api/sessions/${sessionId}`)
651
+ expect(res.status).toBe(200)
652
+
653
+ const body = (await res.json()) as { id: string; title: string; messages: unknown[] }
654
+ expect(body.id).toBe(sessionId)
655
+ expect(body.title).toBe('API test message')
656
+ expect(body.messages).toHaveLength(2)
657
+ })
658
+
659
+ it('GET /api/sessions/:id should 404 for unknown session', async () => {
660
+ const res = await fetch(`${baseUrl}/api/sessions/00000000-0000-0000-0000-000000000000`)
661
+ expect(res.status).toBe(404)
662
+ })
663
+
664
+ it('GET /api/sessions/:id/messages should return messages', async () => {
665
+ const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
666
+ await writeSessionFile('-tmp-api-test', sessionId, [
667
+ makeSnapshotEntry(),
668
+ makeUserEntry('Hello'),
669
+ makeAssistantEntry('World'),
670
+ ])
671
+
672
+ const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/messages`)
673
+ expect(res.status).toBe(200)
674
+
675
+ const body = (await res.json()) as { messages: unknown[] }
676
+ expect(body.messages).toHaveLength(2)
677
+ })
678
+
679
+ it('DELETE /api/sessions/:id should delete the session', async () => {
680
+ const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
681
+ await writeSessionFile('-tmp-api-test', sessionId, [makeSnapshotEntry()])
682
+
683
+ const res = await fetch(`${baseUrl}/api/sessions/${sessionId}`, { method: 'DELETE' })
684
+ expect(res.status).toBe(200)
685
+
686
+ // Verify it's gone
687
+ const res2 = await fetch(`${baseUrl}/api/sessions/${sessionId}`)
688
+ expect(res2.status).toBe(404)
689
+ })
690
+
691
+ it('PATCH /api/sessions/:id should rename the session', async () => {
692
+ const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
693
+ await writeSessionFile('-tmp-api-test', sessionId, [
694
+ makeSnapshotEntry(),
695
+ makeUserEntry('Old title message'),
696
+ ])
697
+
698
+ const res = await fetch(`${baseUrl}/api/sessions/${sessionId}`, {
699
+ method: 'PATCH',
700
+ headers: { 'Content-Type': 'application/json' },
701
+ body: JSON.stringify({ title: 'New Custom Title' }),
702
+ })
703
+ expect(res.status).toBe(200)
704
+
705
+ // Verify new title
706
+ const detailRes = await fetch(`${baseUrl}/api/sessions/${sessionId}`)
707
+ const detail = (await detailRes.json()) as { title: string }
708
+ expect(detail.title).toBe('New Custom Title')
709
+ })
710
+
711
+ it('GET /api/sessions/:id/slash-commands should include user and project skills before CLI init', async () => {
712
+ const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
713
+ const workDir = path.join(tmpDir, 'workspace', 'app')
714
+
715
+ await fs.mkdir(path.join(workDir, '.claude', 'skills'), { recursive: true })
716
+ await fs.mkdir(path.join(tmpDir, 'skills'), { recursive: true })
717
+ await writeSkill(path.join(tmpDir, 'skills'), 'user-skill', 'User skill description')
718
+ await writeSkill(path.join(workDir, '.claude', 'skills'), 'project-skill', 'Project skill description')
719
+
720
+ await writeSessionFile('-tmp-api-test', sessionId, [
721
+ makeSnapshotEntry(),
722
+ makeSessionMetaEntry(workDir),
723
+ ])
724
+
725
+ clearCommandsCache()
726
+
727
+ const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/slash-commands`)
728
+ expect(res.status).toBe(200)
729
+
730
+ const body = (await res.json()) as {
731
+ commands: Array<{ name: string; description: string }>
732
+ }
733
+
734
+ expect(body.commands).toContainEqual(
735
+ expect.objectContaining({ name: 'user-skill', description: 'User skill description' }),
736
+ )
737
+ expect(body.commands).toContainEqual(
738
+ expect.objectContaining({ name: 'project-skill', description: 'Project skill description' }),
739
+ )
740
+ })
741
+
742
+ // --------------------------------------------------------------------------
743
+ // Conversations API via /api/sessions/:id/chat
744
+ // --------------------------------------------------------------------------
745
+
746
+ it('GET /api/sessions/:id/chat/status should return idle by default', async () => {
747
+ const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
748
+ const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/chat/status`)
749
+ expect(res.status).toBe(200)
750
+
751
+ const body = (await res.json()) as { state: string }
752
+ expect(body.state).toBe('idle')
753
+ })
754
+
755
+ it('POST /api/sessions/:id/chat should queue a message', async () => {
756
+ const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
757
+ await writeSessionFile('-tmp-api-test', sessionId, [
758
+ makeSnapshotEntry(),
759
+ makeUserEntry('Previous'),
760
+ ])
761
+
762
+ const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/chat`, {
763
+ method: 'POST',
764
+ headers: { 'Content-Type': 'application/json' },
765
+ body: JSON.stringify({ content: 'New question' }),
766
+ })
767
+ expect(res.status).toBe(202)
768
+
769
+ const body = (await res.json()) as { messageId: string; status: string }
770
+ expect(body.status).toBe('queued')
771
+ expect(body.messageId).toBeTruthy()
772
+ })
773
+
774
+ it('POST /api/sessions/:id/chat/stop should reset state to idle', async () => {
775
+ const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
776
+ const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/chat/stop`, {
777
+ method: 'POST',
778
+ })
779
+ expect(res.status).toBe(200)
780
+
781
+ // Verify state is idle
782
+ const statusRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/chat/status`)
783
+ const status = (await statusRes.json()) as { state: string }
784
+ expect(status.state).toBe('idle')
785
+ })
786
+ })