bingocode 1.0.29 → 1.0.30
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/adapters/common/__tests__/chat-queue.test.ts +61 -0
- package/adapters/common/__tests__/format.test.ts +148 -0
- package/adapters/common/__tests__/http-client.test.ts +105 -0
- package/adapters/common/__tests__/message-buffer.test.ts +84 -0
- package/adapters/common/__tests__/message-dedup.test.ts +57 -0
- package/adapters/common/__tests__/session-store.test.ts +62 -0
- package/adapters/common/__tests__/ws-bridge.test.ts +177 -0
- package/adapters/common/attachment/__tests__/attachment-limits.test.ts +52 -0
- package/adapters/common/attachment/__tests__/attachment-store.test.ts +108 -0
- package/adapters/common/attachment/__tests__/image-block-watcher.test.ts +115 -0
- package/adapters/feishu/__tests__/card-errors.test.ts +194 -0
- package/adapters/feishu/__tests__/cardkit.test.ts +295 -0
- package/adapters/feishu/__tests__/extract-payload.test.ts +77 -0
- package/adapters/feishu/__tests__/feishu.test.ts +907 -0
- package/adapters/feishu/__tests__/flush-controller.test.ts +290 -0
- package/adapters/feishu/__tests__/markdown-style.test.ts +353 -0
- package/adapters/feishu/__tests__/media.test.ts +120 -0
- package/adapters/feishu/__tests__/streaming-card.test.ts +914 -0
- package/adapters/telegram/__tests__/media.test.ts +86 -0
- package/adapters/telegram/__tests__/telegram.test.ts +115 -0
- package/package.json +1 -1
- package/src/server/__tests__/conversation-service.test.ts +173 -0
- package/src/server/__tests__/conversations.test.ts +458 -0
- package/src/server/__tests__/cron-scheduler.test.ts +575 -0
- package/src/server/__tests__/e2e/business-flow.test.ts +841 -0
- package/src/server/__tests__/e2e/full-flow.test.ts +357 -0
- package/src/server/__tests__/fixtures/mock-sdk-cli.ts +123 -0
- package/src/server/__tests__/haha-oauth-api.test.ts +146 -0
- package/src/server/__tests__/haha-oauth-service.test.ts +185 -0
- package/src/server/__tests__/providers-real.test.ts +244 -0
- package/src/server/__tests__/providers.test.ts +579 -0
- package/src/server/__tests__/proxy-streaming.test.ts +317 -0
- package/src/server/__tests__/proxy-transform.test.ts +469 -0
- package/src/server/__tests__/real-llm-test.ts +526 -0
- package/src/server/__tests__/scheduled-tasks.test.ts +371 -0
- package/src/server/__tests__/sessions.test.ts +786 -0
- package/src/server/__tests__/settings.test.ts +376 -0
- package/src/server/__tests__/skills.test.ts +125 -0
- package/src/server/__tests__/tasks.test.ts +171 -0
- package/src/server/__tests__/team-watcher.test.ts +400 -0
- package/src/server/__tests__/teams.test.ts +627 -0
- package/src/server/middleware/cors.test.ts +27 -0
- package/src/utils/__tests__/cronFrequency.test.ts +153 -0
- package/src/utils/__tests__/cronTasks.test.ts +204 -0
- package/src/utils/computerUse/permissions.test.ts +44 -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
|
+
})
|