@robota-sdk/agent-transport 3.0.0-beta.64

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 (183) hide show
  1. package/LICENSE +21 -0
  2. package/dist/node/headless/index.cjs +1 -0
  3. package/dist/node/headless/index.d.ts +2 -0
  4. package/dist/node/headless/index.js +1 -0
  5. package/dist/node/headless-CWEpJXFK.js +7 -0
  6. package/dist/node/headless-CWEpJXFK.js.map +1 -0
  7. package/dist/node/headless-CsZFelG9.cjs +6 -0
  8. package/dist/node/http/index.cjs +1 -0
  9. package/dist/node/http/index.d.ts +2 -0
  10. package/dist/node/http/index.js +1 -0
  11. package/dist/node/http-CM3TJhrF.cjs +1 -0
  12. package/dist/node/http-DwO1AHG-.js +2 -0
  13. package/dist/node/http-DwO1AHG-.js.map +1 -0
  14. package/dist/node/index--Ti9NzQX.d.ts +64 -0
  15. package/dist/node/index--Ti9NzQX.d.ts.map +1 -0
  16. package/dist/node/index-B_rcr14p.d.ts +47 -0
  17. package/dist/node/index-B_rcr14p.d.ts.map +1 -0
  18. package/dist/node/index-C9LWCL4l.d.ts +34 -0
  19. package/dist/node/index-C9LWCL4l.d.ts.map +1 -0
  20. package/dist/node/index-CAr3ioVh.d.ts +64 -0
  21. package/dist/node/index-CAr3ioVh.d.ts.map +1 -0
  22. package/dist/node/index-CEs25wVk.d.ts +213 -0
  23. package/dist/node/index-CEs25wVk.d.ts.map +1 -0
  24. package/dist/node/index-CvXLpjJO.d.ts +213 -0
  25. package/dist/node/index-CvXLpjJO.d.ts.map +1 -0
  26. package/dist/node/index-D34WUfFH.d.ts +26 -0
  27. package/dist/node/index-D34WUfFH.d.ts.map +1 -0
  28. package/dist/node/index-Y0zHb1Bz.d.ts +47 -0
  29. package/dist/node/index-Y0zHb1Bz.d.ts.map +1 -0
  30. package/dist/node/index-k3TUjA-T.d.ts +26 -0
  31. package/dist/node/index-k3TUjA-T.d.ts.map +1 -0
  32. package/dist/node/index-nBlMTFkZ.d.ts +34 -0
  33. package/dist/node/index-nBlMTFkZ.d.ts.map +1 -0
  34. package/dist/node/index.cjs +1 -0
  35. package/dist/node/index.d.ts +6 -0
  36. package/dist/node/index.js +1 -0
  37. package/dist/node/mcp/index.cjs +1 -0
  38. package/dist/node/mcp/index.d.ts +2 -0
  39. package/dist/node/mcp/index.js +1 -0
  40. package/dist/node/mcp-BXBwF6Wu.js +2 -0
  41. package/dist/node/mcp-BXBwF6Wu.js.map +1 -0
  42. package/dist/node/mcp-DcHuGokt.cjs +1 -0
  43. package/dist/node/tui/index.cjs +1 -0
  44. package/dist/node/tui/index.d.ts +2 -0
  45. package/dist/node/tui/index.js +1 -0
  46. package/dist/node/tui-CeD_6rSo.cjs +24 -0
  47. package/dist/node/tui-zmDTPk4b.js +25 -0
  48. package/dist/node/tui-zmDTPk4b.js.map +1 -0
  49. package/dist/node/ws/index.cjs +1 -0
  50. package/dist/node/ws/index.d.ts +2 -0
  51. package/dist/node/ws/index.js +1 -0
  52. package/dist/node/ws-B-oRccFl.js +2 -0
  53. package/dist/node/ws-B-oRccFl.js.map +1 -0
  54. package/dist/node/ws-COnIgnmn.cjs +1 -0
  55. package/package.json +141 -0
  56. package/src/headless/__tests__/headless-runner-initialization.test.ts +45 -0
  57. package/src/headless/__tests__/headless-runner.test.ts +484 -0
  58. package/src/headless/__tests__/headless-skill-activation.integration.test.ts +430 -0
  59. package/src/headless/__tests__/headless-transport.test.ts +268 -0
  60. package/src/headless/headless-runner.ts +141 -0
  61. package/src/headless/headless-stream-json.ts +142 -0
  62. package/src/headless/headless-transport.ts +43 -0
  63. package/src/headless/index.ts +4 -0
  64. package/src/http/__tests__/http-transport.test.ts +55 -0
  65. package/src/http/__tests__/routes.test.ts +168 -0
  66. package/src/http/http-transport.ts +42 -0
  67. package/src/http/index.ts +4 -0
  68. package/src/http/routes.ts +151 -0
  69. package/src/index.ts +5 -0
  70. package/src/mcp/__tests__/mcp-server.test.ts +66 -0
  71. package/src/mcp/__tests__/mcp-transport.test.ts +46 -0
  72. package/src/mcp/index.ts +4 -0
  73. package/src/mcp/mcp-server.ts +162 -0
  74. package/src/mcp/mcp-transport.ts +48 -0
  75. package/src/tui/App.tsx +478 -0
  76. package/src/tui/BackgroundTaskPanel.tsx +34 -0
  77. package/src/tui/CjkTextInput.tsx +204 -0
  78. package/src/tui/ConfirmPrompt.tsx +69 -0
  79. package/src/tui/ExecutionWorkspaceDetailPane.tsx +62 -0
  80. package/src/tui/ExecutionWorkspaceSwitcher.tsx +185 -0
  81. package/src/tui/InkTerminal.ts +42 -0
  82. package/src/tui/InputArea.tsx +298 -0
  83. package/src/tui/InteractivePrompt.tsx +57 -0
  84. package/src/tui/ListPicker.tsx +94 -0
  85. package/src/tui/MenuSelect.tsx +103 -0
  86. package/src/tui/MessageList.tsx +282 -0
  87. package/src/tui/PermissionPrompt.tsx +84 -0
  88. package/src/tui/PluginTUI.tsx +256 -0
  89. package/src/tui/SessionPicker.tsx +66 -0
  90. package/src/tui/SessionStatusBar.tsx +66 -0
  91. package/src/tui/SlashAutocomplete.tsx +110 -0
  92. package/src/tui/StatusBar.tsx +213 -0
  93. package/src/tui/StreamingIndicator.tsx +91 -0
  94. package/src/tui/TextPrompt.tsx +80 -0
  95. package/src/tui/ToolCommandOutput.tsx +37 -0
  96. package/src/tui/ToolDiffBlock.tsx +30 -0
  97. package/src/tui/TransportTUI.tsx +116 -0
  98. package/src/tui/UpdateNotice.tsx +14 -0
  99. package/src/tui/UsageSummaryEntry.tsx +38 -0
  100. package/src/tui/WaveText.tsx +44 -0
  101. package/src/tui/__tests__/InteractivePrompt.test.tsx +82 -0
  102. package/src/tui/__tests__/ListPicker.test.tsx +159 -0
  103. package/src/tui/__tests__/MenuSelect.test.tsx +103 -0
  104. package/src/tui/__tests__/PluginTUI.test.tsx +167 -0
  105. package/src/tui/__tests__/SlashAutocomplete.test.tsx +140 -0
  106. package/src/tui/__tests__/TextPrompt.test.tsx +98 -0
  107. package/src/tui/__tests__/UpdateNotice.test.tsx +15 -0
  108. package/src/tui/__tests__/abort-after-permission.test.tsx +169 -0
  109. package/src/tui/__tests__/abort-streaming-e2e.test.tsx +183 -0
  110. package/src/tui/__tests__/background-task-panel.test.tsx +53 -0
  111. package/src/tui/__tests__/background-task-row-format.test.ts +59 -0
  112. package/src/tui/__tests__/cjk-text-input-flow.test.ts +109 -0
  113. package/src/tui/__tests__/cjk-text-input.test.ts +191 -0
  114. package/src/tui/__tests__/command-effect-handler.test.ts +128 -0
  115. package/src/tui/__tests__/command-output-summary.test.ts +95 -0
  116. package/src/tui/__tests__/compact-event-bridge.test.ts +20 -0
  117. package/src/tui/__tests__/confirm-permission-flow.test.ts +91 -0
  118. package/src/tui/__tests__/confirm-prompt.test.tsx +87 -0
  119. package/src/tui/__tests__/execution-workspace-switcher.test.tsx +110 -0
  120. package/src/tui/__tests__/execution-workspace-view-model.test.ts +93 -0
  121. package/src/tui/__tests__/fixtures/provider-setup-prompt-driver.tsx +122 -0
  122. package/src/tui/__tests__/input-area-flow.test.ts +152 -0
  123. package/src/tui/__tests__/message-list-rendering.test.tsx +353 -0
  124. package/src/tui/__tests__/model-change-side-effect.test.ts +91 -0
  125. package/src/tui/__tests__/prompt-queue.test.tsx +255 -0
  126. package/src/tui/__tests__/provider-setup-pty-e2e.test.ts +233 -0
  127. package/src/tui/__tests__/render-markdown.test.ts +72 -0
  128. package/src/tui/__tests__/selection-flow.test.ts +61 -0
  129. package/src/tui/__tests__/slash-routing-effects.test.ts +225 -0
  130. package/src/tui/__tests__/status-activity.test.ts +71 -0
  131. package/src/tui/__tests__/status-bar.test.tsx +157 -0
  132. package/src/tui/__tests__/streaming-indicator.test.tsx +137 -0
  133. package/src/tui/__tests__/text-prompt-flow.test.ts +77 -0
  134. package/src/tui/__tests__/tui-state-manager.test.ts +401 -0
  135. package/src/tui/background-task-row-format.ts +52 -0
  136. package/src/tui/command-output-summary.ts +122 -0
  137. package/src/tui/execution-workspace-view-model.ts +123 -0
  138. package/src/tui/flows/cjk-text-input-flow.ts +285 -0
  139. package/src/tui/flows/confirm-prompt-flow.ts +45 -0
  140. package/src/tui/flows/input-area-flow.ts +186 -0
  141. package/src/tui/flows/permission-prompt-flow.ts +76 -0
  142. package/src/tui/flows/selection-flow.ts +126 -0
  143. package/src/tui/flows/text-prompt-flow.ts +98 -0
  144. package/src/tui/hooks/command-effect-handler.ts +98 -0
  145. package/src/tui/hooks/command-effect-queue.ts +39 -0
  146. package/src/tui/hooks/model-change-side-effect.ts +63 -0
  147. package/src/tui/hooks/side-effects-types.ts +38 -0
  148. package/src/tui/hooks/use-interactive-session-init.ts +50 -0
  149. package/src/tui/hooks/useAutocomplete.ts +85 -0
  150. package/src/tui/hooks/useInteractiveSession.ts +273 -0
  151. package/src/tui/hooks/usePermissionQueue.ts +51 -0
  152. package/src/tui/hooks/usePluginCallbacks.ts +30 -0
  153. package/src/tui/hooks/usePluginScreenData.ts +84 -0
  154. package/src/tui/hooks/useSideEffects.ts +210 -0
  155. package/src/tui/hooks/useSlashRouting.ts +117 -0
  156. package/src/tui/hooks/useStatusLineSettings.ts +35 -0
  157. package/src/tui/index.ts +3 -0
  158. package/src/tui/plugin-tui-handlers.ts +163 -0
  159. package/src/tui/render-markdown.ts +129 -0
  160. package/src/tui/render.tsx +60 -0
  161. package/src/tui/status-activity.ts +63 -0
  162. package/src/tui/tui-cli-adapter-context.tsx +12 -0
  163. package/src/tui/tui-cli-adapter.ts +25 -0
  164. package/src/tui/tui-state-manager.ts +225 -0
  165. package/src/tui/tui-transport.ts +32 -0
  166. package/src/tui/types.ts +14 -0
  167. package/src/tui/utils/__tests__/edit-diff.test.ts +426 -0
  168. package/src/tui/utils/__tests__/paste-detection.test.ts +116 -0
  169. package/src/tui/utils/__tests__/paste-labels.test.ts +46 -0
  170. package/src/tui/utils/__tests__/tool-call-extractor.test.ts +227 -0
  171. package/src/tui/utils/__tests__/tool-diff-summary.test.ts +104 -0
  172. package/src/tui/utils/edit-diff.ts +152 -0
  173. package/src/tui/utils/paste-labels.ts +9 -0
  174. package/src/tui/utils/tool-call-extractor.ts +91 -0
  175. package/src/tui/utils/tool-diff-summary.ts +75 -0
  176. package/src/ws/__tests__/ws-handler.test.ts +407 -0
  177. package/src/ws/__tests__/ws-transport.test.ts +53 -0
  178. package/src/ws/index.ts +13 -0
  179. package/src/ws/ws-background-messages.ts +170 -0
  180. package/src/ws/ws-handler.ts +279 -0
  181. package/src/ws/ws-protocol.ts +76 -0
  182. package/src/ws/ws-transport-configurable.ts +123 -0
  183. package/src/ws/ws-transport.ts +42 -0
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Tests for paste detection and the full paste → label → expand pipeline.
3
+ */
4
+
5
+ import { describe, it, expect } from 'vitest';
6
+ import { filterPrintable } from '../../flows/cjk-text-input-flow.js';
7
+ import { expandPasteLabels } from '../paste-labels.js';
8
+
9
+ describe('filterPrintable and newlines', () => {
10
+ it('should strip newlines from input (they are control chars)', () => {
11
+ expect(filterPrintable('line1\nline2')).toBe('line1line2');
12
+ });
13
+
14
+ it('should strip carriage returns', () => {
15
+ expect(filterPrintable('line1\r\nline2')).toBe('line1line2');
16
+ });
17
+
18
+ it('should strip tabs', () => {
19
+ expect(filterPrintable('a\tb')).toBe('ab');
20
+ });
21
+
22
+ it('should preserve normal text', () => {
23
+ expect(filterPrintable('hello world')).toBe('hello world');
24
+ });
25
+ });
26
+
27
+ describe('Full paste pipeline: detect → label → expand', () => {
28
+ it('should correctly round-trip multiline paste content', () => {
29
+ const pastedText = 'const a = 1;\nconst b = 2;\nconst c = 3;';
30
+ const store = new Map<number, string>();
31
+
32
+ // Simulate paste detection
33
+ expect(pastedText.length > 1 && pastedText.includes('\n')).toBe(true);
34
+
35
+ // Simulate store + label creation (as InputArea.handlePaste does)
36
+ const id = 1;
37
+ store.set(id, pastedText);
38
+ const lineCount = pastedText.split('\n').length;
39
+ const label = `[Pasted text #${id} +${lineCount} lines]`;
40
+ expect(label).toBe('[Pasted text #1 +3 lines]');
41
+
42
+ // Simulate submit → expand
43
+ const expanded = expandPasteLabels(label, store);
44
+ expect(expanded).toBe(pastedText);
45
+ });
46
+
47
+ it('should handle paste mixed with typed text', () => {
48
+ const pastedText = 'function hello() {\n return "world";\n}';
49
+ const store = new Map<number, string>();
50
+ store.set(1, pastedText);
51
+
52
+ const userInput = 'Review this code: [Pasted text #1 +3 lines] and fix any bugs';
53
+ const expanded = expandPasteLabels(userInput, store);
54
+ expect(expanded).toBe(
55
+ 'Review this code: function hello() {\n return "world";\n} and fix any bugs',
56
+ );
57
+ });
58
+
59
+ it('should handle multiple pastes in one message', () => {
60
+ const store = new Map<number, string>();
61
+ store.set(1, 'first\npaste');
62
+ store.set(2, 'second\npaste\nhere');
63
+
64
+ const input = '[Pasted text #1 +2 lines] compare with [Pasted text #2 +3 lines]';
65
+ const expanded = expandPasteLabels(input, store);
66
+ expect(expanded).toBe('first\npaste compare with second\npaste\nhere');
67
+ });
68
+ });
69
+
70
+ describe('Paste at cursor position', () => {
71
+ it('label is inserted at cursor position, not end', () => {
72
+ const existingText = 'hello world';
73
+ const cursorPosition = 5; // between "hello" and " world"
74
+ const pastedText = 'line1\nline2';
75
+ const store = new Map<number, string>();
76
+ const id = 1;
77
+ store.set(id, pastedText);
78
+ const lineCount = pastedText.split('\n').length;
79
+ const label = `[Pasted text #${id} +${lineCount} lines]`;
80
+
81
+ // Simulate cursor-aware insertion (as handlePaste does)
82
+ const result =
83
+ existingText.slice(0, cursorPosition) + label + existingText.slice(cursorPosition);
84
+ expect(result).toBe('hello[Pasted text #1 +2 lines] world');
85
+
86
+ // Expand should restore original text at correct position
87
+ const expanded = expandPasteLabels(result, store);
88
+ expect(expanded).toBe('helloline1\nline2 world');
89
+ });
90
+
91
+ it('cursor hint equals cursorPosition + label.length', () => {
92
+ const cursorPosition = 5;
93
+ const label = '[Pasted text #1 +3 lines]';
94
+ const newCursorPos = cursorPosition + label.length;
95
+ expect(newCursorPos).toBe(5 + 25); // label is 25 chars
96
+ expect(newCursorPos).toBe(30);
97
+ });
98
+
99
+ it('paste at start (cursor = 0)', () => {
100
+ const existingText = 'existing';
101
+ const cursorPosition = 0;
102
+ const label = '[Pasted text #1 +2 lines]';
103
+ const result =
104
+ existingText.slice(0, cursorPosition) + label + existingText.slice(cursorPosition);
105
+ expect(result).toBe('[Pasted text #1 +2 lines]existing');
106
+ });
107
+
108
+ it('paste at end (cursor = text.length)', () => {
109
+ const existingText = 'existing';
110
+ const cursorPosition = existingText.length;
111
+ const label = '[Pasted text #1 +2 lines]';
112
+ const result =
113
+ existingText.slice(0, cursorPosition) + label + existingText.slice(cursorPosition);
114
+ expect(result).toBe('existing[Pasted text #1 +2 lines]');
115
+ });
116
+ });
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { expandPasteLabels } from '../paste-labels.js';
3
+
4
+ describe('expandPasteLabels', () => {
5
+ it('should expand single paste label', () => {
6
+ const store = new Map([[1, 'line1\nline2\nline3']]);
7
+ const result = expandPasteLabels('[Pasted text #1 +3 lines]', store);
8
+ expect(result).toBe('line1\nline2\nline3');
9
+ });
10
+
11
+ it('should expand multiple paste labels', () => {
12
+ const store = new Map([
13
+ [1, 'first paste'],
14
+ [2, 'second paste'],
15
+ ]);
16
+ const result = expandPasteLabels(
17
+ 'before [Pasted text #1 +1 lines] middle [Pasted text #2 +1 lines] after',
18
+ store,
19
+ );
20
+ expect(result).toBe('before first paste middle second paste after');
21
+ });
22
+
23
+ it('should return empty string for missing store entry', () => {
24
+ const store = new Map<number, string>();
25
+ const result = expandPasteLabels('[Pasted text #1 +3 lines]', store);
26
+ expect(result).toBe('');
27
+ });
28
+
29
+ it('should preserve text without paste labels', () => {
30
+ const store = new Map<number, string>();
31
+ const result = expandPasteLabels('regular text without labels', store);
32
+ expect(result).toBe('regular text without labels');
33
+ });
34
+
35
+ it('should handle label with different line counts', () => {
36
+ const store = new Map([[1, 'a\nb']]);
37
+ expect(expandPasteLabels('[Pasted text #1 +2 lines]', store)).toBe('a\nb');
38
+ expect(expandPasteLabels('[Pasted text #1 +99 lines]', store)).toBe('a\nb');
39
+ });
40
+
41
+ it('should expand label without line count (debounce pending)', () => {
42
+ const store = new Map([[1, 'partial paste']]);
43
+ const result = expandPasteLabels('[Pasted text #1]', store);
44
+ expect(result).toBe('partial paste');
45
+ });
46
+ });
@@ -0,0 +1,227 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { extractToolCalls, extractToolCallsWithDiff } from '../tool-call-extractor.js';
3
+
4
+ describe('extractToolCalls', () => {
5
+ it('returns empty array for empty history', () => {
6
+ expect(extractToolCalls([], 0)).toEqual([]);
7
+ });
8
+
9
+ it('returns empty array when no assistant messages have toolCalls', () => {
10
+ const history = [{ role: 'user' }, { role: 'assistant' }];
11
+ expect(extractToolCalls(history, 0)).toEqual([]);
12
+ });
13
+
14
+ it('extracts tool call with string first argument', () => {
15
+ const history = [
16
+ {
17
+ role: 'assistant',
18
+ toolCalls: [{ function: { name: 'Read', arguments: '{"filePath":"/src/index.ts"}' } }],
19
+ },
20
+ ];
21
+ expect(extractToolCalls(history, 0)).toEqual(['Read(/src/index.ts)']);
22
+ });
23
+
24
+ it('extracts multiple tool calls from one message', () => {
25
+ const history = [
26
+ {
27
+ role: 'assistant',
28
+ toolCalls: [
29
+ { function: { name: 'Bash', arguments: '{"command":"ls -la"}' } },
30
+ { function: { name: 'Glob', arguments: '{"pattern":"**/*.md"}' } },
31
+ ],
32
+ },
33
+ ];
34
+ expect(extractToolCalls(history, 0)).toEqual(['Bash(ls -la)', 'Glob(**/*.md)']);
35
+ });
36
+
37
+ it('extracts tool calls from multiple assistant messages', () => {
38
+ const history = [
39
+ {
40
+ role: 'assistant',
41
+ toolCalls: [{ function: { name: 'Read', arguments: '{"filePath":"a.ts"}' } }],
42
+ },
43
+ { role: 'user' },
44
+ {
45
+ role: 'assistant',
46
+ toolCalls: [{ function: { name: 'Write', arguments: '{"filePath":"b.ts"}' } }],
47
+ },
48
+ ];
49
+ expect(extractToolCalls(history, 0)).toEqual(['Read(a.ts)', 'Write(b.ts)']);
50
+ });
51
+
52
+ it('skips messages before startIndex', () => {
53
+ const history = [
54
+ {
55
+ role: 'assistant',
56
+ toolCalls: [{ function: { name: 'Old', arguments: '{"x":"skip"}' } }],
57
+ },
58
+ {
59
+ role: 'assistant',
60
+ toolCalls: [{ function: { name: 'New', arguments: '{"x":"keep"}' } }],
61
+ },
62
+ ];
63
+ expect(extractToolCalls(history, 1)).toEqual(['New(keep)']);
64
+ });
65
+
66
+ it('truncates long argument values with middle ellipsis', () => {
67
+ const longPath =
68
+ '/Users/jungyoun/Documents/dev/robota/packages/agent-sdk/src/plugins/very-long-directory-name/file.ts';
69
+ const history = [
70
+ {
71
+ role: 'assistant',
72
+ toolCalls: [
73
+ { function: { name: 'Read', arguments: JSON.stringify({ filePath: longPath }) } },
74
+ ],
75
+ },
76
+ ];
77
+ const result = extractToolCalls(history, 0);
78
+ expect(result).toHaveLength(1);
79
+ expect(result[0]).toContain('...');
80
+ // Should keep the tail (file name visible)
81
+ expect(result[0]).toContain('file.ts');
82
+ // Total display length should be reasonable
83
+ expect(result[0].length).toBeLessThanOrEqual(90);
84
+ });
85
+
86
+ it('handles non-string first argument (JSON stringified)', () => {
87
+ const history = [
88
+ {
89
+ role: 'assistant',
90
+ toolCalls: [{ function: { name: 'Tool', arguments: '{"count":42}' } }],
91
+ },
92
+ ];
93
+ expect(extractToolCalls(history, 0)).toEqual(['Tool(42)']);
94
+ });
95
+
96
+ it('handles invalid JSON arguments gracefully', () => {
97
+ const history = [
98
+ {
99
+ role: 'assistant',
100
+ toolCalls: [{ function: { name: 'Tool', arguments: 'not json' } }],
101
+ },
102
+ ];
103
+ expect(extractToolCalls(history, 0)).toEqual(['Tool(not json)']);
104
+ });
105
+
106
+ it('skips non-assistant messages', () => {
107
+ const history = [
108
+ { role: 'user' },
109
+ { role: 'system' },
110
+ {
111
+ role: 'assistant',
112
+ toolCalls: [{ function: { name: 'Bash', arguments: '{"command":"echo hi"}' } }],
113
+ },
114
+ ];
115
+ expect(extractToolCalls(history, 0)).toEqual(['Bash(echo hi)']);
116
+ });
117
+ });
118
+
119
+ describe('extractToolCallsWithDiff', () => {
120
+ it('non-Edit tool has no diffLines', () => {
121
+ const history = [
122
+ {
123
+ role: 'assistant',
124
+ toolCalls: [{ function: { name: 'Read', arguments: '{"filePath":"/src/a.ts"}' } }],
125
+ },
126
+ ];
127
+ const summaries = extractToolCallsWithDiff(history, 0);
128
+ expect(summaries).toHaveLength(1);
129
+ expect(summaries[0].line).toBe('Read(/src/a.ts)');
130
+ expect(summaries[0].diffLines).toBeUndefined();
131
+ expect(summaries[0].diffFile).toBeUndefined();
132
+ });
133
+
134
+ it('Edit tool includes diffLines and diffFile', () => {
135
+ const args = JSON.stringify({
136
+ file_path: '/src/index.ts',
137
+ old_string: 'const a = 1;',
138
+ new_string: 'const a = 2;',
139
+ });
140
+ const history = [
141
+ {
142
+ role: 'assistant',
143
+ toolCalls: [{ function: { name: 'Edit', arguments: args } }],
144
+ },
145
+ ];
146
+ const summaries = extractToolCallsWithDiff(history, 0);
147
+ expect(summaries).toHaveLength(1);
148
+ expect(summaries[0].diffFile).toBe('/src/index.ts');
149
+ expect(summaries[0].diffLines).toEqual([
150
+ { type: 'remove', text: 'const a = 1;', lineNumber: 1 },
151
+ { type: 'add', text: 'const a = 2;', lineNumber: 1 },
152
+ ]);
153
+ });
154
+
155
+ it('Edit tool with identical old/new has no diffLines', () => {
156
+ const args = JSON.stringify({
157
+ file_path: '/src/index.ts',
158
+ old_string: 'same',
159
+ new_string: 'same',
160
+ });
161
+ const history = [
162
+ {
163
+ role: 'assistant',
164
+ toolCalls: [{ function: { name: 'Edit', arguments: args } }],
165
+ },
166
+ ];
167
+ const summaries = extractToolCallsWithDiff(history, 0);
168
+ expect(summaries).toHaveLength(1);
169
+ expect(summaries[0].diffLines).toBeUndefined();
170
+ expect(summaries[0].diffFile).toBeUndefined();
171
+ });
172
+
173
+ it('multiple tools including Edit: only Edit has diff', () => {
174
+ const editArgs = JSON.stringify({
175
+ file_path: '/src/main.ts',
176
+ old_string: 'foo',
177
+ new_string: 'bar',
178
+ });
179
+ const history = [
180
+ {
181
+ role: 'assistant',
182
+ toolCalls: [
183
+ { function: { name: 'Read', arguments: '{"filePath":"/src/a.ts"}' } },
184
+ { function: { name: 'Edit', arguments: editArgs } },
185
+ { function: { name: 'Bash', arguments: '{"command":"ls"}' } },
186
+ ],
187
+ },
188
+ ];
189
+ const summaries = extractToolCallsWithDiff(history, 0);
190
+ expect(summaries).toHaveLength(3);
191
+ expect(summaries[0].diffLines).toBeUndefined();
192
+ expect(summaries[1].diffLines).toBeDefined();
193
+ expect(summaries[1].diffFile).toBe('/src/main.ts');
194
+ expect(summaries[2].diffLines).toBeUndefined();
195
+ });
196
+
197
+ it('extractToolCalls (plain) returns string[] without diff info', () => {
198
+ const editArgs = JSON.stringify({
199
+ file_path: '/src/index.ts',
200
+ old_string: 'a',
201
+ new_string: 'b',
202
+ });
203
+ const history = [
204
+ {
205
+ role: 'assistant',
206
+ toolCalls: [{ function: { name: 'Edit', arguments: editArgs } }],
207
+ },
208
+ ];
209
+ const result = extractToolCalls(history, 0);
210
+ expect(result).toHaveLength(1);
211
+ expect(typeof result[0]).toBe('string');
212
+ // No diff properties on plain strings
213
+ expect(result[0]).toContain('Edit(');
214
+ });
215
+
216
+ it('handles Edit tool with invalid JSON arguments gracefully', () => {
217
+ const history = [
218
+ {
219
+ role: 'assistant',
220
+ toolCalls: [{ function: { name: 'Edit', arguments: 'not json' } }],
221
+ },
222
+ ];
223
+ const summaries = extractToolCallsWithDiff(history, 0);
224
+ expect(summaries).toHaveLength(1);
225
+ expect(summaries[0].diffLines).toBeUndefined();
226
+ });
227
+ });
@@ -0,0 +1,104 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildToolDiffSummary } from '../tool-diff-summary.js';
3
+ import type { IDiffLine } from '../edit-diff.js';
4
+
5
+ describe('buildToolDiffSummary', () => {
6
+ it('builds a markdown diff fenced body while preserving file metadata', () => {
7
+ const lines: IDiffLine[] = [
8
+ { type: 'hunk', lineNumber: 10, text: '@@ -10,2 +10,2 @@' },
9
+ { type: 'context', lineNumber: 10, text: 'const before = true;' },
10
+ { type: 'remove', lineNumber: 11, text: 'const value = false;' },
11
+ { type: 'add', lineNumber: 11, text: 'const value = true;' },
12
+ ];
13
+
14
+ const summary = buildToolDiffSummary({ file: '/src/index.ts', lines });
15
+
16
+ expect(summary.file).toBe('/src/index.ts');
17
+ expect(summary.truncated).toBe(false);
18
+ expect(summary.remainingLineCount).toBe(0);
19
+ expect(summary.markdown).toBe(
20
+ [
21
+ '```diff',
22
+ '@@ -10,2 +10,2 @@',
23
+ ' 10 | const before = true;',
24
+ '- 11 | const value = false;',
25
+ '+ 11 | const value = true;',
26
+ '```',
27
+ ].join('\n'),
28
+ );
29
+ });
30
+
31
+ it('truncates long diff bodies while keeping truncation metadata outside markdown', () => {
32
+ const lines = Array.from({ length: 13 }, (_, index): IDiffLine => {
33
+ const lineNumber = index + 1;
34
+ return {
35
+ type: index % 2 === 0 ? 'remove' : 'add',
36
+ lineNumber,
37
+ text: `line ${lineNumber}`,
38
+ };
39
+ });
40
+
41
+ const summary = buildToolDiffSummary({ lines });
42
+
43
+ expect(summary.truncated).toBe(true);
44
+ expect(summary.remainingLineCount).toBe(3);
45
+ expect(summary.markdown).toContain('- 1 | line 1');
46
+ expect(summary.markdown).toContain('+ 10 | line 10');
47
+ expect(summary.markdown).not.toContain('line 11');
48
+ expect(summary.markdown).not.toContain('more lines');
49
+ });
50
+
51
+ it('preserves the first hunk when truncating multi-hunk diffs', () => {
52
+ const lines: IDiffLine[] = [
53
+ { type: 'hunk', lineNumber: 1, text: '@@ -1,4 +1,4 @@' },
54
+ { type: 'context', lineNumber: 1, text: 'one' },
55
+ { type: 'remove', lineNumber: 2, text: 'two-old' },
56
+ { type: 'add', lineNumber: 2, text: 'two-new' },
57
+ { type: 'context', lineNumber: 3, text: 'three' },
58
+ { type: 'hunk', lineNumber: 20, text: '@@ -20,4 +20,4 @@' },
59
+ { type: 'context', lineNumber: 20, text: 'twenty' },
60
+ { type: 'remove', lineNumber: 21, text: 'twenty-one-old' },
61
+ { type: 'add', lineNumber: 21, text: 'twenty-one-new' },
62
+ { type: 'context', lineNumber: 22, text: 'twenty-two' },
63
+ { type: 'hunk', lineNumber: 40, text: '@@ -40,4 +40,4 @@' },
64
+ { type: 'context', lineNumber: 40, text: 'forty' },
65
+ { type: 'remove', lineNumber: 41, text: 'forty-one-old' },
66
+ { type: 'add', lineNumber: 41, text: 'forty-one-new' },
67
+ { type: 'context', lineNumber: 42, text: 'forty-two' },
68
+ ];
69
+
70
+ const summary = buildToolDiffSummary({ lines });
71
+
72
+ expect(summary.truncated).toBe(true);
73
+ expect(summary.remainingLineCount).toBe(5);
74
+ expect(summary.markdown).toContain('@@ -1,4 +1,4 @@');
75
+ expect(summary.markdown).toContain('two-new');
76
+ expect(summary.markdown).toContain('@@ -20,4 +20,4 @@');
77
+ expect(summary.markdown).not.toContain('@@ -40,4 +40,4 @@');
78
+ });
79
+
80
+ it('counts omitted lines from the actual rendered diff lines', () => {
81
+ const lines: IDiffLine[] = [
82
+ { type: 'hunk', lineNumber: 1, text: '@@ -1,5 +1,5 @@' },
83
+ { type: 'context', lineNumber: 1, text: 'one' },
84
+ { type: 'remove', lineNumber: 2, text: 'two-old' },
85
+ { type: 'add', lineNumber: 2, text: 'two-new' },
86
+ { type: 'context', lineNumber: 3, text: 'three' },
87
+ { type: 'context', lineNumber: 4, text: 'four' },
88
+ { type: 'hunk', lineNumber: 20, text: '@@ -20,4 +20,4 @@' },
89
+ { type: 'context', lineNumber: 20, text: 'twenty' },
90
+ { type: 'remove', lineNumber: 21, text: 'twenty-one-old' },
91
+ { type: 'add', lineNumber: 21, text: 'twenty-one-new' },
92
+ { type: 'context', lineNumber: 22, text: 'twenty-two' },
93
+ { type: 'hunk', lineNumber: 40, text: '@@ -40,1 +40,1 @@' },
94
+ { type: 'context', lineNumber: 40, text: 'forty' },
95
+ ];
96
+
97
+ const summary = buildToolDiffSummary({ lines });
98
+
99
+ expect(summary.truncated).toBe(true);
100
+ expect(summary.remainingLineCount).toBe(7);
101
+ expect(summary.markdown).toContain('@@ -1,5 +1,5 @@');
102
+ expect(summary.markdown).not.toContain('@@ -20,4 +20,4 @@');
103
+ });
104
+ });
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Generate diff lines from Edit tool's old_string and new_string.
3
+ * Includes absolute line numbers and optional context lines from the file.
4
+ */
5
+
6
+ import { readFileSync } from 'node:fs';
7
+ import type { TUniversalValue } from '@robota-sdk/agent-core';
8
+
9
+ export interface IDiffLine {
10
+ type: 'add' | 'remove' | 'context' | 'hunk';
11
+ text: string;
12
+ /** Absolute line number in the file */
13
+ lineNumber: number;
14
+ }
15
+
16
+ /** Number of context lines to show before and after the change */
17
+ const CONTEXT_LINES = 3;
18
+
19
+ /**
20
+ * Generate diff lines from old and new strings with absolute line numbers.
21
+ * @param oldStr - The text being replaced
22
+ * @param newStr - The replacement text
23
+ * @param startLine - The 1-based line number where oldStr starts in the file
24
+ */
25
+ export function generateDiffLines(
26
+ oldStr: string,
27
+ newStr: string,
28
+ startLine: number = 1,
29
+ ): IDiffLine[] {
30
+ if (oldStr === newStr) return [];
31
+
32
+ const lines: IDiffLine[] = [];
33
+
34
+ const oldLines = oldStr.split('\n');
35
+ const newLines = newStr.split('\n');
36
+
37
+ for (let i = 0; i < oldLines.length; i++) {
38
+ lines.push({ type: 'remove', text: oldLines[i], lineNumber: startLine + i });
39
+ }
40
+ for (let i = 0; i < newLines.length; i++) {
41
+ lines.push({ type: 'add', text: newLines[i], lineNumber: startLine + i });
42
+ }
43
+
44
+ return lines;
45
+ }
46
+
47
+ /**
48
+ * Generate diff lines with context from the modified file.
49
+ * Reads the file (already modified) to get surrounding lines.
50
+ */
51
+ export function generateDiffLinesWithContext(
52
+ oldStr: string,
53
+ newStr: string,
54
+ startLine: number,
55
+ filePath: string,
56
+ ): IDiffLine[] {
57
+ if (oldStr === newStr) return [];
58
+
59
+ const diffLines = generateDiffLines(oldStr, newStr, startLine);
60
+
61
+ // Read modified file for context lines
62
+ let fileLines: string[];
63
+ try {
64
+ fileLines = readFileSync(filePath, 'utf-8').split('\n');
65
+ } catch {
66
+ return diffLines; // Can't read file — return without context
67
+ }
68
+
69
+ // Context BEFORE: lines before startLine in the file
70
+ const contextStart = Math.max(0, startLine - 1 - CONTEXT_LINES);
71
+ const beforeContext: IDiffLine[] = [];
72
+ for (let i = contextStart; i < startLine - 1; i++) {
73
+ if (i < fileLines.length) {
74
+ beforeContext.push({ type: 'context', text: fileLines[i], lineNumber: i + 1 });
75
+ }
76
+ }
77
+
78
+ // Context AFTER: lines after the new content in the modified file
79
+ const newLineCount = newStr.split('\n').length;
80
+ const afterStart = startLine - 1 + newLineCount;
81
+ const afterContext: IDiffLine[] = [];
82
+ for (let i = afterStart; i < afterStart + CONTEXT_LINES; i++) {
83
+ if (i < fileLines.length) {
84
+ afterContext.push({ type: 'context', text: fileLines[i], lineNumber: i + 1 });
85
+ }
86
+ }
87
+
88
+ const hunkStart =
89
+ beforeContext[0]?.lineNumber ??
90
+ diffLines[0]?.lineNumber ??
91
+ afterContext[0]?.lineNumber ??
92
+ startLine;
93
+ const oldLineCount = oldStr.split('\n').length;
94
+ const newLineCountInHunk = newStr.split('\n').length;
95
+ const oldHunkLineCount = beforeContext.length + oldLineCount + afterContext.length;
96
+ const newHunkLineCount = beforeContext.length + newLineCountInHunk + afterContext.length;
97
+
98
+ return [
99
+ {
100
+ type: 'hunk',
101
+ text: `@@ -${hunkStart},${oldHunkLineCount} +${hunkStart},${newHunkLineCount} @@`,
102
+ lineNumber: hunkStart,
103
+ },
104
+ ...beforeContext,
105
+ ...diffLines,
106
+ ...afterContext,
107
+ ];
108
+ }
109
+
110
+ /**
111
+ * Extract Edit tool diff info from tool arguments.
112
+ * @param toolName - Tool name (must be 'Edit')
113
+ * @param toolArgs - Tool arguments (filePath, oldString, newString)
114
+ * @param startLine - Start line number from Edit tool result (optional)
115
+ */
116
+ export function extractEditDiff(
117
+ toolName: string,
118
+ toolArgs?: Record<string, TUniversalValue>,
119
+ startLine?: number,
120
+ ): { file: string; lines: IDiffLine[] } | null {
121
+ if (toolName !== 'Edit' || !toolArgs) return null;
122
+
123
+ const filePath = toolArgs.file_path ?? toolArgs.filePath;
124
+ const oldStr = toolArgs.old_string ?? toolArgs.oldString;
125
+ const newStr = toolArgs.new_string ?? toolArgs.newString;
126
+
127
+ if (typeof filePath !== 'string') return null;
128
+ if (typeof oldStr !== 'string' || typeof newStr !== 'string') return null;
129
+
130
+ // Resolve start line: from tool result, or by finding newString in the modified file
131
+ let sl = startLine ?? 0;
132
+ if (!sl) {
133
+ try {
134
+ const fileContent = readFileSync(filePath, 'utf-8');
135
+ const idx = fileContent.indexOf(newStr);
136
+ if (idx >= 0) {
137
+ sl = fileContent.substring(0, idx).split('\n').length;
138
+ } else {
139
+ sl = 1;
140
+ }
141
+ } catch {
142
+ sl = 1;
143
+ }
144
+ }
145
+
146
+ // Always try to include context from the file
147
+ const lines = generateDiffLinesWithContext(oldStr, newStr, sl, filePath);
148
+
149
+ if (lines.length === 0) return null;
150
+
151
+ return { file: filePath, lines };
152
+ }
@@ -0,0 +1,9 @@
1
+ const PASTE_LABEL_RE = /\[Pasted text #(\d+)(?: \+\d+ lines)?\]/g;
2
+
3
+ /**
4
+ * Replace paste label placeholders with their original content from the store.
5
+ * Labels that have no matching store entry are replaced with empty string.
6
+ */
7
+ export function expandPasteLabels(text: string, store: Map<number, string>): string {
8
+ return text.replace(PASTE_LABEL_RE, (_, id: string) => store.get(Number(id)) ?? '');
9
+ }