lonny-agent 0.2.3 → 0.2.4

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 (64) hide show
  1. package/.claude/settings.local.json +14 -0
  2. package/dist/agent/index.js +1 -1
  3. package/dist/agent/index.js.map +1 -1
  4. package/dist/agent/project.d.ts +26 -0
  5. package/dist/agent/project.d.ts.map +1 -0
  6. package/dist/agent/project.js +303 -0
  7. package/dist/agent/project.js.map +1 -0
  8. package/dist/agent/prompt-builder.d.ts +1 -1
  9. package/dist/agent/prompt-builder.d.ts.map +1 -1
  10. package/dist/agent/prompt-builder.js +10 -5
  11. package/dist/agent/prompt-builder.js.map +1 -1
  12. package/dist/agent/providers/anthropic.d.ts.map +1 -1
  13. package/dist/agent/providers/anthropic.js +20 -2
  14. package/dist/agent/providers/anthropic.js.map +1 -1
  15. package/dist/agent/providers/google.js +1 -1
  16. package/dist/agent/providers/google.js.map +1 -1
  17. package/dist/agent/providers/ollama.d.ts.map +1 -1
  18. package/dist/agent/providers/ollama.js +2 -1
  19. package/dist/agent/providers/ollama.js.map +1 -1
  20. package/dist/agent/providers/openai.d.ts.map +1 -1
  21. package/dist/agent/providers/openai.js +23 -3
  22. package/dist/agent/providers/openai.js.map +1 -1
  23. package/dist/agent/session.d.ts +4 -2
  24. package/dist/agent/session.d.ts.map +1 -1
  25. package/dist/agent/session.js +206 -147
  26. package/dist/agent/session.js.map +1 -1
  27. package/dist/tools/__tests__/edit.test.js +14 -14
  28. package/dist/tools/__tests__/edit.test.js.map +1 -1
  29. package/dist/tools/edit.d.ts +17 -0
  30. package/dist/tools/edit.d.ts.map +1 -1
  31. package/dist/tools/edit.js +81 -34
  32. package/dist/tools/edit.js.map +1 -1
  33. package/dist/tools/read.d.ts +6 -0
  34. package/dist/tools/read.d.ts.map +1 -1
  35. package/dist/tools/read.js +42 -15
  36. package/dist/tools/read.js.map +1 -1
  37. package/dist/tools/registry.d.ts.map +1 -1
  38. package/dist/tools/registry.js +0 -4
  39. package/dist/tools/registry.js.map +1 -1
  40. package/dist/tools/write_plan.d.ts +1 -1
  41. package/dist/tools/write_plan.d.ts.map +1 -1
  42. package/dist/tools/write_plan.js +14 -4
  43. package/dist/tools/write_plan.js.map +1 -1
  44. package/dist/tui/index.d.ts.map +1 -1
  45. package/dist/tui/index.js +5 -4
  46. package/dist/tui/index.js.map +1 -1
  47. package/dist/web/index.js +5 -5
  48. package/dist/web/index.js.map +1 -1
  49. package/package.json +1 -1
  50. package/src/agent/index.ts +1 -1
  51. package/src/agent/project.ts +366 -0
  52. package/src/agent/prompt-builder.ts +11 -5
  53. package/src/agent/providers/anthropic.ts +9 -2
  54. package/src/agent/providers/google.ts +1 -1
  55. package/src/agent/providers/ollama.ts +5 -1
  56. package/src/agent/providers/openai.ts +15 -3
  57. package/src/agent/session.ts +216 -150
  58. package/src/tools/__tests__/edit.test.ts +14 -14
  59. package/src/tools/edit.ts +103 -36
  60. package/src/tools/read.ts +61 -25
  61. package/src/tools/registry.ts +260 -265
  62. package/src/tools/write_plan.ts +21 -5
  63. package/src/tui/index.ts +5 -4
  64. package/src/web/index.ts +5 -5
@@ -5,6 +5,7 @@ import * as path from 'node:path'
5
5
  import type { Config } from '../config/index.js'
6
6
  import { saveTokenUsage } from '../config/tokens.js'
7
7
  import { FileReadTracker } from '../diff/apply.js'
8
+ import { fmtErr } from '../tools/errors.js'
8
9
  import { ToolRegistry } from '../tools/registry.js'
9
10
  import type { ToolCall, ToolResult } from '../tools/types.js'
10
11
  import { compact, estimateMessagesTokens, shouldCompact } from './compaction.js'
@@ -291,7 +292,17 @@ export class Session {
291
292
  this.provider = new AnthropicProvider(config.apiKey, config.baseUrl, config.model)
292
293
  }
293
294
 
294
- this.messages = [{ role: 'system', content: buildSystemPrompt(config) }]
295
+ // Placeholder until initSystemPrompt() is called
296
+ this.messages = [{ role: 'system', content: '' }]
297
+ // Initialize system prompt asynchronously
298
+ this.initSystemPrompt(config)
299
+ }
300
+
301
+ /** Initialize the system prompt asynchronously */
302
+ private initSystemPrompt(config: Config): void {
303
+ buildSystemPrompt(config).then(prompt => {
304
+ this.messages = [{ role: 'system', content: prompt }]
305
+ })
295
306
  }
296
307
 
297
308
  /** Persist the current session to ~/.lonny/sessions/ */
@@ -314,7 +325,7 @@ export class Session {
314
325
  }
315
326
 
316
327
  /** Try to load a saved session for the given cwd. Returns null if none exists. */
317
- static load(config: Config, output?: SessionOutput): Session | null {
328
+ static async load(config: Config, output?: SessionOutput): Promise<Session | null> {
318
329
  const filePath = getSessionFilePath(config.cwd)
319
330
  let data: SessionData
320
331
  try {
@@ -340,7 +351,8 @@ export class Session {
340
351
  data.provider !== config.provider ||
341
352
  data.mode !== config.mode
342
353
  ) {
343
- session.messages[0] = { role: 'system', content: buildSystemPrompt(config) }
354
+ const prompt = await buildSystemPrompt(config)
355
+ session.messages[0] = { role: 'system', content: prompt }
344
356
  }
345
357
  // Restore token stats
346
358
  session.totalInputTokens = data.totalInputTokens
@@ -359,9 +371,10 @@ export class Session {
359
371
  }
360
372
  }
361
373
 
362
- setMode(mode: 'code' | 'plan' | 'ask'): void {
374
+ async setMode(mode: 'code' | 'plan' | 'ask'): Promise<void> {
363
375
  this.config.mode = mode
364
- this.messages[0] = { role: 'system', content: buildSystemPrompt(this.config) }
376
+ const prompt = await buildSystemPrompt(this.config)
377
+ this.messages[0] = { role: 'system', content: prompt }
365
378
  this.registry.setMode(mode)
366
379
  this.save()
367
380
  }
@@ -381,6 +394,8 @@ export class Session {
381
394
  /** Reset the stopped flag for a new conversation */
382
395
  resetStopped(): void {
383
396
  this.stopped = false
397
+ // Create new AbortController for next conversation
398
+ this.abortController = new AbortController()
384
399
  }
385
400
 
386
401
  async chat(userPrompt: string): Promise<void> {
@@ -433,171 +448,211 @@ export class Session {
433
448
  this.abortController.signal,
434
449
  )
435
450
 
436
- for await (const chunk of stream) {
437
- if (chunk.reasoning_content) {
438
- reasoningContent = chunk.reasoning_content
439
- // Stream reasoning content in real-time (only when no text in same chunk)
440
- if (!chunk.text) {
441
- // Emit thinking via EventBus for Web UI
442
- bus.emit(EventChannels.THINKING, { text: chunk.reasoning_content })
443
- if (!reasoningOutput) {
444
- reasoningOutput = true
445
- reasoningLineStart = true
446
- if (!out?.suppressToolOutput) {
447
- writeOut(thinkTopBorder(), out)
448
- }
449
- }
450
- // Terminal display with box drawing (skip in Web UI mode, handled by EventBus)
451
- if (!out?.suppressToolOutput) {
452
- // Track column position on current line for wrapping
453
- let thinkCol = 0
454
- // Handle newlines in streamed content - add left border on each new line
455
- // Also manually wrap long lines so wrapped lines keep the │ prefix.
456
- let remaining = chunk.reasoning_content
457
- const maxContentWidth = termWidth() - THINK_PREFIX_WIDTH
458
- while (remaining.length > 0) {
459
- if (reasoningLineStart) {
460
- writeOut(` ${GY}│${RS}${TH}`, out)
461
- reasoningLineStart = false
462
- thinkCol = 0
451
+ try {
452
+ for await (const chunk of stream) {
453
+ if (chunk.reasoning_content) {
454
+ reasoningContent = chunk.reasoning_content
455
+ // Stream reasoning content in real-time (only when no text in same chunk)
456
+ if (!chunk.text) {
457
+ // Emit thinking via EventBus for Web UI
458
+ bus.emit(EventChannels.THINKING, { text: chunk.reasoning_content })
459
+ if (!reasoningOutput) {
460
+ reasoningOutput = true
461
+ reasoningLineStart = true
462
+ if (!out?.suppressToolOutput) {
463
+ writeOut(thinkTopBorder(), out)
463
464
  }
464
- const nlIdx = remaining.indexOf('\n')
465
- if (nlIdx === -1) {
466
- // No newline — write as much as fits on current line, wrap if needed
467
- while (remaining.length > 0) {
468
- const segWidth = visibleWidth(remaining)
465
+ }
466
+ // Terminal display with box drawing (skip in Web UI mode, handled by EventBus)
467
+ if (!out?.suppressToolOutput) {
468
+ // Track column position on current line for wrapping
469
+ let thinkCol = 0
470
+ // Handle newlines in streamed content - add left border on each new line
471
+ // Also manually wrap long lines so wrapped lines keep the │ prefix.
472
+ let remaining = chunk.reasoning_content
473
+ const maxContentWidth = termWidth() - THINK_PREFIX_WIDTH
474
+ while (remaining.length > 0) {
475
+ if (reasoningLineStart) {
476
+ writeOut(` ${GY}│${RS}${TH}`, out)
477
+ reasoningLineStart = false
478
+ thinkCol = 0
479
+ }
480
+ const nlIdx = remaining.indexOf('\n')
481
+ if (nlIdx === -1) {
482
+ // No newline — write as much as fits on current line, wrap if needed
483
+ while (remaining.length > 0) {
484
+ const segWidth = visibleWidth(remaining)
485
+ const avail = maxContentWidth - thinkCol
486
+ if (segWidth <= avail) {
487
+ // Fits entirely on current line
488
+ writeOut(remaining, out)
489
+ thinkCol += segWidth
490
+ remaining = ''
491
+ } else if (avail <= 0) {
492
+ // Current line is full, wrap to next
493
+ writeOut(`${RS}\n`, out)
494
+ writeOut(` ${GY}│${RS}${TH}`, out)
495
+ thinkCol = 0
496
+ } else {
497
+ // Write first part that fits, then wrap
498
+ // Find character boundary that fits within avail
499
+ let cut = avail
500
+ while (cut > 0 && visibleWidth(remaining.slice(0, cut)) > avail) cut--
501
+ if (cut <= 0) cut = 1
502
+ writeOut(remaining.slice(0, cut), out)
503
+ writeOut(`${RS}\n`, out)
504
+ writeOut(` ${GY}│${RS}${TH}`, out)
505
+ thinkCol = 0
506
+ remaining = remaining.slice(cut)
507
+ }
508
+ }
509
+ } else {
510
+ // Has newline — process the segment up to newline
511
+ const segment = remaining.slice(0, nlIdx)
512
+ const segWidth = visibleWidth(segment)
469
513
  const avail = maxContentWidth - thinkCol
470
514
  if (segWidth <= avail) {
471
- // Fits entirely on current line
472
- writeOut(remaining, out)
473
- thinkCol += segWidth
474
- remaining = ''
475
- } else if (avail <= 0) {
476
- // Current line is full, wrap to next
515
+ // Segment fits on current line
516
+ writeOut(segment, out)
477
517
  writeOut(`${RS}\n`, out)
478
- writeOut(` ${GY}│${RS}${TH}`, out)
518
+ reasoningLineStart = true
479
519
  thinkCol = 0
480
520
  } else {
481
- // Write first part that fits, then wrap
482
- // Find character boundary that fits within avail
483
- let cut = avail
484
- while (cut > 0 && visibleWidth(remaining.slice(0, cut)) > avail) cut--
485
- if (cut <= 0) cut = 1
486
- writeOut(remaining.slice(0, cut), out)
521
+ // Segment too long write what fits, wrap, then rest
522
+ let rest = segment
523
+ // Write remainder of current line
524
+ if (avail > 0) {
525
+ let cut = avail
526
+ while (cut > 0 && visibleWidth(rest.slice(0, cut)) > avail) cut--
527
+ if (cut <= 0) cut = 1
528
+ writeOut(rest.slice(0, cut), out)
529
+ rest = rest.slice(cut)
530
+ }
487
531
  writeOut(`${RS}\n`, out)
488
- writeOut(` ${GY}│${RS}${TH}`, out)
532
+ reasoningLineStart = true
489
533
  thinkCol = 0
490
- remaining = remaining.slice(cut)
491
- }
492
- }
493
- } else {
494
- // Has newline process the segment up to newline
495
- const segment = remaining.slice(0, nlIdx)
496
- const segWidth = visibleWidth(segment)
497
- const avail = maxContentWidth - thinkCol
498
- if (segWidth <= avail) {
499
- // Segment fits on current line
500
- writeOut(segment, out)
501
- writeOut(`${RS}\n`, out)
502
- reasoningLineStart = true
503
- thinkCol = 0
504
- } else {
505
- // Segment too long — write what fits, wrap, then rest
506
- let rest = segment
507
- // Write remainder of current line
508
- if (avail > 0) {
509
- let cut = avail
510
- while (cut > 0 && visibleWidth(rest.slice(0, cut)) > avail) cut--
511
- if (cut <= 0) cut = 1
512
- writeOut(rest.slice(0, cut), out)
513
- rest = rest.slice(cut)
514
- }
515
- writeOut(`${RS}\n`, out)
516
- reasoningLineStart = true
517
- thinkCol = 0
518
- // Write rest of segment on continuation line(s)
519
- if (rest.length > 0) {
520
- writeOut(` ${GY}│${RS}${TH}`, out)
521
- reasoningLineStart = false
522
- while (rest.length > 0) {
523
- const rw = visibleWidth(rest)
524
- if (rw <= maxContentWidth) {
525
- writeOut(rest, out)
526
- thinkCol = rw
527
- rest = ''
528
- } else {
529
- let cut = maxContentWidth
530
- while (cut > 0 && visibleWidth(rest.slice(0, cut)) > maxContentWidth)
531
- cut--
532
- if (cut <= 0) cut = 1
533
- writeOut(rest.slice(0, cut), out)
534
- writeOut(`${RS}\n`, out)
535
- writeOut(` ${GY}│${RS}${TH}`, out)
536
- rest = rest.slice(cut)
534
+ // Write rest of segment on continuation line(s)
535
+ if (rest.length > 0) {
536
+ writeOut(` ${GY}│${RS}${TH}`, out)
537
+ reasoningLineStart = false
538
+ while (rest.length > 0) {
539
+ const rw = visibleWidth(rest)
540
+ if (rw <= maxContentWidth) {
541
+ writeOut(rest, out)
542
+ thinkCol = rw
543
+ rest = ''
544
+ } else {
545
+ let cut = maxContentWidth
546
+ while (cut > 0 && visibleWidth(rest.slice(0, cut)) > maxContentWidth)
547
+ cut--
548
+ if (cut <= 0) cut = 1
549
+ writeOut(rest.slice(0, cut), out)
550
+ writeOut(`${RS}\n`, out)
551
+ writeOut(` ${GY}│${RS}${TH}`, out)
552
+ rest = rest.slice(cut)
553
+ }
537
554
  }
538
555
  }
556
+ writeOut(`${RS}\n`, out)
557
+ reasoningLineStart = true
558
+ thinkCol = 0
539
559
  }
540
- writeOut(`${RS}\n`, out)
541
- reasoningLineStart = true
542
- thinkCol = 0
560
+ remaining = remaining.slice(nlIdx + 1)
543
561
  }
544
- remaining = remaining.slice(nlIdx + 1)
545
562
  }
546
563
  }
547
564
  }
548
565
  }
549
- }
550
- if (chunk.type === 'text' && chunk.text) {
551
- if (reasoningOutput) {
552
- bus.emit(EventChannels.THINKING_END, {})
553
- if (!out?.suppressToolOutput) {
554
- writeOut(`${RS}\n`, out)
555
- writeOut(thinkBottomBorder(), out)
566
+ if (chunk.type === 'text' && chunk.text) {
567
+ if (reasoningOutput) {
568
+ bus.emit(EventChannels.THINKING_END, {})
569
+ if (!out?.suppressToolOutput) {
570
+ writeOut(`${RS}\n`, out)
571
+ writeOut(thinkBottomBorder(), out)
572
+ }
573
+ reasoningOutput = false
574
+ reasoningLineStart = false
556
575
  }
557
- reasoningOutput = false
558
- reasoningLineStart = false
559
- }
560
- fullResponse += chunk.text
561
- writeOut(chunk.text, out)
562
- } else if (chunk.type === 'tool_use' && chunk.tool_call) {
563
- toolCalls.push(chunk.tool_call)
564
- } else if (chunk.type === 'complete') {
565
- if (chunk.usage) {
566
- this.turnInputTokens += chunk.usage.input_tokens
567
- this.turnOutputTokens += chunk.usage.output_tokens
568
- this.totalInputTokens += chunk.usage.input_tokens
569
- this.totalOutputTokens += chunk.usage.output_tokens
570
- }
571
- if (chunk.finish_reason === 'stop' || chunk.finish_reason === 'end_turn') {
572
- if (toolCalls.length === 0) {
573
- const finalAssistantMsg: LLMMessage = {
574
- role: 'assistant',
575
- content: fullResponse || null,
576
- reasoning_content: reasoningContent,
576
+ fullResponse += chunk.text
577
+ writeOut(chunk.text, out)
578
+ } else if (chunk.type === 'tool_use' && chunk.tool_call) {
579
+ toolCalls.push(chunk.tool_call)
580
+ } else if (chunk.type === 'complete') {
581
+ if (chunk.usage) {
582
+ this.turnInputTokens += chunk.usage.input_tokens
583
+ this.turnOutputTokens += chunk.usage.output_tokens
584
+ this.totalInputTokens += chunk.usage.input_tokens
585
+ this.totalOutputTokens += chunk.usage.output_tokens
586
+ }
587
+ if (chunk.finish_reason === 'stop' || chunk.finish_reason === 'end_turn') {
588
+ if (toolCalls.length === 0) {
589
+ const finalAssistantMsg: LLMMessage = {
590
+ role: 'assistant',
591
+ content: fullResponse || null,
592
+ reasoning_content: reasoningContent,
593
+ }
594
+ this.messages.push(finalAssistantMsg)
595
+ printTokenStats(
596
+ this.turnInputTokens,
597
+ this.turnOutputTokens,
598
+ this.totalInputTokens,
599
+ this.totalOutputTokens,
600
+ this.turnApiCalls,
601
+ this.totalApiCalls,
602
+ out,
603
+ )
604
+ writeOut('\n\n', out)
605
+ saveTokenUsage(
606
+ this.config.cwd,
607
+ this.turnInputTokens,
608
+ this.turnOutputTokens,
609
+ this.turnApiCalls,
610
+ )
611
+ bus.emit(EventChannels.TURN_END, { iterations, toolCallCount: 0 })
612
+ this.save()
613
+ return
577
614
  }
578
- this.messages.push(finalAssistantMsg)
579
- printTokenStats(
580
- this.turnInputTokens,
581
- this.turnOutputTokens,
582
- this.totalInputTokens,
583
- this.totalOutputTokens,
584
- this.turnApiCalls,
585
- this.totalApiCalls,
586
- out,
587
- )
588
- writeOut('\n\n', out)
589
- saveTokenUsage(
590
- this.config.cwd,
591
- this.turnInputTokens,
592
- this.turnOutputTokens,
593
- this.turnApiCalls,
594
- )
595
- bus.emit(EventChannels.TURN_END, { iterations, toolCallCount: 0 })
596
- this.save()
597
- return
598
615
  }
599
616
  }
600
617
  }
618
+ } catch (e) {
619
+ const errMsg = fmtErr(e)
620
+ const partialContent = fullResponse ? fullResponse.slice(0, 500) : '(empty)'
621
+ if (!out?.suppressToolOutput) {
622
+ writeOut(`\n${RE}Stream error:${RS} ${errMsg}`, out)
623
+ writeOut(`\n ${GY}┃${RS} Partial response: ${partialContent}\n`, out)
624
+ }
625
+ console.error('[session] Stream error:', errMsg, '| Partial response:', partialContent)
626
+ if (reasoningOutput) {
627
+ bus.emit(EventChannels.THINKING_END, {})
628
+ if (!out?.suppressToolOutput) {
629
+ writeOut(`${RS}\n`, out)
630
+ writeOut(thinkBottomBorder(), out)
631
+ }
632
+ }
633
+ bus.emit(EventChannels.LLM_STREAM_END, { iteration: iterations, toolCallCount: 0 })
634
+ bus.emit(EventChannels.TURN_END, { iterations, toolCallCount: 0 })
635
+
636
+ // If there are pending toolCalls that weren't executed due to abort,
637
+ // add an assistant message so the model knows they weren't executed
638
+ if (toolCalls.length > 0) {
639
+ const interruptedMsg: LLMMessage = {
640
+ role: 'assistant',
641
+ content: null,
642
+ tool_calls: toolCalls,
643
+ reasoning_content: reasoningContent,
644
+ }
645
+ this.messages.push(interruptedMsg)
646
+ }
647
+
648
+ saveTokenUsage(
649
+ this.config.cwd,
650
+ this.turnInputTokens,
651
+ this.turnOutputTokens,
652
+ this.turnApiCalls,
653
+ )
654
+ this.save()
655
+ return
601
656
  }
602
657
 
603
658
  bus.emit(EventChannels.LLM_STREAM_END, {
@@ -676,9 +731,20 @@ export class Session {
676
731
  }
677
732
  }
678
733
 
679
- for (const tc of toolCalls) {
734
+ for (let i = 0; i < toolCalls.length; i++) {
735
+ const tc = toolCalls[i]
680
736
  // Check if stop was requested after each tool call
681
737
  if (this.isStopped()) {
738
+ // Add assistant message with remaining tool_calls so model knows they weren't executed
739
+ const remainingToolCalls = toolCalls.slice(i)
740
+ if (remainingToolCalls.length > 0) {
741
+ const interruptedMsg: LLMMessage = {
742
+ role: 'assistant',
743
+ content: null,
744
+ tool_calls: remainingToolCalls,
745
+ }
746
+ this.messages.push(interruptedMsg)
747
+ }
682
748
  bus.emit(EventChannels.TURN_END, { iterations, toolCallCount: toolCalls.length })
683
749
  this.save()
684
750
  return
@@ -295,8 +295,8 @@ describe('edit tool', () => {
295
295
  expect(r.success).toBe(true)
296
296
  // Should contain green ANSI code for added lines
297
297
  expect(r.output).toContain('\x1b[38;2;0;200;100m') // green
298
- expect(r.output).toContain('+ hello')
299
- expect(r.output).toContain('+ world')
298
+ expect(r.output).toContain('hello')
299
+ expect(r.output).toContain('world')
300
300
  // Should NOT contain red ANSI code (no removed lines)
301
301
  expect(r.output).not.toContain('\x1b[38;2;255;80;80m')
302
302
  expect(r.output).toContain('Created')
@@ -315,10 +315,10 @@ describe('edit tool', () => {
315
315
  expect(r.success).toBe(true)
316
316
  // Should contain red ANSI code for removed content
317
317
  expect(r.output).toContain('\x1b[38;2;255;80;80m')
318
- expect(r.output).toContain('- line two')
318
+ expect(r.output).toContain('line two')
319
319
  // Should contain green ANSI code for added content
320
320
  expect(r.output).toContain('\x1b[38;2;0;200;100m')
321
- expect(r.output).toContain('+ line TWO')
321
+ expect(r.output).toContain('line TWO')
322
322
  expect(r.output).toContain('Edited')
323
323
  })
324
324
 
@@ -333,12 +333,12 @@ describe('edit tool', () => {
333
333
  ],
334
334
  })
335
335
  expect(r.success).toBe(true)
336
- // Removed lines
337
- expect(r.output).toContain('- line one')
338
- expect(r.output).toContain('- line TWO')
339
- // Added lines
340
- expect(r.output).toContain('+ line 1')
341
- expect(r.output).toContain('+ line 2')
336
+ // Removed lines (red)
337
+ expect(r.output).toContain('\x1b[38;2;255;80;80mline one')
338
+ expect(r.output).toContain('\x1b[38;2;255;80;80mline TWO')
339
+ // Added lines (green)
340
+ expect(r.output).toContain('\x1b[38;2;0;200;100mline 1')
341
+ expect(r.output).toContain('\x1b[38;2;0;200;100mline 2')
342
342
  })
343
343
 
344
344
  it('handles CRLF in old_string (Windows compatibility)', async () => {
@@ -357,8 +357,8 @@ describe('edit tool', () => {
357
357
  const content = fs.readFileSync(path.join(tmpDir, 'crlf-test.txt'), 'utf8')
358
358
  expect(content).toContain('B')
359
359
  // Should show the normalized diff (with \\n, not \\r\\n)
360
- expect(r.output).toContain('- b')
361
- expect(r.output).toContain('+ B')
360
+ expect(r.output).toContain('\x1b[38;2;255;80;80mb')
361
+ expect(r.output).toContain('\x1b[38;2;0;200;100mB')
362
362
  })
363
363
 
364
364
  it('does not show empty red line when old_string is empty', async () => {
@@ -366,8 +366,8 @@ describe('edit tool', () => {
366
366
  edits: [{ file_path: 'empty-old.txt', old_string: '', new_string: 'only added' }],
367
367
  })
368
368
  expect(r.success).toBe(true)
369
- // Should have green + lines
370
- expect(r.output).toContain('+ only added')
369
+ // Should have green colored line
370
+ expect(r.output).toContain('\x1b[38;2;0;200;100monly added')
371
371
  // Count red-start markers — should be 0 (no removed content)
372
372
  const redCount = (r.output.match(/\x1b\[38;2;255;80;80m/g) || []).length
373
373
  expect(redCount).toBe(0)