bingocode 1.0.41 → 1.1.42
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/bin/bingo-win.cjs +2 -1
- package/bin/bingocode-win.cjs +2 -1
- package/bin/claude-win.cjs +2 -1
- package/bun.lock +1716 -0
- package/package.json +14 -2
- package/src/server/config/providers.yaml +1 -1
- package/src/server/proxy/transform/anthropicToOpenaiChat.ts +11 -4
- package/adapters/README.md +0 -87
- package/adapters/common/__tests__/chat-queue.test.ts +0 -61
- package/adapters/common/__tests__/format.test.ts +0 -148
- package/adapters/common/__tests__/http-client.test.ts +0 -105
- package/adapters/common/__tests__/message-buffer.test.ts +0 -84
- package/adapters/common/__tests__/message-dedup.test.ts +0 -57
- package/adapters/common/__tests__/session-store.test.ts +0 -62
- package/adapters/common/__tests__/ws-bridge.test.ts +0 -177
- package/adapters/common/attachment/__tests__/attachment-limits.test.ts +0 -52
- package/adapters/common/attachment/__tests__/attachment-store.test.ts +0 -108
- package/adapters/common/attachment/__tests__/image-block-watcher.test.ts +0 -115
- package/adapters/common/attachment/attachment-limits.ts +0 -58
- package/adapters/common/attachment/attachment-store.ts +0 -121
- package/adapters/common/attachment/attachment-types.ts +0 -29
- package/adapters/common/attachment/image-block-watcher.ts +0 -94
- package/adapters/common/chat-queue.ts +0 -24
- package/adapters/common/config.ts +0 -96
- package/adapters/common/format.ts +0 -229
- package/adapters/common/http-client.ts +0 -107
- package/adapters/common/message-buffer.ts +0 -91
- package/adapters/common/message-dedup.ts +0 -57
- package/adapters/common/pairing.ts +0 -149
- package/adapters/common/session-store.ts +0 -60
- package/adapters/common/ws-bridge.ts +0 -282
- package/adapters/feishu/__tests__/card-errors.test.ts +0 -194
- package/adapters/feishu/__tests__/cardkit.test.ts +0 -295
- package/adapters/feishu/__tests__/extract-payload.test.ts +0 -77
- package/adapters/feishu/__tests__/feishu.test.ts +0 -907
- package/adapters/feishu/__tests__/flush-controller.test.ts +0 -290
- package/adapters/feishu/__tests__/markdown-style.test.ts +0 -353
- package/adapters/feishu/__tests__/media.test.ts +0 -120
- package/adapters/feishu/__tests__/streaming-card.test.ts +0 -914
- package/adapters/feishu/card-errors.ts +0 -151
- package/adapters/feishu/cardkit.ts +0 -294
- package/adapters/feishu/extract-payload.ts +0 -95
- package/adapters/feishu/flush-controller.ts +0 -149
- package/adapters/feishu/index.ts +0 -1275
- package/adapters/feishu/markdown-style.ts +0 -212
- package/adapters/feishu/media.ts +0 -176
- package/adapters/feishu/streaming-card.ts +0 -612
- package/adapters/package.json +0 -23
- package/adapters/telegram/__tests__/media.test.ts +0 -86
- package/adapters/telegram/__tests__/telegram.test.ts +0 -115
- package/adapters/telegram/index.ts +0 -754
- package/adapters/telegram/media.ts +0 -89
- package/adapters/tsconfig.json +0 -18
- package/runtime/mac_helper.py +0 -775
- package/runtime/requirements-win.txt +0 -7
- package/runtime/requirements.txt +0 -6
- package/runtime/test_helpers.py +0 -322
- package/runtime/win_helper.py +0 -723
- package/scripts/count-app-loc.ts +0 -256
- package/scripts/release.ts +0 -130
- package/start-cli.bat +0 -7
- package/stubs/ant-claude-for-chrome-mcp.ts +0 -24
- package/stubs/color-diff-napi.ts +0 -45
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bingocode",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.42",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"claude": "bin/claude-win.cjs",
|
|
@@ -14,8 +14,20 @@
|
|
|
14
14
|
"bingocode": "bun run ./bin/bingocode-win.cjs",
|
|
15
15
|
"docs:dev": "vitepress dev docs",
|
|
16
16
|
"docs:build": "vitepress build docs",
|
|
17
|
-
"docs:preview": "vitepress preview docs"
|
|
17
|
+
"docs:preview": "vitepress preview docs",
|
|
18
|
+
"local-install": "npm install -g ."
|
|
18
19
|
},
|
|
20
|
+
"files": [
|
|
21
|
+
"bin/",
|
|
22
|
+
"src/",
|
|
23
|
+
"config/",
|
|
24
|
+
"preload.ts",
|
|
25
|
+
"tsconfig.json",
|
|
26
|
+
"package.json",
|
|
27
|
+
"README.md",
|
|
28
|
+
"bun.lock",
|
|
29
|
+
"bunfig.toml"
|
|
30
|
+
],
|
|
19
31
|
"dependencies": {
|
|
20
32
|
"@alcalzone/ansi-tokenize": "^0.3.0",
|
|
21
33
|
"@anthropic-ai/sandbox-runtime": "^0.0.44",
|
|
@@ -43,9 +43,16 @@ export function anthropicToOpenaiChat(body: AnthropicRequest): OpenAIChatRequest
|
|
|
43
43
|
stream: body.stream,
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
// max_tokens —
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
// max_tokens — limit for DeepSeek to avoid invalid parameter errors
|
|
47
|
+
if (body.max_tokens !== undefined) {
|
|
48
|
+
if (body.model.toLowerCase().includes('deepseek')) {
|
|
49
|
+
// DeepSeek R1 often fails if max_tokens is set to Claude's high defaults (like 128k)
|
|
50
|
+
// Especially when tools or thinking are involved. 8192 is a safe upper limit for most.
|
|
51
|
+
result.max_tokens = Math.min(body.max_tokens, 8192)
|
|
52
|
+
} else {
|
|
53
|
+
result.max_tokens = body.max_tokens
|
|
54
|
+
}
|
|
55
|
+
}
|
|
49
56
|
|
|
50
57
|
// temperature & top_p
|
|
51
58
|
if (body.temperature !== undefined) result.temperature = body.temperature
|
|
@@ -76,7 +83,7 @@ export function anthropicToOpenaiChat(body: AnthropicRequest): OpenAIChatRequest
|
|
|
76
83
|
}
|
|
77
84
|
|
|
78
85
|
// thinking → reasoning_effort
|
|
79
|
-
if (body.thinking) {
|
|
86
|
+
if (body.thinking && !body.model.toLowerCase().includes('deepseek')) {
|
|
80
87
|
const budget = body.thinking.budget_tokens
|
|
81
88
|
if (budget !== undefined) {
|
|
82
89
|
if (budget <= 1024) result.reasoning_effort = 'low'
|
package/adapters/README.md
DELETED
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
# Claude Code IM Adapters
|
|
2
|
-
|
|
3
|
-
当前目录只放 IM Adapter 运行时代码。
|
|
4
|
-
|
|
5
|
-
用户文档已经迁移到 `docs/`,并且以 Desktop Webapp 配置流程为准:
|
|
6
|
-
|
|
7
|
-
- `docs/im/index.md`
|
|
8
|
-
- `docs/im/telegram.md`
|
|
9
|
-
- `docs/im/feishu.md`
|
|
10
|
-
|
|
11
|
-
## 当前方案摘要
|
|
12
|
-
|
|
13
|
-
当前真实链路是:
|
|
14
|
-
|
|
15
|
-
```text
|
|
16
|
-
Desktop Webapp Settings
|
|
17
|
-
-> /api/adapters
|
|
18
|
-
-> ~/.claude/adapters.json
|
|
19
|
-
-> adapters/<platform>/index.ts
|
|
20
|
-
-> /api/sessions + /ws/:sessionId
|
|
21
|
-
-> Claude Code session
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
注意两点:
|
|
25
|
-
|
|
26
|
-
- IM 配置和配对都在 Desktop Webapp 的 `Settings -> IM 接入`
|
|
27
|
-
- Webapp 不会自动启动 Adapter 进程,仍需手动运行 `bun run telegram` 或 `bun run feishu`
|
|
28
|
-
|
|
29
|
-
## 快速启动
|
|
30
|
-
|
|
31
|
-
```bash
|
|
32
|
-
cd adapters
|
|
33
|
-
bun install
|
|
34
|
-
bun run telegram
|
|
35
|
-
# 或
|
|
36
|
-
bun run feishu
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
## 开发
|
|
40
|
-
|
|
41
|
-
### 运行测试
|
|
42
|
-
|
|
43
|
-
```bash
|
|
44
|
-
cd adapters
|
|
45
|
-
bun test
|
|
46
|
-
bun test common/
|
|
47
|
-
bun test telegram/
|
|
48
|
-
bun test feishu/
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
### 目录结构
|
|
52
|
-
|
|
53
|
-
```text
|
|
54
|
-
adapters/
|
|
55
|
-
├── common/
|
|
56
|
-
│ └── attachment/ # 跨平台附件工具(types / limits / store / image-watcher)
|
|
57
|
-
├── telegram/
|
|
58
|
-
│ └── media.ts # TelegramMediaService(grammy Bot API 封装)
|
|
59
|
-
├── feishu/
|
|
60
|
-
│ ├── media.ts # FeishuMediaService(@larksuiteoapi/node-sdk 封装)
|
|
61
|
-
│ └── extract-payload.ts # 入站 im.message.receive_v1 事件解析
|
|
62
|
-
├── package.json
|
|
63
|
-
├── tsconfig.json
|
|
64
|
-
└── README.md
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
## 附件收发
|
|
68
|
-
|
|
69
|
-
两个 Adapter 都支持双向图片/文件,和 Desktop 端走同一套 `AttachmentRef` 协议透传给主进程。
|
|
70
|
-
|
|
71
|
-
**入站(用户 → Claude):**
|
|
72
|
-
|
|
73
|
-
- 飞书: 图片(jpg/png/gif/webp/heic)、文档(doc/xls/ppt/pdf 等)、post 富文本里的 img/file 元素
|
|
74
|
-
- Telegram: photo、document、video、audio、voice
|
|
75
|
-
|
|
76
|
-
下载落地到 `~/.claude/im-downloads/{platform}/{sessionId}/`,24 小时后自动 GC(`.part` 孤文件 10 分钟超时)。大小限制:单张图 ≤10 MB、单个文件 ≤30 MB,超限直接拒收并在 IM 里提示。
|
|
77
|
-
|
|
78
|
-
**出站(Claude → 用户):**
|
|
79
|
-
|
|
80
|
-
Agent 流式文本里的 markdown 图片引用 `` 会被 `ImageBlockWatcher` 识别、上传到 IM 平台,作为独立图片消息发出:
|
|
81
|
-
|
|
82
|
-
- 飞书: `im.message.create(msg_type='image')` 单发(card 内嵌是后续优化)
|
|
83
|
-
- Telegram: `bot.api.sendPhoto(InputFile)` 单发
|
|
84
|
-
|
|
85
|
-
非图片类出站(Agent 产的 pdf/zip 等)暂不支持。
|
|
86
|
-
|
|
87
|
-
设计细节: `docs/superpowers/specs/2026-04-11-im-attachment-support-design.md`。
|
|
@@ -1,61 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,148 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,105 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,84 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,57 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,62 +0,0 @@
|
|
|
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
|
-
})
|