lonny-agent 0.1.9 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/.kilo/plans/1780293725888-quick-wizard.md +62 -0
  2. package/.lonny/plan-web-cwd-status.md +38 -0
  3. package/README.md +63 -10
  4. package/dist/agent/compaction.d.ts +5 -0
  5. package/dist/agent/compaction.d.ts.map +1 -1
  6. package/dist/agent/compaction.js +5 -0
  7. package/dist/agent/compaction.js.map +1 -1
  8. package/dist/agent/prompt-builder.d.ts.map +1 -1
  9. package/dist/agent/prompt-builder.js +39 -4
  10. package/dist/agent/prompt-builder.js.map +1 -1
  11. package/dist/agent/providers/google.js +1 -1
  12. package/dist/agent/providers/google.js.map +1 -1
  13. package/dist/agent/providers/openai.js +1 -1
  14. package/dist/agent/providers/openai.js.map +1 -1
  15. package/dist/agent/session.d.ts.map +1 -1
  16. package/dist/agent/session.js +18 -2
  17. package/dist/agent/session.js.map +1 -1
  18. package/dist/config/__tests__/context-window.test.d.ts +2 -0
  19. package/dist/config/__tests__/context-window.test.d.ts.map +1 -0
  20. package/dist/config/__tests__/context-window.test.js +80 -0
  21. package/dist/config/__tests__/context-window.test.js.map +1 -0
  22. package/dist/config/index.d.ts +7 -0
  23. package/dist/config/index.d.ts.map +1 -1
  24. package/dist/config/index.js +211 -0
  25. package/dist/config/index.js.map +1 -1
  26. package/dist/pi-tui/tui.d.ts.map +1 -1
  27. package/dist/pi-tui/tui.js +7 -16
  28. package/dist/pi-tui/tui.js.map +1 -1
  29. package/dist/tools/__tests__/edit.test.js +254 -0
  30. package/dist/tools/__tests__/edit.test.js.map +1 -1
  31. package/dist/tools/__tests__/fetch.test.js +11 -9
  32. package/dist/tools/__tests__/fetch.test.js.map +1 -1
  33. package/dist/tools/edit.d.ts.map +1 -1
  34. package/dist/tools/edit.js +205 -41
  35. package/dist/tools/edit.js.map +1 -1
  36. package/dist/tools/install_superpowers.d.ts +3 -0
  37. package/dist/tools/install_superpowers.d.ts.map +1 -0
  38. package/dist/tools/install_superpowers.js +207 -0
  39. package/dist/tools/install_superpowers.js.map +1 -0
  40. package/dist/tui/index.d.ts.map +1 -1
  41. package/dist/tui/index.js +25 -1
  42. package/dist/tui/index.js.map +1 -1
  43. package/dist/web/index.d.ts.map +1 -1
  44. package/dist/web/index.js +65 -0
  45. package/dist/web/index.js.map +1 -1
  46. package/dist/web/public/app.js +9 -0
  47. package/dist/web/public/index.html +6 -0
  48. package/dist/web/public/style.css +11 -0
  49. package/package.json +1 -1
  50. package/src/agent/compaction.ts +6 -0
  51. package/src/agent/prompt-builder.ts +40 -4
  52. package/src/agent/providers/google.ts +1 -1
  53. package/src/agent/providers/openai.ts +1 -1
  54. package/src/agent/session.ts +19 -2
  55. package/src/config/__tests__/context-window.test.ts +94 -0
  56. package/src/config/index.ts +243 -0
  57. package/src/pi-tui/tui.ts +8 -14
  58. package/src/tools/__tests__/edit.test.ts +283 -0
  59. package/src/tools/__tests__/fetch.test.ts +12 -10
  60. package/src/tools/edit.ts +225 -42
  61. package/src/tools/registry.ts +265 -265
  62. package/src/tui/index.ts +23 -1
  63. package/src/web/index.ts +71 -0
  64. package/src/web/public/app.js +9 -0
  65. package/src/web/public/index.html +6 -0
  66. package/src/web/public/style.css +11 -0
  67. package/.lonny/fix-new-command-session-cleanup.md +0 -65
  68. package/.lonny/tui-autocomplete-above.md +0 -62
@@ -0,0 +1,94 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import { getContextWindowForModel } from '../index.js'
3
+
4
+ describe('getContextWindowForModel', () => {
5
+ // 精确匹配测试
6
+ test('exact match with known model names', () => {
7
+ expect(getContextWindowForModel('doubao-seed-2.0-code')).toBe(256_000)
8
+ expect(getContextWindowForModel('deepseek-v4-pro')).toBe(1_000_000)
9
+ expect(getContextWindowForModel('qwen3.6-plus')).toBe(1_000_000)
10
+ expect(getContextWindowForModel('claude-sonnet-4.6')).toBe(1_000_000)
11
+ expect(getContextWindowForModel('gpt-5.5')).toBe(1_000_000)
12
+ })
13
+
14
+ // 大小写不敏感测试
15
+ test('case insensitive matching', () => {
16
+ expect(getContextWindowForModel('DOUBAO-SEED-2.0-CODE')).toBe(256_000)
17
+ expect(getContextWindowForModel('DeepSeek-V4-Pro')).toBe(1_000_000)
18
+ expect(getContextWindowForModel('QWEN3.6-PLUS')).toBe(1_000_000)
19
+ })
20
+
21
+ // 模糊匹配 - 含空格的变体
22
+ test('fuzzy match with space variants', () => {
23
+ expect(getContextWindowForModel('doubao seed 2.0 code')).toBe(256_000)
24
+ expect(getContextWindowForModel('deepseek v4 pro')).toBe(1_000_000)
25
+ expect(getContextWindowForModel('qwen 3.6 plus')).toBe(1_000_000)
26
+ })
27
+
28
+ // 模糊匹配 - 下划线与连字符变体
29
+ test('fuzzy match with underscore and hyphen variants', () => {
30
+ expect(getContextWindowForModel('doubao_seed_2.0_code')).toBe(256_000)
31
+ expect(getContextWindowForModel('deepseek_v4_pro')).toBe(1_000_000)
32
+ })
33
+
34
+ // 模糊匹配 - 含前缀和后缀的模型名
35
+ test('fuzzy match with prefixed and suffixed model names', () => {
36
+ expect(getContextWindowForModel('my-doubao-seed-2.0-code')).toBe(256_000)
37
+ expect(getContextWindowForModel('deepseek-v4-pro-variant')).toBe(1_000_000)
38
+ expect(getContextWindowForModel('qwen3.6-plus-prod')).toBe(1_000_000)
39
+ })
40
+
41
+ // 模糊匹配 - 关键词匹配(不完整的模型名)
42
+ test('fuzzy match with keyword matching (partial model names)', () => {
43
+ // 百万上下文模型
44
+ expect(getContextWindowForModel('deepseek-v4')).toBe(1_000_000)
45
+ expect(getContextWindowForModel('qwen3.6')).toBe(1_000_000)
46
+ expect(getContextWindowForModel('qwen3.7')).toBe(1_000_000)
47
+ expect(getContextWindowForModel('claude opus 4.7')).toBe(1_000_000)
48
+ expect(getContextWindowForModel('gemini 2.5')).toBe(1_000_000)
49
+
50
+ // 256K 上下文模型
51
+ expect(getContextWindowForModel('doubao-seed-2.0')).toBe(256_000)
52
+ expect(getContextWindowForModel('kimi-k2.5')).toBe(262_144) // 实际值
53
+ expect(getContextWindowForModel('kimi-k2.6')).toBe(262_144) // 实际值
54
+
55
+ // 200K 上下文模型
56
+ expect(getContextWindowForModel('minimax')).toBe(200_000)
57
+ expect(getContextWindowForModel('glm-5')).toBe(200_000)
58
+ expect(getContextWindowForModel('glm-5.1')).toBe(200_000)
59
+ expect(getContextWindowForModel('claude-haiku')).toBe(200_000)
60
+
61
+ // 160K 上下文模型
62
+ expect(getContextWindowForModel('deepseek-v3')).toBe(160_000)
63
+ expect(getContextWindowForModel('deepseek-v3.2')).toBe(160_000)
64
+
65
+ // 128K 上下文模型
66
+ expect(getContextWindowForModel('gpt-4o')).toBe(128_000)
67
+ expect(getContextWindowForModel('gpt-4')).toBe(128_000)
68
+ expect(getContextWindowForModel('llama-3')).toBe(128_000)
69
+ })
70
+
71
+ // 未知模型名 - 兜底默认值
72
+ test('unknown model names return default 128K', () => {
73
+ expect(getContextWindowForModel('unknown-model')).toBe(128_000)
74
+ expect(getContextWindowForModel('random-model-name')).toBe(128_000)
75
+ })
76
+
77
+ // 用户配置中常用的模型名
78
+ test('common model names from user configs', () => {
79
+ // 豆包模型
80
+ expect(getContextWindowForModel('Doubao-Seed-2.0-Code')).toBe(256_000)
81
+
82
+ // DeepSeek 模型
83
+ expect(getContextWindowForModel('deepseek-chat')).toBe(128_000)
84
+ expect(getContextWindowForModel('deepseek-reasoner')).toBe(128_000)
85
+
86
+ // Kimi 模型
87
+ expect(getContextWindowForModel('kimi-k2.5')).toBe(262_144) // 实际值
88
+ expect(getContextWindowForModel('kimi-k2.6')).toBe(262_144) // 实际值
89
+
90
+ // 通义千问
91
+ expect(getContextWindowForModel('qwen3.6-plus')).toBe(1_000_000)
92
+ expect(getContextWindowForModel('qwen3.7-max')).toBe(1_000_000)
93
+ })
94
+ })
@@ -8,6 +8,7 @@ export interface Config {
8
8
  mode: 'code' | 'plan' | 'ask'
9
9
  provider: 'openai' | 'anthropic' | 'google' | 'ollama'
10
10
  model: string
11
+ contextWindow: number
11
12
  cwd: string
12
13
  autoApprove: boolean
13
14
  thinking?: boolean
@@ -19,6 +20,243 @@ export interface Config {
19
20
  tavilyApiKey?: string
20
21
  }
21
22
 
23
+ /**
24
+ * 模型上下文窗口大小映射表(基于 2026 年最新数据)
25
+ * 单位:tokens
26
+ */
27
+ const MODEL_CONTEXT_WINDOWS: Record<string, number> = {
28
+ // Doubao 系列
29
+ 'doubao-seed-2.0-code': 256_000,
30
+ 'doubao-seed-2.0-pro': 256_000,
31
+ 'doubao-seed-2.0-lite': 256_000,
32
+ 'doubao-seed-2.0-mini': 256_000,
33
+ 'doubao-seed-2.0-code-preview-260215': 256_000,
34
+ 'doubao-seed-1.8': 256_000,
35
+ 'doubao-seed-1.6': 256_000,
36
+
37
+ // DeepSeek 系列
38
+ 'deepseek-v4-pro': 1_000_000,
39
+ 'deepseek-v4-flash': 256_000,
40
+ 'deepseek-v3.2': 160_000,
41
+ 'deepseek-r1': 128_000,
42
+ 'deepseek-chat': 128_000,
43
+ 'deepseek-reasoner': 128_000,
44
+
45
+ // Kimi 系列
46
+ 'kimi-k2.5': 262_144,
47
+ 'kimi-k2.6': 262_144,
48
+ 'kimi-k2-thinking': 256_000,
49
+
50
+ // MiniMax 系列
51
+ 'minimax-m2.5': 200_000,
52
+ 'minimax-m2.7': 200_000,
53
+ 'minimax-m2': 205_000,
54
+
55
+ // GLM 系列
56
+ 'glm-5': 200_000,
57
+ 'glm-5.1': 200_000,
58
+ 'glm-4.7-flashx': 200_000,
59
+ 'glm-4-flash': 128_000,
60
+ 'glm-4': 128_000,
61
+
62
+ // Qwen 系列
63
+ 'qwen3.6-plus': 1_000_000,
64
+ 'qwen3.7-max': 1_000_000,
65
+ 'qwen3-max': 256_000,
66
+ 'qwen3.5-plus': 1_000_000,
67
+ 'qwen-flash': 1_000_000,
68
+
69
+ // Claude 系列
70
+ 'claude-opus-4.7': 1_000_000,
71
+ 'claude-opus-4.6': 1_000_000,
72
+ 'claude-sonnet-4.6': 1_000_000,
73
+ 'claude-haiku-4.5': 200_000,
74
+
75
+ // GPT 系列
76
+ 'gpt-5.5': 1_000_000,
77
+ 'gpt-5.4': 1_000_000,
78
+ 'gpt-5.1': 1_000_000,
79
+ 'gpt-5.2-codex': 512_000,
80
+ 'gpt-4.1': 1_000_000,
81
+ 'gpt-4.1-mini': 1_000_000,
82
+ 'gpt-4o': 128_000,
83
+ 'gpt-4': 128_000,
84
+
85
+ // Gemini 系列
86
+ 'gemini-2.5-pro': 1_000_000,
87
+ 'gemini-2.5-flash': 1_000_000,
88
+ 'gemini-3.1-pro': 1_000_000,
89
+ 'gemini-3.1-flash': 1_000_000,
90
+
91
+ // Llama 系列
92
+ 'llama-4-maverick': 1_000_000,
93
+ 'llama-4-scout': 10_000_000,
94
+ 'llama-3.1': 128_000,
95
+ 'llama-3': 128_000,
96
+
97
+ // Mistral 系列
98
+ 'mistral-small-3.1': 128_000,
99
+ 'mistral-small-3.2': 128_000,
100
+ }
101
+
102
+ /**
103
+ * 模糊匹配规则
104
+ */
105
+ interface FuzzyMatchRule {
106
+ keywords: string[]
107
+ windowSize: number
108
+ }
109
+
110
+ /**
111
+ * 模糊匹配规则列表(从高优先级到低优先级)
112
+ */
113
+ const fuzzyMatchRules: FuzzyMatchRule[] = [
114
+ // 首先匹配 128K 模型(防止 gpt-4 匹配到 gpt-5 规则)
115
+ {
116
+ keywords: [
117
+ 'gpt-4o',
118
+ 'gpt 4o',
119
+ 'gpt-4',
120
+ 'gpt 4',
121
+ 'llama-3',
122
+ 'llama 3',
123
+ 'mistral-small',
124
+ 'mistral small',
125
+ 'glm-4-flash',
126
+ 'glm 4 flash',
127
+ 'glm-4',
128
+ ],
129
+ windowSize: 128_000,
130
+ },
131
+ // 百万上下文模型 - 更高优先级(更精确的关键词)
132
+ {
133
+ keywords: [
134
+ 'deepseek-v4',
135
+ 'deepseek v4',
136
+ 'qwen3.6',
137
+ 'qwen 3.6',
138
+ 'qwen3.7',
139
+ 'qwen 3.7',
140
+ 'qwen3.5',
141
+ 'qwen 3.5',
142
+ 'qwen-flash',
143
+ 'qwen flash',
144
+ ],
145
+ windowSize: 1_000_000,
146
+ },
147
+ {
148
+ keywords: [
149
+ 'claude-opus',
150
+ 'claude sonnet',
151
+ 'claude-sonnet',
152
+ 'gpt-5.',
153
+ 'gpt 5',
154
+ 'gpt-4.1',
155
+ 'gpt 4.1',
156
+ 'gemini-2.5',
157
+ 'gemini 2.5',
158
+ 'gemini-3.1',
159
+ 'gemini 3.1',
160
+ 'llama-4',
161
+ 'llama 4',
162
+ ],
163
+ windowSize: 1_000_000,
164
+ },
165
+ // 256K 上下文模型
166
+ {
167
+ keywords: [
168
+ 'doubao-seed-2.0',
169
+ 'doubao seed 2.0',
170
+ 'doubao-seed-1',
171
+ 'doubao seed 1',
172
+ 'kimi-k2',
173
+ 'kimi k2',
174
+ 'kimi-k2.5',
175
+ 'kimi k2.5',
176
+ 'kimi-k2.6',
177
+ 'kimi k2.6',
178
+ 'qwen3',
179
+ 'qwen 3',
180
+ 'qwen3-max',
181
+ 'qwen3 max',
182
+ ],
183
+ windowSize: 256_000,
184
+ },
185
+ // 200K 上下文模型
186
+ {
187
+ keywords: [
188
+ 'minimax',
189
+ 'glm-5',
190
+ 'glm 5',
191
+ 'glm-5.1',
192
+ 'glm 5.1',
193
+ 'glm-4.7',
194
+ 'glm 4.7',
195
+ 'claude-haiku',
196
+ 'claude haiku',
197
+ ],
198
+ windowSize: 200_000,
199
+ },
200
+ // 160K 上下文模型
201
+ {
202
+ keywords: ['deepseek-v3', 'deepseek v3', 'deepseek-v3.2', 'deepseek v3.2'],
203
+ windowSize: 160_000,
204
+ },
205
+ // 最后匹配通用关键词(deepseek)
206
+ {
207
+ keywords: ['deepseek'],
208
+ windowSize: 128_000,
209
+ },
210
+ ]
211
+
212
+ /**
213
+ * 根据模型名称获取上下文窗口大小(改进的模糊匹配)
214
+ * @param modelName 模型名称(不区分大小写)
215
+ * @returns 上下文窗口大小(tokens)
216
+ */
217
+ export function getContextWindowForModel(modelName: string): number {
218
+ // 处理空字符串
219
+ if (!modelName) {
220
+ return 128_000
221
+ }
222
+
223
+ const normalizedModelName = modelName.toLowerCase().replace(/[-_]/g, ' ')
224
+
225
+ // 1. 精确匹配(原始键名)
226
+ if (MODEL_CONTEXT_WINDOWS[modelName]) {
227
+ return MODEL_CONTEXT_WINDOWS[modelName]
228
+ }
229
+
230
+ // 2. 精确匹配(小写键名)
231
+ if (MODEL_CONTEXT_WINDOWS[modelName.toLowerCase()]) {
232
+ return MODEL_CONTEXT_WINDOWS[modelName.toLowerCase()]
233
+ }
234
+
235
+ // 3. 前缀匹配(支持部分匹配)
236
+ for (const [pattern, size] of Object.entries(MODEL_CONTEXT_WINDOWS)) {
237
+ const normalizedPattern = pattern.toLowerCase().replace(/[-_]/g, ' ')
238
+ if (
239
+ normalizedModelName.includes(normalizedPattern) ||
240
+ normalizedPattern.includes(normalizedModelName)
241
+ ) {
242
+ return size
243
+ }
244
+ }
245
+
246
+ // 4. 模糊匹配(关键词匹配)- 按照规则顺序(优先级)处理
247
+ for (const rule of fuzzyMatchRules) {
248
+ for (const keyword of rule.keywords) {
249
+ const normalizedKeyword = keyword.toLowerCase().replace(/[-_]/g, ' ')
250
+ if (normalizedModelName.includes(normalizedKeyword)) {
251
+ return rule.windowSize
252
+ }
253
+ }
254
+ }
255
+
256
+ // 5. 兜底默认值
257
+ return 128_000
258
+ }
259
+
22
260
  interface JsonConfig {
23
261
  apiKey?: string
24
262
  baseUrl?: string
@@ -29,6 +267,7 @@ interface JsonConfig {
29
267
  autoApprove?: boolean
30
268
  enableCache?: boolean
31
269
  strictTools?: boolean
270
+ contextWindow?: number
32
271
  temperature?: number
33
272
  maxTokens?: number
34
273
  tavilyApiKey?: string
@@ -69,6 +308,9 @@ export function loadConfig(options?: {
69
308
  // Auto-enable cache for DeepSeek models unless explicitly disabled
70
309
  const enableCache = jsonConfig.enableCache ?? (isDeepSeekModel(model, baseUrl) || undefined)
71
310
 
311
+ // 根据模型名称获取上下文窗口大小,如果配置中有则优先使用配置值
312
+ const contextWindow = jsonConfig.contextWindow || getContextWindowForModel(model)
313
+
72
314
  return {
73
315
  apiKey: jsonConfig.apiKey || '',
74
316
  baseUrl,
@@ -79,6 +321,7 @@ export function loadConfig(options?: {
79
321
  | 'ollama',
80
322
  mode: options?.mode || 'code',
81
323
  model,
324
+ contextWindow,
82
325
  cwd: options?.cwd || process.cwd(),
83
326
  autoApprove: options?.autoApprove ?? jsonConfig.autoApprove ?? false,
84
327
  thinking: jsonConfig.thinking,
package/src/pi-tui/tui.ts CHANGED
@@ -1351,26 +1351,20 @@ export class TUI extends Container {
1351
1351
  const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1))
1352
1352
  const targetCol = Math.max(0, cursorPos.col)
1353
1353
 
1354
- // Move cursor from current position to target
1355
- const rowDelta = targetRow - this.hardwareCursorRow
1356
- let buffer = ''
1357
- if (rowDelta > 0) {
1358
- buffer += `\x1b[${rowDelta}B` // Move down
1359
- } else if (rowDelta < 0) {
1360
- buffer += `\x1b[${-rowDelta}A` // Move up
1354
+ // Use absolute cursor positioning (CUP) to avoid scroll artifacts.
1355
+ // Relative movement (\x1b[NB/\x1b[NA) with a stale baseline can
1356
+ // cause the terminal to scroll, inserting blank lines, especially
1357
+ // when the editor overlay cursor is far from the rendered content.
1358
+ let buffer = `\x1b[${targetRow + 1};${targetCol + 1}H`
1359
+
1360
+ if (this.showHardwareCursor) {
1361
+ buffer += '\x1b[?25h'
1361
1362
  }
1362
- // Move to absolute column (1-indexed)
1363
- buffer += `\x1b[${targetCol + 1}G`
1364
1363
 
1365
1364
  if (buffer) {
1366
1365
  this.terminal.write(buffer)
1367
1366
  }
1368
1367
 
1369
1368
  this.hardwareCursorRow = targetRow
1370
- if (this.showHardwareCursor) {
1371
- this.terminal.showCursor()
1372
- } else {
1373
- this.terminal.hideCursor()
1374
- }
1375
1369
  }
1376
1370
  }
@@ -86,6 +86,134 @@ describe('edit tool', () => {
86
86
  })
87
87
  })
88
88
 
89
+ describe('validation', () => {
90
+ beforeAll(() => {
91
+ fs.writeFileSync(path.join(tmpDir, 'a.txt'), 'test content\n')
92
+ })
93
+
94
+ it('rejects edit missing old_string', async () => {
95
+ const r = await tool().execute({
96
+ edits: [{ file_path: 'a.txt', new_string: 'replacement' }],
97
+ })
98
+ expect(r.success).toBe(false)
99
+ expect(r.error).toContain('missing')
100
+ expect(r.error).toContain('old_string')
101
+ })
102
+
103
+ it('rejects edit missing new_string', async () => {
104
+ const r = await tool().execute({
105
+ edits: [{ file_path: 'a.txt', old_string: 'original' }],
106
+ })
107
+ expect(r.success).toBe(false)
108
+ expect(r.error).toContain('missing')
109
+ expect(r.error).toContain('new_string')
110
+ })
111
+
112
+ it('rejects edit missing file_path', async () => {
113
+ const r = await tool().execute({
114
+ edits: [{ old_string: 'a', new_string: 'b' }],
115
+ })
116
+ expect(r.success).toBe(false)
117
+ expect(r.error).toContain('missing')
118
+ expect(r.error).toContain('file_path')
119
+ })
120
+
121
+ it('reports all missing fields in one error', async () => {
122
+ const r = await tool().execute({
123
+ edits: [
124
+ { file_path: 'a.txt', old_string: 'a', new_string: 'b' },
125
+ { file_path: 'a.txt', new_string: 'c' },
126
+ { file_path: 'a.txt', old_string: 'd' },
127
+ ],
128
+ })
129
+ expect(r.success).toBe(false)
130
+ // Should report BOTH errors, not just the first
131
+ expect(r.error).toContain('2 of 3')
132
+ expect(r.error).toContain('edit #2')
133
+ expect(r.error).toContain('edit #3')
134
+ // Should mention not to split across edits
135
+ expect(r.error).toContain('COMPLETE')
136
+ })
137
+
138
+ it('reports which fields each malformed edit has', async () => {
139
+ const r = await tool().execute({
140
+ edits: [{ file_path: 'a.txt', new_string: 'c' }],
141
+ })
142
+ expect(r.success).toBe(false)
143
+ expect(r.error).toContain('has: file_path, new_string')
144
+ })
145
+
146
+ it('includes rawInput in the error message', async () => {
147
+ const r = await tool().execute({
148
+ edits: [{ file_path: 'a.txt', new_string: 'c' }],
149
+ })
150
+ expect(r.success).toBe(false)
151
+ expect(r.error).toContain('a.txt')
152
+ expect(r.error).toContain('new_string')
153
+ })
154
+ })
155
+
156
+ describe('auto-correction', () => {
157
+ it('wraps top-level file_path/old_string/new_string into edits array', async () => {
158
+ const r = await tool().execute({
159
+ file_path: 'nonexistent.ts',
160
+ old_string: 'x',
161
+ new_string: 'y',
162
+ })
163
+ // Should be auto-corrected so the error is about file not found, not about missing edits
164
+ expect(r.success).toBe(false)
165
+ expect(r.error).toContain('not found')
166
+ })
167
+
168
+ it('wraps flat file_path + old_string (missing new_string) with default empty new_string', async () => {
169
+ const r = await tool().execute({
170
+ file_path: path.join(tmpDir, 'new.txt'),
171
+ old_string: '',
172
+ })
173
+ // Auto-corrected to { edits: [{ file_path, old_string: '', new_string: '' }] }
174
+ // This creates a file with empty content
175
+ expect(r.success).toBe(true)
176
+ expect(fs.existsSync(path.join(tmpDir, 'new.txt'))).toBe(true)
177
+ expect(fs.readFileSync(path.join(tmpDir, 'new.txt'), 'utf8')).toBe('')
178
+ fs.unlinkSync(path.join(tmpDir, 'new.txt'))
179
+ })
180
+
181
+ it('handles edits passed directly as an array (not wrapped)', async () => {
182
+ // @ts-expect-error — runtime accepts array, TS expects object
183
+ const r = await tool().execute([
184
+ { file_path: 'a.txt', old_string: 'test', new_string: 'TEST' },
185
+ ])
186
+ expect(r.success).toBe(true)
187
+ })
188
+
189
+ it('handles file_path + new_string (no old_string) at top level as create', async () => {
190
+ const r = await tool().execute({
191
+ file_path: path.join(tmpDir, 'auto-create.txt'),
192
+ new_string: 'auto created',
193
+ })
194
+ expect(r.success).toBe(true)
195
+ expect(fs.readFileSync(path.join(tmpDir, 'auto-create.txt'), 'utf8')).toBe('auto created')
196
+ fs.unlinkSync(path.join(tmpDir, 'auto-create.txt'))
197
+ })
198
+
199
+ it('handles only file_path at top level as empty file creation', async () => {
200
+ const r = await tool().execute({
201
+ file_path: path.join(tmpDir, 'empty-file.txt'),
202
+ })
203
+ expect(r.success).toBe(true)
204
+ expect(fs.existsSync(path.join(tmpDir, 'empty-file.txt'))).toBe(true)
205
+ expect(fs.readFileSync(path.join(tmpDir, 'empty-file.txt'), 'utf8')).toBe('')
206
+ fs.unlinkSync(path.join(tmpDir, 'empty-file.txt'))
207
+ })
208
+
209
+ it('rejects nonsensical top-level keys with helpful error', async () => {
210
+ const r = await tool().execute({ foo: 'bar' })
211
+ expect(r.success).toBe(false)
212
+ expect(r.error).toContain('edits')
213
+ expect(r.error).toContain('array')
214
+ })
215
+ })
216
+
89
217
  describe('batch mode', () => {
90
218
  beforeAll(() => {
91
219
  fs.writeFileSync(path.join(tmpDir, 'a.txt'), 'one\ntwo\nthree\nfour\n')
@@ -305,4 +433,159 @@ describe('edit tool', () => {
305
433
  expect(fs.existsSync(path.join(createdDir(), 'rollback-1.ts'))).toBe(false)
306
434
  })
307
435
  })
436
+
437
+ describe('whitespace tolerance', () => {
438
+ const wsDir = () => path.join(tmpDir, 'ws-test')
439
+ const f = (name: string) => path.join(wsDir(), name)
440
+
441
+ beforeAll(() => {
442
+ fs.mkdirSync(wsDir(), { recursive: true })
443
+ })
444
+
445
+ const tool = () => createEditTool(applier, wsDir())
446
+
447
+ it('handles trailing space in file (AI omits trailing space)', async () => {
448
+ fs.writeFileSync(f('trailing.txt'), 'hello \nworld\n')
449
+ const r = await tool().execute({
450
+ edits: [
451
+ { file_path: 'trailing.txt', old_string: 'hello\nworld', new_string: 'HELLO\nWORLD' },
452
+ ],
453
+ })
454
+ expect(r.success).toBe(true)
455
+ expect(fs.readFileSync(f('trailing.txt'), 'utf8')).toBe('HELLO\nWORLD\n')
456
+ // Should indicate whitespace-normalized in output
457
+ expect(r.output).toContain('whitespace-normalized')
458
+ })
459
+
460
+ it('handles trailing space in old_string (AI adds extra trailing space)', async () => {
461
+ fs.writeFileSync(f('trailing.txt'), 'hello \nworld\n')
462
+ const r = await tool().execute({
463
+ edits: [
464
+ { file_path: 'trailing.txt', old_string: 'hello \nworld', new_string: 'HELLO\nWORLD' },
465
+ ],
466
+ })
467
+ expect(r.success).toBe(true)
468
+ expect(fs.readFileSync(f('trailing.txt'), 'utf8')).toBe('HELLO\nWORLD\n')
469
+ })
470
+
471
+ it('handles leading space in file (AI omits leading indent)', async () => {
472
+ fs.writeFileSync(f('leading.txt'), ' hello\nworld\n')
473
+ const r = await tool().execute({
474
+ edits: [
475
+ { file_path: 'leading.txt', old_string: 'hello\nworld', new_string: 'HELLO\nWORLD' },
476
+ ],
477
+ })
478
+ expect(r.success).toBe(true)
479
+ // Leading spaces in the original file become part of the matched block
480
+ // and are preserved in the result
481
+ expect(fs.readFileSync(f('leading.txt'), 'utf8')).toBe(' HELLO\nWORLD\n')
482
+ })
483
+
484
+ it('handles leading space in old_string (AI adds extra indent)', async () => {
485
+ fs.writeFileSync(f('leading.txt'), ' hello\nworld\n')
486
+ const r = await tool().execute({
487
+ edits: [
488
+ { file_path: 'leading.txt', old_string: ' hello\nworld', new_string: 'HELLO\nWORLD' },
489
+ ],
490
+ })
491
+ expect(r.success).toBe(true)
492
+ // The file's leading spaces are part of the matched block, so they
493
+ // get replaced along with the rest of the matched text
494
+ expect(fs.readFileSync(f('leading.txt'), 'utf8')).toBe('HELLO\nWORLD\n')
495
+ })
496
+
497
+ it('handles extra internal spaces in file (AI sends single space)', async () => {
498
+ fs.writeFileSync(f('internal.txt'), 'foo bar\nbaz\n')
499
+ const r = await tool().execute({
500
+ edits: [
501
+ { file_path: 'internal.txt', old_string: 'foo bar\nbaz', new_string: 'FOO BAR\nBAZ' },
502
+ ],
503
+ })
504
+ expect(r.success).toBe(true)
505
+ expect(fs.readFileSync(f('internal.txt'), 'utf8')).toBe('FOO BAR\nBAZ\n')
506
+ })
507
+
508
+ it('handles extra internal spaces in old_string (AI adds double space)', async () => {
509
+ fs.writeFileSync(f('internal.txt'), 'foo bar\nbaz\n')
510
+ const r = await tool().execute({
511
+ edits: [
512
+ { file_path: 'internal.txt', old_string: 'foo bar\nbaz', new_string: 'FOO BAR\nBAZ' },
513
+ ],
514
+ })
515
+ expect(r.success).toBe(true)
516
+ expect(fs.readFileSync(f('internal.txt'), 'utf8')).toBe('FOO BAR\nBAZ\n')
517
+ })
518
+
519
+ it('handles blank line with spaces matching empty blank line', async () => {
520
+ fs.writeFileSync(f('blank.txt'), 'foo\n \nbar\n')
521
+ const r = await tool().execute({
522
+ edits: [{ file_path: 'blank.txt', old_string: 'foo\n\nbar', new_string: 'FOO\n\nBAR' }],
523
+ })
524
+ expect(r.success).toBe(true)
525
+ // The blank line with spaces is part of the matched block and gets
526
+ // replaced entirely by the new_string's blank line
527
+ expect(fs.readFileSync(f('blank.txt'), 'utf8')).toBe('FOO\n\nBAR\n')
528
+ })
529
+
530
+ it('handles mixed whitespace across multiple lines', async () => {
531
+ fs.writeFileSync(f('mixed.txt'), ' a \nb \nc\n')
532
+ const r = await tool().execute({
533
+ edits: [{ file_path: 'mixed.txt', old_string: 'a\nb\nc', new_string: 'A\nB\nC' }],
534
+ })
535
+ expect(r.success).toBe(true)
536
+ expect(fs.readFileSync(f('mixed.txt'), 'utf8')).toBe('A\nB\nC\n')
537
+ })
538
+
539
+ it('still fails when text content differs (not just whitespace)', async () => {
540
+ fs.writeFileSync(f('trailing.txt'), 'hello \nworld\n')
541
+ const r = await tool().execute({
542
+ edits: [{ file_path: 'trailing.txt', old_string: 'hello\nmundo', new_string: 'x\ny' }],
543
+ })
544
+ expect(r.success).toBe(false)
545
+ expect(r.error).toContain('not found')
546
+ })
547
+
548
+ it('still fails for completely wrong old_string', async () => {
549
+ fs.writeFileSync(f('trailing.txt'), 'hello \nworld\n')
550
+ const r = await tool().execute({
551
+ edits: [{ file_path: 'trailing.txt', old_string: 'zzz\nzzz', new_string: 'x\ny' }],
552
+ })
553
+ expect(r.success).toBe(false)
554
+ expect(r.error).toContain('not found')
555
+ })
556
+
557
+ it('reports duplicate in whitespace-normalized mode', async () => {
558
+ fs.writeFileSync(f('duplicate.txt'), 'a \nb\na \nb\n')
559
+ const r = await tool().execute({
560
+ edits: [{ file_path: 'duplicate.txt', old_string: 'a\nb', new_string: 'X\nY' }],
561
+ })
562
+ expect(r.success).toBe(false)
563
+ expect(r.error).toContain('MULTIPLE times')
564
+ expect(r.error).toContain('whitespace-normalized')
565
+ })
566
+
567
+ it('prefers exact match when available', async () => {
568
+ fs.writeFileSync(f('exact-prefer.txt'), 'hello\nworld\n')
569
+ const r = await tool().execute({
570
+ edits: [
571
+ { file_path: 'exact-prefer.txt', old_string: 'hello\nworld', new_string: 'HELLO\nWORLD' },
572
+ ],
573
+ })
574
+ expect(r.success).toBe(true)
575
+ expect(fs.readFileSync(f('exact-prefer.txt'), 'utf8')).toBe('HELLO\nWORLD\n')
576
+ // Exact match should NOT have the whitespace-normalized note
577
+ expect(r.output).not.toContain('whitespace-normalized')
578
+ })
579
+
580
+ it('provides context snippet when old_string not found', async () => {
581
+ fs.writeFileSync(f('trailing.txt'), 'hello \nworld\n')
582
+ const r = await tool().execute({
583
+ edits: [{ file_path: 'trailing.txt', old_string: 'nonexistent_line_xyz', new_string: 'x' }],
584
+ })
585
+ expect(r.success).toBe(false)
586
+ expect(r.error).toContain('not found')
587
+ // Should contain a snippet with the file's actual content
588
+ expect(r.error).toContain('hello')
589
+ })
590
+ })
308
591
  })