bingocode 1.0.28 → 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/adapters/tsconfig.json +18 -0
- package/bunfig.toml +1 -0
- package/package.json +1 -1
- package/preload.ts +30 -0
- package/scripts/count-app-loc.ts +256 -0
- package/scripts/release.ts +130 -0
- 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
- package/stubs/ant-claude-for-chrome-mcp.ts +24 -0
- package/stubs/color-diff-napi.ts +45 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect } from 'bun:test'
|
|
2
|
+
import { enqueue } from '../chat-queue.js'
|
|
3
|
+
|
|
4
|
+
describe('ChatQueue', () => {
|
|
5
|
+
it('executes tasks for the same chatId serially', async () => {
|
|
6
|
+
const order: number[] = []
|
|
7
|
+
|
|
8
|
+
await Promise.all([
|
|
9
|
+
enqueue('chat-1', async () => {
|
|
10
|
+
await new Promise((r) => setTimeout(r, 30))
|
|
11
|
+
order.push(1)
|
|
12
|
+
}),
|
|
13
|
+
enqueue('chat-1', async () => {
|
|
14
|
+
order.push(2)
|
|
15
|
+
}),
|
|
16
|
+
enqueue('chat-1', async () => {
|
|
17
|
+
order.push(3)
|
|
18
|
+
}),
|
|
19
|
+
])
|
|
20
|
+
|
|
21
|
+
// Wait for all to complete
|
|
22
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
23
|
+
expect(order).toEqual([1, 2, 3])
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('executes tasks for different chatIds in parallel', async () => {
|
|
27
|
+
const order: string[] = []
|
|
28
|
+
|
|
29
|
+
const p1 = enqueue('chat-a', async () => {
|
|
30
|
+
await new Promise((r) => setTimeout(r, 30))
|
|
31
|
+
order.push('a')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const p2 = enqueue('chat-b', async () => {
|
|
35
|
+
order.push('b') // should run immediately, not wait for chat-a
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
await Promise.all([p1, p2])
|
|
39
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
40
|
+
|
|
41
|
+
// 'b' should appear before 'a' since chat-a has a delay
|
|
42
|
+
expect(order[0]).toBe('b')
|
|
43
|
+
expect(order[1]).toBe('a')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('continues processing after a task fails', async () => {
|
|
47
|
+
const order: number[] = []
|
|
48
|
+
|
|
49
|
+
await enqueue('chat-err', async () => {
|
|
50
|
+
order.push(1)
|
|
51
|
+
throw new Error('task failed')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
await enqueue('chat-err', async () => {
|
|
55
|
+
order.push(2) // should still run
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
await new Promise((r) => setTimeout(r, 20))
|
|
59
|
+
expect(order).toEqual([1, 2])
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, it, expect } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
formatImHelp,
|
|
4
|
+
formatImStatus,
|
|
5
|
+
splitMessage,
|
|
6
|
+
formatToolUse,
|
|
7
|
+
formatPermissionRequest,
|
|
8
|
+
truncateInput,
|
|
9
|
+
escapeMarkdownV2,
|
|
10
|
+
} from '../format.js'
|
|
11
|
+
|
|
12
|
+
describe('splitMessage', () => {
|
|
13
|
+
it('returns single chunk for short text', () => {
|
|
14
|
+
expect(splitMessage('hello', 100)).toEqual(['hello'])
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('splits at paragraph boundary', () => {
|
|
18
|
+
const text = 'First paragraph.\n\nSecond paragraph.'
|
|
19
|
+
const chunks = splitMessage(text, 20)
|
|
20
|
+
expect(chunks.length).toBeGreaterThan(1)
|
|
21
|
+
expect(chunks.join(' ').replace(/\s+/g, ' ')).toContain('First paragraph')
|
|
22
|
+
expect(chunks.join(' ').replace(/\s+/g, ' ')).toContain('Second paragraph')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('splits at newline if no paragraph break', () => {
|
|
26
|
+
const text = 'Line one\nLine two\nLine three\nLine four'
|
|
27
|
+
const chunks = splitMessage(text, 20)
|
|
28
|
+
expect(chunks.length).toBeGreaterThan(1)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('hard-splits at limit if no natural break', () => {
|
|
32
|
+
const text = 'a'.repeat(50)
|
|
33
|
+
const chunks = splitMessage(text, 20)
|
|
34
|
+
expect(chunks.length).toBe(3) // 20 + 20 + 10
|
|
35
|
+
expect(chunks.every((c) => c.length <= 20)).toBe(true)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('preserves all content after splitting', () => {
|
|
39
|
+
const text = 'Hello world. This is a test. Foo bar baz.'
|
|
40
|
+
const chunks = splitMessage(text, 15)
|
|
41
|
+
const joined = chunks.join(' ')
|
|
42
|
+
// All words should be present
|
|
43
|
+
expect(joined).toContain('Hello')
|
|
44
|
+
expect(joined).toContain('test')
|
|
45
|
+
expect(joined).toContain('baz')
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('formatToolUse', () => {
|
|
50
|
+
it('includes tool name and input preview', () => {
|
|
51
|
+
const result = formatToolUse('Bash', { command: 'npm test' })
|
|
52
|
+
expect(result).toContain('🔧 Bash')
|
|
53
|
+
expect(result).toContain('npm test')
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('formatPermissionRequest', () => {
|
|
58
|
+
it('includes tool name, input preview, and request ID', () => {
|
|
59
|
+
const result = formatPermissionRequest('Bash', { command: 'rm -rf /' }, 'abcde')
|
|
60
|
+
expect(result).toContain('🔐')
|
|
61
|
+
expect(result).toContain('Bash')
|
|
62
|
+
expect(result).toContain('abcde')
|
|
63
|
+
expect(result).toContain('rm -rf')
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe('truncateInput', () => {
|
|
68
|
+
it('returns short input as-is', () => {
|
|
69
|
+
expect(truncateInput('hello', 100)).toBe('hello')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('truncates long input with ellipsis', () => {
|
|
73
|
+
const long = 'x'.repeat(300)
|
|
74
|
+
const result = truncateInput(long, 100)
|
|
75
|
+
expect(result.length).toBe(101) // 100 chars + '…'
|
|
76
|
+
expect(result.endsWith('…')).toBe(true)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('handles objects by stringifying', () => {
|
|
80
|
+
const result = truncateInput({ key: 'value' }, 100)
|
|
81
|
+
expect(result).toContain('key')
|
|
82
|
+
expect(result).toContain('value')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('handles unserializable input', () => {
|
|
86
|
+
const circular: any = {}
|
|
87
|
+
circular.self = circular
|
|
88
|
+
expect(truncateInput(circular, 100)).toBe('(unserializable)')
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('escapeMarkdownV2', () => {
|
|
93
|
+
it('escapes special characters', () => {
|
|
94
|
+
expect(escapeMarkdownV2('hello_world')).toBe('hello\\_world')
|
|
95
|
+
expect(escapeMarkdownV2('a*b*c')).toBe('a\\*b\\*c')
|
|
96
|
+
expect(escapeMarkdownV2('test.md')).toBe('test\\.md')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('leaves plain text unchanged', () => {
|
|
100
|
+
expect(escapeMarkdownV2('hello world')).toBe('hello world')
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe('formatImHelp', () => {
|
|
105
|
+
it('lists the lightweight IM commands', () => {
|
|
106
|
+
const text = formatImHelp()
|
|
107
|
+
expect(text).toContain('/new')
|
|
108
|
+
expect(text).toContain('/projects')
|
|
109
|
+
expect(text).toContain('/status')
|
|
110
|
+
expect(text).toContain('/clear')
|
|
111
|
+
expect(text).toContain('/stop')
|
|
112
|
+
expect(text).toContain('/help')
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
describe('formatImStatus', () => {
|
|
117
|
+
it('formats an active session summary for mobile reading', () => {
|
|
118
|
+
const text = formatImStatus({
|
|
119
|
+
sessionId: 'abc1234567890',
|
|
120
|
+
projectName: 'claude-code-haha',
|
|
121
|
+
branch: 'main',
|
|
122
|
+
model: 'claude-sonnet',
|
|
123
|
+
state: 'tool_executing',
|
|
124
|
+
verb: 'Running tests',
|
|
125
|
+
pendingPermissionCount: 1,
|
|
126
|
+
taskCounts: {
|
|
127
|
+
total: 4,
|
|
128
|
+
pending: 1,
|
|
129
|
+
inProgress: 2,
|
|
130
|
+
completed: 1,
|
|
131
|
+
},
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
expect(text).toContain('项目: claude-code-haha (main)')
|
|
135
|
+
expect(text).toContain('会话: abc12345…')
|
|
136
|
+
expect(text).toContain('模型: claude-sonnet')
|
|
137
|
+
expect(text).toContain('状态: 执行工具中 (Running tests)')
|
|
138
|
+
expect(text).toContain('审批: 1 个待确认')
|
|
139
|
+
expect(text).toContain('任务: 总计 4 · 进行中 2 · 待处理 1 · 已完成 1')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('returns a friendly empty-session message when nothing is active', () => {
|
|
143
|
+
const text = formatImStatus(null)
|
|
144
|
+
expect(text).toContain('当前没有活动会话')
|
|
145
|
+
expect(text).toContain('/new')
|
|
146
|
+
expect(text).toContain('/projects')
|
|
147
|
+
})
|
|
148
|
+
})
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test'
|
|
2
|
+
import { AdapterHttpClient } from '../http-client.js'
|
|
3
|
+
|
|
4
|
+
describe('AdapterHttpClient', () => {
|
|
5
|
+
let client: AdapterHttpClient
|
|
6
|
+
const originalFetch = globalThis.fetch
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
client = new AdapterHttpClient('ws://127.0.0.1:3456')
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
globalThis.fetch = originalFetch
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('derives HTTP URL from WS URL', () => {
|
|
17
|
+
expect(client.httpBaseUrl).toBe('http://127.0.0.1:3456')
|
|
18
|
+
|
|
19
|
+
const secure = new AdapterHttpClient('wss://example.com:443')
|
|
20
|
+
expect(secure.httpBaseUrl).toBe('https://example.com:443')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('createSession calls POST /api/sessions', async () => {
|
|
24
|
+
const mockSessionId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
|
25
|
+
globalThis.fetch = mock(() =>
|
|
26
|
+
Promise.resolve(new Response(JSON.stringify({ sessionId: mockSessionId }), {
|
|
27
|
+
status: 201,
|
|
28
|
+
headers: { 'Content-Type': 'application/json' },
|
|
29
|
+
}))
|
|
30
|
+
) as any
|
|
31
|
+
|
|
32
|
+
const sessionId = await client.createSession('/path/to/project')
|
|
33
|
+
expect(sessionId).toBe(mockSessionId)
|
|
34
|
+
|
|
35
|
+
const call = (globalThis.fetch as any).mock.calls[0]
|
|
36
|
+
expect(call[0]).toBe('http://127.0.0.1:3456/api/sessions')
|
|
37
|
+
const body = JSON.parse(call[1].body)
|
|
38
|
+
expect(body.workDir).toBe('/path/to/project')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('listRecentProjects calls GET /api/sessions/recent-projects', async () => {
|
|
42
|
+
const mockProjects = [
|
|
43
|
+
{ projectName: 'my-app', realPath: '/home/user/my-app', sessionCount: 3 },
|
|
44
|
+
]
|
|
45
|
+
globalThis.fetch = mock(() =>
|
|
46
|
+
Promise.resolve(new Response(JSON.stringify({ projects: mockProjects }), {
|
|
47
|
+
headers: { 'Content-Type': 'application/json' },
|
|
48
|
+
}))
|
|
49
|
+
) as any
|
|
50
|
+
|
|
51
|
+
const projects = await client.listRecentProjects()
|
|
52
|
+
expect(projects).toHaveLength(1)
|
|
53
|
+
expect(projects[0].projectName).toBe('my-app')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('createSession throws on server error', async () => {
|
|
57
|
+
globalThis.fetch = mock(() =>
|
|
58
|
+
Promise.resolve(new Response(JSON.stringify({ error: 'BAD_REQUEST', message: 'workDir required' }), {
|
|
59
|
+
status: 400,
|
|
60
|
+
headers: { 'Content-Type': 'application/json' },
|
|
61
|
+
}))
|
|
62
|
+
) as any
|
|
63
|
+
|
|
64
|
+
expect(client.createSession('')).rejects.toThrow()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('getGitInfo calls GET /api/sessions/:id/git-info', async () => {
|
|
68
|
+
globalThis.fetch = mock(() =>
|
|
69
|
+
Promise.resolve(new Response(JSON.stringify({
|
|
70
|
+
branch: 'main',
|
|
71
|
+
repoName: 'claude-code-haha',
|
|
72
|
+
workDir: '/repo/claude-code-haha',
|
|
73
|
+
changedFiles: 2,
|
|
74
|
+
}), {
|
|
75
|
+
headers: { 'Content-Type': 'application/json' },
|
|
76
|
+
}))
|
|
77
|
+
) as any
|
|
78
|
+
|
|
79
|
+
const gitInfo = await client.getGitInfo('session-123')
|
|
80
|
+
expect(gitInfo.repoName).toBe('claude-code-haha')
|
|
81
|
+
expect((globalThis.fetch as any).mock.calls[0][0]).toBe(
|
|
82
|
+
'http://127.0.0.1:3456/api/sessions/session-123/git-info',
|
|
83
|
+
)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('getTasksForSession calls GET /api/tasks/lists/:id', async () => {
|
|
87
|
+
globalThis.fetch = mock(() =>
|
|
88
|
+
Promise.resolve(new Response(JSON.stringify({
|
|
89
|
+
tasks: [
|
|
90
|
+
{ id: '1', subject: 'Fix bug', status: 'in_progress' },
|
|
91
|
+
{ id: '2', subject: 'Write docs', status: 'pending' },
|
|
92
|
+
],
|
|
93
|
+
}), {
|
|
94
|
+
headers: { 'Content-Type': 'application/json' },
|
|
95
|
+
}))
|
|
96
|
+
) as any
|
|
97
|
+
|
|
98
|
+
const tasks = await client.getTasksForSession('session-123')
|
|
99
|
+
expect(tasks).toHaveLength(2)
|
|
100
|
+
expect(tasks[0]?.status).toBe('in_progress')
|
|
101
|
+
expect((globalThis.fetch as any).mock.calls[0][0]).toBe(
|
|
102
|
+
'http://127.0.0.1:3456/api/tasks/lists/session-123',
|
|
103
|
+
)
|
|
104
|
+
})
|
|
105
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'bun:test'
|
|
2
|
+
import { MessageBuffer } from '../message-buffer.js'
|
|
3
|
+
|
|
4
|
+
describe('MessageBuffer', () => {
|
|
5
|
+
it('accumulates text and flushes on complete', async () => {
|
|
6
|
+
const flushed: Array<{ text: string; isComplete: boolean }> = []
|
|
7
|
+
const buf = new MessageBuffer(
|
|
8
|
+
(text, isComplete) => { flushed.push({ text, isComplete }) },
|
|
9
|
+
500, // 500ms interval
|
|
10
|
+
1000, // 1000 char threshold
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
buf.append('Hello ')
|
|
14
|
+
buf.append('World')
|
|
15
|
+
await buf.complete()
|
|
16
|
+
|
|
17
|
+
expect(flushed.length).toBeGreaterThanOrEqual(1)
|
|
18
|
+
const allText = flushed.map((f) => f.text).join('')
|
|
19
|
+
expect(allText).toBe('Hello World')
|
|
20
|
+
// Last flush should be marked complete
|
|
21
|
+
expect(flushed[flushed.length - 1]!.isComplete).toBe(true)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('flushes when character threshold is reached', async () => {
|
|
25
|
+
const flushed: string[] = []
|
|
26
|
+
const buf = new MessageBuffer(
|
|
27
|
+
(text) => { flushed.push(text) },
|
|
28
|
+
10000, // very long interval (won't trigger)
|
|
29
|
+
10, // 10 char threshold
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
buf.append('12345678901') // 11 chars > threshold
|
|
33
|
+
|
|
34
|
+
// Wait for microtask
|
|
35
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
36
|
+
expect(flushed.length).toBeGreaterThanOrEqual(1)
|
|
37
|
+
|
|
38
|
+
buf.reset()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('flushes on timer interval', async () => {
|
|
42
|
+
const flushed: string[] = []
|
|
43
|
+
const buf = new MessageBuffer(
|
|
44
|
+
(text) => { flushed.push(text) },
|
|
45
|
+
50, // 50ms interval
|
|
46
|
+
1000,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
buf.append('hi')
|
|
50
|
+
|
|
51
|
+
// Wait for timer
|
|
52
|
+
await new Promise((r) => setTimeout(r, 80))
|
|
53
|
+
expect(flushed).toContain('hi')
|
|
54
|
+
|
|
55
|
+
buf.reset()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('does not flush empty buffer on complete', async () => {
|
|
59
|
+
const flushed: string[] = []
|
|
60
|
+
const buf = new MessageBuffer(
|
|
61
|
+
(text) => { flushed.push(text) },
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
await buf.complete()
|
|
65
|
+
expect(flushed.length).toBe(0)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('resets properly between messages', async () => {
|
|
69
|
+
const flushed: string[] = []
|
|
70
|
+
const buf = new MessageBuffer(
|
|
71
|
+
(text) => { flushed.push(text) },
|
|
72
|
+
500,
|
|
73
|
+
1000,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
buf.append('first')
|
|
77
|
+
buf.reset()
|
|
78
|
+
buf.append('second')
|
|
79
|
+
await buf.complete()
|
|
80
|
+
|
|
81
|
+
const allText = flushed.map((f) => f).join('')
|
|
82
|
+
expect(allText).toBe('second')
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
|
|
2
|
+
import { MessageDedup } from '../message-dedup.js'
|
|
3
|
+
|
|
4
|
+
describe('MessageDedup', () => {
|
|
5
|
+
let dedup: MessageDedup
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
dedup = new MessageDedup(1000, 100) // 1s TTL, 100 max entries
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
dedup.destroy()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('returns true for new messages', () => {
|
|
16
|
+
expect(dedup.tryRecord('msg-1')).toBe(true)
|
|
17
|
+
expect(dedup.tryRecord('msg-2')).toBe(true)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('returns false for duplicate messages', () => {
|
|
21
|
+
expect(dedup.tryRecord('msg-1')).toBe(true)
|
|
22
|
+
expect(dedup.tryRecord('msg-1')).toBe(false)
|
|
23
|
+
expect(dedup.tryRecord('msg-1')).toBe(false)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('allows same ID after TTL expires', async () => {
|
|
27
|
+
const shortDedup = new MessageDedup(50, 100) // 50ms TTL
|
|
28
|
+
expect(shortDedup.tryRecord('msg-1')).toBe(true)
|
|
29
|
+
expect(shortDedup.tryRecord('msg-1')).toBe(false)
|
|
30
|
+
await new Promise((r) => setTimeout(r, 60))
|
|
31
|
+
expect(shortDedup.tryRecord('msg-1')).toBe(true)
|
|
32
|
+
shortDedup.destroy()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('evicts oldest entry when at capacity', () => {
|
|
36
|
+
const smallDedup = new MessageDedup(60_000, 3) // max 3 entries
|
|
37
|
+
expect(smallDedup.tryRecord('a')).toBe(true)
|
|
38
|
+
expect(smallDedup.tryRecord('b')).toBe(true)
|
|
39
|
+
expect(smallDedup.tryRecord('c')).toBe(true)
|
|
40
|
+
// Adding 4th should evict 'a'
|
|
41
|
+
expect(smallDedup.tryRecord('d')).toBe(true)
|
|
42
|
+
// 'a' was evicted, should be treated as new
|
|
43
|
+
expect(smallDedup.tryRecord('a')).toBe(true)
|
|
44
|
+
// Now store has {c, d, a} — 'b' was evicted when 'a' was re-inserted
|
|
45
|
+
// 'c' should still be deduped (was not evicted)
|
|
46
|
+
expect(smallDedup.tryRecord('c')).toBe(false)
|
|
47
|
+
smallDedup.destroy()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('handles distinct messages independently', () => {
|
|
51
|
+
expect(dedup.tryRecord('msg-1')).toBe(true)
|
|
52
|
+
expect(dedup.tryRecord('msg-2')).toBe(true)
|
|
53
|
+
expect(dedup.tryRecord('msg-1')).toBe(false)
|
|
54
|
+
expect(dedup.tryRecord('msg-2')).toBe(false)
|
|
55
|
+
expect(dedup.tryRecord('msg-3')).toBe(true)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
|
|
2
|
+
import * as fs from 'node:fs'
|
|
3
|
+
import * as path from 'node:path'
|
|
4
|
+
import * as os from 'node:os'
|
|
5
|
+
import { SessionStore } from '../session-store.js'
|
|
6
|
+
|
|
7
|
+
describe('SessionStore', () => {
|
|
8
|
+
let tmpDir: string
|
|
9
|
+
let store: SessionStore
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-store-'))
|
|
13
|
+
store = new SessionStore(path.join(tmpDir, 'sessions.json'))
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('returns null for unknown chatId', () => {
|
|
21
|
+
expect(store.get('unknown')).toBeNull()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('stores and retrieves a session', () => {
|
|
25
|
+
store.set('chat-1', 'uuid-aaa', '/path/to/project')
|
|
26
|
+
const entry = store.get('chat-1')
|
|
27
|
+
expect(entry).not.toBeNull()
|
|
28
|
+
expect(entry!.sessionId).toBe('uuid-aaa')
|
|
29
|
+
expect(entry!.workDir).toBe('/path/to/project')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('overwrites existing entry on set', () => {
|
|
33
|
+
store.set('chat-1', 'uuid-aaa', '/old')
|
|
34
|
+
store.set('chat-1', 'uuid-bbb', '/new')
|
|
35
|
+
expect(store.get('chat-1')!.sessionId).toBe('uuid-bbb')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('deletes an entry', () => {
|
|
39
|
+
store.set('chat-1', 'uuid-aaa', '/path')
|
|
40
|
+
store.delete('chat-1')
|
|
41
|
+
expect(store.get('chat-1')).toBeNull()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('persists to disk and reloads', () => {
|
|
45
|
+
store.set('chat-1', 'uuid-aaa', '/path')
|
|
46
|
+
|
|
47
|
+
const store2 = new SessionStore(path.join(tmpDir, 'sessions.json'))
|
|
48
|
+
expect(store2.get('chat-1')!.sessionId).toBe('uuid-aaa')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('handles missing file gracefully', () => {
|
|
52
|
+
const store2 = new SessionStore(path.join(tmpDir, 'nonexistent.json'))
|
|
53
|
+
expect(store2.get('anything')).toBeNull()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('lists all entries', () => {
|
|
57
|
+
store.set('chat-1', 'uuid-1', '/a')
|
|
58
|
+
store.set('chat-2', 'uuid-2', '/b')
|
|
59
|
+
const all = store.listAll()
|
|
60
|
+
expect(all).toHaveLength(2)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
|
|
2
|
+
import { WsBridge } from '../ws-bridge.js'
|
|
3
|
+
import { WebSocketServer, type WebSocket as WsServerSocket } from 'ws'
|
|
4
|
+
|
|
5
|
+
describe('WsBridge', () => {
|
|
6
|
+
let bridge: WsBridge
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
bridge = new WsBridge('ws://127.0.0.1:19999', 'test')
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
bridge.destroy()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('connectSession connects with provided sessionId', () => {
|
|
17
|
+
const result = bridge.connectSession('chat-1', 'my-uuid-session-id')
|
|
18
|
+
expect(result).toBe(true)
|
|
19
|
+
expect(bridge.hasSession('chat-1')).toBe(true)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('connectSession for different chatIds creates separate sessions', () => {
|
|
23
|
+
bridge.connectSession('chat-1', 'uuid-1')
|
|
24
|
+
bridge.connectSession('chat-2', 'uuid-2')
|
|
25
|
+
expect(bridge.hasSession('chat-1')).toBe(true)
|
|
26
|
+
expect(bridge.hasSession('chat-2')).toBe(true)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('resetSession removes the session', () => {
|
|
30
|
+
bridge.connectSession('chat-reset', 'uuid-reset')
|
|
31
|
+
bridge.resetSession('chat-reset')
|
|
32
|
+
expect(bridge.hasSession('chat-reset')).toBe(false)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('sendUserMessage returns false when no open connection', () => {
|
|
36
|
+
bridge.connectSession('chat-offline', 'uuid-offline')
|
|
37
|
+
expect(bridge.sendUserMessage('chat-offline', 'hello')).toBe(false)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('sendPermissionResponse returns false when no open connection', () => {
|
|
41
|
+
bridge.connectSession('chat-perm', 'uuid-perm')
|
|
42
|
+
expect(bridge.sendPermissionResponse('chat-perm', 'req-1', true)).toBe(false)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('sendStopGeneration returns false when no open connection', () => {
|
|
46
|
+
bridge.connectSession('chat-stop', 'uuid-stop')
|
|
47
|
+
expect(bridge.sendStopGeneration('chat-stop')).toBe(false)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('destroy cleans up all sessions', () => {
|
|
51
|
+
bridge.connectSession('a', 'uuid-a')
|
|
52
|
+
bridge.connectSession('b', 'uuid-b')
|
|
53
|
+
bridge.destroy()
|
|
54
|
+
expect(bridge.hasSession('a')).toBe(false)
|
|
55
|
+
expect(bridge.hasSession('b')).toBe(false)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Integration: per-chat handler serialization
|
|
61
|
+
//
|
|
62
|
+
// Reproduces the feishu text→tool→text race: a slow handler on msg 1 must
|
|
63
|
+
// complete BEFORE msg 2's handler starts, otherwise msg 2 reads the stale
|
|
64
|
+
// state msg 1's continuation is about to clear.
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
describe('WsBridge: handler serialization', () => {
|
|
68
|
+
let server: WebSocketServer
|
|
69
|
+
let port: number
|
|
70
|
+
let connections: WsServerSocket[]
|
|
71
|
+
let serverUrl: string
|
|
72
|
+
|
|
73
|
+
beforeEach(async () => {
|
|
74
|
+
connections = []
|
|
75
|
+
// port 0 → let the OS pick a free one
|
|
76
|
+
server = new WebSocketServer({ port: 0 })
|
|
77
|
+
server.on('connection', (ws) => {
|
|
78
|
+
connections.push(ws)
|
|
79
|
+
})
|
|
80
|
+
await new Promise<void>((resolve) => server.on('listening', () => resolve()))
|
|
81
|
+
port = (server.address() as { port: number }).port
|
|
82
|
+
serverUrl = `ws://127.0.0.1:${port}`
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
afterEach(async () => {
|
|
86
|
+
// Forcibly kill any server-side sockets (not graceful close) so
|
|
87
|
+
// WebSocketServer.close() doesn't wait for client FIN.
|
|
88
|
+
for (const ws of connections) {
|
|
89
|
+
try { ws.terminate() } catch {}
|
|
90
|
+
}
|
|
91
|
+
await new Promise<void>((resolve) => {
|
|
92
|
+
const t = setTimeout(() => resolve(), 500) // hard cap
|
|
93
|
+
server.close(() => {
|
|
94
|
+
clearTimeout(t)
|
|
95
|
+
resolve()
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('processes handler calls in strict FIFO order per chatId', async () => {
|
|
101
|
+
const bridge = new WsBridge(serverUrl, 'test')
|
|
102
|
+
const events: string[] = []
|
|
103
|
+
|
|
104
|
+
// The handler simulates an async side effect that takes varying time.
|
|
105
|
+
// If handlers ran concurrently, fast msgs could finish before slow ones,
|
|
106
|
+
// producing an out-of-order `events` array.
|
|
107
|
+
bridge.onServerMessage('chat-1', async (msg: any) => {
|
|
108
|
+
const tag = msg.tag as string
|
|
109
|
+
const delay = msg.delay as number
|
|
110
|
+
events.push(`start:${tag}`)
|
|
111
|
+
await new Promise((r) => setTimeout(r, delay))
|
|
112
|
+
events.push(`end:${tag}`)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
bridge.connectSession('chat-1', 'sess-1')
|
|
116
|
+
const ok = await bridge.waitForOpen('chat-1')
|
|
117
|
+
expect(ok).toBe(true)
|
|
118
|
+
expect(connections.length).toBe(1)
|
|
119
|
+
const serverWs = connections[0]!
|
|
120
|
+
|
|
121
|
+
// Blast three messages back-to-back. msg1 is slow, msg2/msg3 are fast.
|
|
122
|
+
// With serialization: start:1, end:1, start:2, end:2, start:3, end:3
|
|
123
|
+
// Without serialization: start:1, start:2, start:3, end:2, end:3, end:1
|
|
124
|
+
serverWs.send(JSON.stringify({ tag: '1', delay: 40 }))
|
|
125
|
+
serverWs.send(JSON.stringify({ tag: '2', delay: 5 }))
|
|
126
|
+
serverWs.send(JSON.stringify({ tag: '3', delay: 5 }))
|
|
127
|
+
|
|
128
|
+
// Wait long enough for all three handlers to run serially
|
|
129
|
+
await new Promise((r) => setTimeout(r, 200))
|
|
130
|
+
|
|
131
|
+
expect(events).toEqual([
|
|
132
|
+
'start:1', 'end:1',
|
|
133
|
+
'start:2', 'end:2',
|
|
134
|
+
'start:3', 'end:3',
|
|
135
|
+
])
|
|
136
|
+
|
|
137
|
+
bridge.destroy()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('handler error does not break the chain (subsequent messages still run)', async () => {
|
|
141
|
+
const bridge = new WsBridge(serverUrl, 'test')
|
|
142
|
+
const events: string[] = []
|
|
143
|
+
|
|
144
|
+
bridge.onServerMessage('chat-err', async (msg: any) => {
|
|
145
|
+
if (msg.throw) {
|
|
146
|
+
events.push('throwing')
|
|
147
|
+
throw new Error('boom')
|
|
148
|
+
}
|
|
149
|
+
events.push(`ok:${msg.tag}`)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
bridge.connectSession('chat-err', 'sess-err')
|
|
153
|
+
await bridge.waitForOpen('chat-err')
|
|
154
|
+
const serverWs = connections[0]!
|
|
155
|
+
|
|
156
|
+
serverWs.send(JSON.stringify({ throw: true }))
|
|
157
|
+
serverWs.send(JSON.stringify({ tag: 'after' }))
|
|
158
|
+
|
|
159
|
+
await new Promise((r) => setTimeout(r, 80))
|
|
160
|
+
|
|
161
|
+
expect(events).toEqual(['throwing', 'ok:after'])
|
|
162
|
+
|
|
163
|
+
bridge.destroy()
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('resetSession clears the handler chain', async () => {
|
|
167
|
+
const bridge = new WsBridge(serverUrl, 'test')
|
|
168
|
+
bridge.onServerMessage('chat-reset', () => {})
|
|
169
|
+
bridge.connectSession('chat-reset', 'sess-reset')
|
|
170
|
+
await bridge.waitForOpen('chat-reset')
|
|
171
|
+
|
|
172
|
+
bridge.resetSession('chat-reset')
|
|
173
|
+
expect(bridge.hasSession('chat-reset')).toBe(false)
|
|
174
|
+
|
|
175
|
+
bridge.destroy()
|
|
176
|
+
})
|
|
177
|
+
})
|