@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.
- package/LICENSE +21 -0
- package/dist/node/headless/index.cjs +1 -0
- package/dist/node/headless/index.d.ts +2 -0
- package/dist/node/headless/index.js +1 -0
- package/dist/node/headless-CWEpJXFK.js +7 -0
- package/dist/node/headless-CWEpJXFK.js.map +1 -0
- package/dist/node/headless-CsZFelG9.cjs +6 -0
- package/dist/node/http/index.cjs +1 -0
- package/dist/node/http/index.d.ts +2 -0
- package/dist/node/http/index.js +1 -0
- package/dist/node/http-CM3TJhrF.cjs +1 -0
- package/dist/node/http-DwO1AHG-.js +2 -0
- package/dist/node/http-DwO1AHG-.js.map +1 -0
- package/dist/node/index--Ti9NzQX.d.ts +64 -0
- package/dist/node/index--Ti9NzQX.d.ts.map +1 -0
- package/dist/node/index-B_rcr14p.d.ts +47 -0
- package/dist/node/index-B_rcr14p.d.ts.map +1 -0
- package/dist/node/index-C9LWCL4l.d.ts +34 -0
- package/dist/node/index-C9LWCL4l.d.ts.map +1 -0
- package/dist/node/index-CAr3ioVh.d.ts +64 -0
- package/dist/node/index-CAr3ioVh.d.ts.map +1 -0
- package/dist/node/index-CEs25wVk.d.ts +213 -0
- package/dist/node/index-CEs25wVk.d.ts.map +1 -0
- package/dist/node/index-CvXLpjJO.d.ts +213 -0
- package/dist/node/index-CvXLpjJO.d.ts.map +1 -0
- package/dist/node/index-D34WUfFH.d.ts +26 -0
- package/dist/node/index-D34WUfFH.d.ts.map +1 -0
- package/dist/node/index-Y0zHb1Bz.d.ts +47 -0
- package/dist/node/index-Y0zHb1Bz.d.ts.map +1 -0
- package/dist/node/index-k3TUjA-T.d.ts +26 -0
- package/dist/node/index-k3TUjA-T.d.ts.map +1 -0
- package/dist/node/index-nBlMTFkZ.d.ts +34 -0
- package/dist/node/index-nBlMTFkZ.d.ts.map +1 -0
- package/dist/node/index.cjs +1 -0
- package/dist/node/index.d.ts +6 -0
- package/dist/node/index.js +1 -0
- package/dist/node/mcp/index.cjs +1 -0
- package/dist/node/mcp/index.d.ts +2 -0
- package/dist/node/mcp/index.js +1 -0
- package/dist/node/mcp-BXBwF6Wu.js +2 -0
- package/dist/node/mcp-BXBwF6Wu.js.map +1 -0
- package/dist/node/mcp-DcHuGokt.cjs +1 -0
- package/dist/node/tui/index.cjs +1 -0
- package/dist/node/tui/index.d.ts +2 -0
- package/dist/node/tui/index.js +1 -0
- package/dist/node/tui-CeD_6rSo.cjs +24 -0
- package/dist/node/tui-zmDTPk4b.js +25 -0
- package/dist/node/tui-zmDTPk4b.js.map +1 -0
- package/dist/node/ws/index.cjs +1 -0
- package/dist/node/ws/index.d.ts +2 -0
- package/dist/node/ws/index.js +1 -0
- package/dist/node/ws-B-oRccFl.js +2 -0
- package/dist/node/ws-B-oRccFl.js.map +1 -0
- package/dist/node/ws-COnIgnmn.cjs +1 -0
- package/package.json +141 -0
- package/src/headless/__tests__/headless-runner-initialization.test.ts +45 -0
- package/src/headless/__tests__/headless-runner.test.ts +484 -0
- package/src/headless/__tests__/headless-skill-activation.integration.test.ts +430 -0
- package/src/headless/__tests__/headless-transport.test.ts +268 -0
- package/src/headless/headless-runner.ts +141 -0
- package/src/headless/headless-stream-json.ts +142 -0
- package/src/headless/headless-transport.ts +43 -0
- package/src/headless/index.ts +4 -0
- package/src/http/__tests__/http-transport.test.ts +55 -0
- package/src/http/__tests__/routes.test.ts +168 -0
- package/src/http/http-transport.ts +42 -0
- package/src/http/index.ts +4 -0
- package/src/http/routes.ts +151 -0
- package/src/index.ts +5 -0
- package/src/mcp/__tests__/mcp-server.test.ts +66 -0
- package/src/mcp/__tests__/mcp-transport.test.ts +46 -0
- package/src/mcp/index.ts +4 -0
- package/src/mcp/mcp-server.ts +162 -0
- package/src/mcp/mcp-transport.ts +48 -0
- package/src/tui/App.tsx +478 -0
- package/src/tui/BackgroundTaskPanel.tsx +34 -0
- package/src/tui/CjkTextInput.tsx +204 -0
- package/src/tui/ConfirmPrompt.tsx +69 -0
- package/src/tui/ExecutionWorkspaceDetailPane.tsx +62 -0
- package/src/tui/ExecutionWorkspaceSwitcher.tsx +185 -0
- package/src/tui/InkTerminal.ts +42 -0
- package/src/tui/InputArea.tsx +298 -0
- package/src/tui/InteractivePrompt.tsx +57 -0
- package/src/tui/ListPicker.tsx +94 -0
- package/src/tui/MenuSelect.tsx +103 -0
- package/src/tui/MessageList.tsx +282 -0
- package/src/tui/PermissionPrompt.tsx +84 -0
- package/src/tui/PluginTUI.tsx +256 -0
- package/src/tui/SessionPicker.tsx +66 -0
- package/src/tui/SessionStatusBar.tsx +66 -0
- package/src/tui/SlashAutocomplete.tsx +110 -0
- package/src/tui/StatusBar.tsx +213 -0
- package/src/tui/StreamingIndicator.tsx +91 -0
- package/src/tui/TextPrompt.tsx +80 -0
- package/src/tui/ToolCommandOutput.tsx +37 -0
- package/src/tui/ToolDiffBlock.tsx +30 -0
- package/src/tui/TransportTUI.tsx +116 -0
- package/src/tui/UpdateNotice.tsx +14 -0
- package/src/tui/UsageSummaryEntry.tsx +38 -0
- package/src/tui/WaveText.tsx +44 -0
- package/src/tui/__tests__/InteractivePrompt.test.tsx +82 -0
- package/src/tui/__tests__/ListPicker.test.tsx +159 -0
- package/src/tui/__tests__/MenuSelect.test.tsx +103 -0
- package/src/tui/__tests__/PluginTUI.test.tsx +167 -0
- package/src/tui/__tests__/SlashAutocomplete.test.tsx +140 -0
- package/src/tui/__tests__/TextPrompt.test.tsx +98 -0
- package/src/tui/__tests__/UpdateNotice.test.tsx +15 -0
- package/src/tui/__tests__/abort-after-permission.test.tsx +169 -0
- package/src/tui/__tests__/abort-streaming-e2e.test.tsx +183 -0
- package/src/tui/__tests__/background-task-panel.test.tsx +53 -0
- package/src/tui/__tests__/background-task-row-format.test.ts +59 -0
- package/src/tui/__tests__/cjk-text-input-flow.test.ts +109 -0
- package/src/tui/__tests__/cjk-text-input.test.ts +191 -0
- package/src/tui/__tests__/command-effect-handler.test.ts +128 -0
- package/src/tui/__tests__/command-output-summary.test.ts +95 -0
- package/src/tui/__tests__/compact-event-bridge.test.ts +20 -0
- package/src/tui/__tests__/confirm-permission-flow.test.ts +91 -0
- package/src/tui/__tests__/confirm-prompt.test.tsx +87 -0
- package/src/tui/__tests__/execution-workspace-switcher.test.tsx +110 -0
- package/src/tui/__tests__/execution-workspace-view-model.test.ts +93 -0
- package/src/tui/__tests__/fixtures/provider-setup-prompt-driver.tsx +122 -0
- package/src/tui/__tests__/input-area-flow.test.ts +152 -0
- package/src/tui/__tests__/message-list-rendering.test.tsx +353 -0
- package/src/tui/__tests__/model-change-side-effect.test.ts +91 -0
- package/src/tui/__tests__/prompt-queue.test.tsx +255 -0
- package/src/tui/__tests__/provider-setup-pty-e2e.test.ts +233 -0
- package/src/tui/__tests__/render-markdown.test.ts +72 -0
- package/src/tui/__tests__/selection-flow.test.ts +61 -0
- package/src/tui/__tests__/slash-routing-effects.test.ts +225 -0
- package/src/tui/__tests__/status-activity.test.ts +71 -0
- package/src/tui/__tests__/status-bar.test.tsx +157 -0
- package/src/tui/__tests__/streaming-indicator.test.tsx +137 -0
- package/src/tui/__tests__/text-prompt-flow.test.ts +77 -0
- package/src/tui/__tests__/tui-state-manager.test.ts +401 -0
- package/src/tui/background-task-row-format.ts +52 -0
- package/src/tui/command-output-summary.ts +122 -0
- package/src/tui/execution-workspace-view-model.ts +123 -0
- package/src/tui/flows/cjk-text-input-flow.ts +285 -0
- package/src/tui/flows/confirm-prompt-flow.ts +45 -0
- package/src/tui/flows/input-area-flow.ts +186 -0
- package/src/tui/flows/permission-prompt-flow.ts +76 -0
- package/src/tui/flows/selection-flow.ts +126 -0
- package/src/tui/flows/text-prompt-flow.ts +98 -0
- package/src/tui/hooks/command-effect-handler.ts +98 -0
- package/src/tui/hooks/command-effect-queue.ts +39 -0
- package/src/tui/hooks/model-change-side-effect.ts +63 -0
- package/src/tui/hooks/side-effects-types.ts +38 -0
- package/src/tui/hooks/use-interactive-session-init.ts +50 -0
- package/src/tui/hooks/useAutocomplete.ts +85 -0
- package/src/tui/hooks/useInteractiveSession.ts +273 -0
- package/src/tui/hooks/usePermissionQueue.ts +51 -0
- package/src/tui/hooks/usePluginCallbacks.ts +30 -0
- package/src/tui/hooks/usePluginScreenData.ts +84 -0
- package/src/tui/hooks/useSideEffects.ts +210 -0
- package/src/tui/hooks/useSlashRouting.ts +117 -0
- package/src/tui/hooks/useStatusLineSettings.ts +35 -0
- package/src/tui/index.ts +3 -0
- package/src/tui/plugin-tui-handlers.ts +163 -0
- package/src/tui/render-markdown.ts +129 -0
- package/src/tui/render.tsx +60 -0
- package/src/tui/status-activity.ts +63 -0
- package/src/tui/tui-cli-adapter-context.tsx +12 -0
- package/src/tui/tui-cli-adapter.ts +25 -0
- package/src/tui/tui-state-manager.ts +225 -0
- package/src/tui/tui-transport.ts +32 -0
- package/src/tui/types.ts +14 -0
- package/src/tui/utils/__tests__/edit-diff.test.ts +426 -0
- package/src/tui/utils/__tests__/paste-detection.test.ts +116 -0
- package/src/tui/utils/__tests__/paste-labels.test.ts +46 -0
- package/src/tui/utils/__tests__/tool-call-extractor.test.ts +227 -0
- package/src/tui/utils/__tests__/tool-diff-summary.test.ts +104 -0
- package/src/tui/utils/edit-diff.ts +152 -0
- package/src/tui/utils/paste-labels.ts +9 -0
- package/src/tui/utils/tool-call-extractor.ts +91 -0
- package/src/tui/utils/tool-diff-summary.ts +75 -0
- package/src/ws/__tests__/ws-handler.test.ts +407 -0
- package/src/ws/__tests__/ws-transport.test.ts +53 -0
- package/src/ws/index.ts +13 -0
- package/src/ws/ws-background-messages.ts +170 -0
- package/src/ws/ws-handler.ts +279 -0
- package/src/ws/ws-protocol.ts +76 -0
- package/src/ws/ws-transport-configurable.ts +123 -0
- package/src/ws/ws-transport.ts +42 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TuiStateManager — pure TypeScript rendering state manager.
|
|
3
|
+
*
|
|
4
|
+
* Converts InteractiveSession events into rendering state.
|
|
5
|
+
* No React dependency. Fully unit-testable.
|
|
6
|
+
*
|
|
7
|
+
* React hook (useInteractiveSession) subscribes to onChange
|
|
8
|
+
* and reads state for rendering.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { IContextWindowState, IHistoryEntry } from '@robota-sdk/agent-core';
|
|
12
|
+
import type {
|
|
13
|
+
IToolState,
|
|
14
|
+
IExecutionResult,
|
|
15
|
+
IExecutionWorkspaceSnapshot,
|
|
16
|
+
} from '@robota-sdk/agent-framework';
|
|
17
|
+
|
|
18
|
+
/** Max messages kept in rendering state */
|
|
19
|
+
const MAX_RENDERED_MESSAGES = 100;
|
|
20
|
+
|
|
21
|
+
/** Debounce interval for streaming text notify (limits renderMarkdown frequency) */
|
|
22
|
+
const STREAMING_DEBOUNCE_MS = 300;
|
|
23
|
+
export interface IContextState {
|
|
24
|
+
percentage: number;
|
|
25
|
+
usedTokens: number;
|
|
26
|
+
maxTokens: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Create a debounced notify — schedules at most one call per interval. */
|
|
30
|
+
function createDebouncedNotify(
|
|
31
|
+
notify: () => void,
|
|
32
|
+
ms: number,
|
|
33
|
+
): { schedule: () => void; flush: () => void } {
|
|
34
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
35
|
+
return {
|
|
36
|
+
schedule() {
|
|
37
|
+
if (!timer) {
|
|
38
|
+
timer = setTimeout(() => {
|
|
39
|
+
timer = null;
|
|
40
|
+
notify();
|
|
41
|
+
}, ms);
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
flush() {
|
|
45
|
+
if (timer) {
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
timer = null;
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class TuiStateManager {
|
|
54
|
+
// ── Rendering state ───────────────────────────────────────────
|
|
55
|
+
history: IHistoryEntry[] = [];
|
|
56
|
+
streamingText = '';
|
|
57
|
+
activeTools: IToolState[] = [];
|
|
58
|
+
isThinking = false;
|
|
59
|
+
isAborting = false;
|
|
60
|
+
pendingPrompt: string | null = null;
|
|
61
|
+
contextState: IContextState = { percentage: 0, usedTokens: 0, maxTokens: 0 };
|
|
62
|
+
executionWorkspaceSnapshot: IExecutionWorkspaceSnapshot | null = null;
|
|
63
|
+
selectedExecutionEntryId: string | undefined;
|
|
64
|
+
|
|
65
|
+
/** Called after any state change. React hook sets this to trigger re-render. */
|
|
66
|
+
onChange: (() => void) | null = null;
|
|
67
|
+
|
|
68
|
+
// ── Internal ──────────────────────────────────────────────────
|
|
69
|
+
private streamBuf = '';
|
|
70
|
+
private debouncedStreamNotify = createDebouncedNotify(() => this.notify(), STREAMING_DEBOUNCE_MS);
|
|
71
|
+
|
|
72
|
+
private notify(): void {
|
|
73
|
+
this.onChange?.();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Event handlers (InteractiveSession → state) ───────────────
|
|
77
|
+
|
|
78
|
+
onTextDelta = (delta: string): void => {
|
|
79
|
+
this.streamBuf += delta;
|
|
80
|
+
this.streamingText = this.streamBuf;
|
|
81
|
+
this.debouncedStreamNotify.schedule();
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
onToolStart = (state: IToolState): void => {
|
|
85
|
+
this.activeTools = [...this.activeTools, state];
|
|
86
|
+
this.notify();
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
onToolEnd = (state: IToolState): void => {
|
|
90
|
+
// findLastIndex: when same tool runs concurrently, match the most recently started instance
|
|
91
|
+
const idx = this.activeTools.findLastIndex((t) => t.toolName === state.toolName && t.isRunning);
|
|
92
|
+
if (idx !== -1) {
|
|
93
|
+
const updated = [...this.activeTools];
|
|
94
|
+
updated[idx] = state;
|
|
95
|
+
this.activeTools = updated;
|
|
96
|
+
}
|
|
97
|
+
this.notify();
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
onThinking = (thinking: boolean): void => {
|
|
101
|
+
this.isThinking = thinking;
|
|
102
|
+
if (thinking) {
|
|
103
|
+
// Clear at START of new execution (preserves previous result until next)
|
|
104
|
+
this.debouncedStreamNotify.flush();
|
|
105
|
+
this.streamBuf = '';
|
|
106
|
+
this.streamingText = '';
|
|
107
|
+
this.activeTools = [];
|
|
108
|
+
} else {
|
|
109
|
+
this.isAborting = false;
|
|
110
|
+
}
|
|
111
|
+
this.notify();
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
onComplete = (result: IExecutionResult): void => {
|
|
115
|
+
// Tool summary is now in messages (pushed by InteractiveSession)
|
|
116
|
+
// Clear streaming display
|
|
117
|
+
this.debouncedStreamNotify.flush();
|
|
118
|
+
this.streamBuf = '';
|
|
119
|
+
this.streamingText = '';
|
|
120
|
+
this.activeTools = [];
|
|
121
|
+
this.contextState = {
|
|
122
|
+
percentage: result.contextState.usedPercentage,
|
|
123
|
+
usedTokens: result.contextState.usedTokens,
|
|
124
|
+
maxTokens: result.contextState.maxTokens,
|
|
125
|
+
};
|
|
126
|
+
this.notify();
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
onInterrupted = (): void => {
|
|
130
|
+
// Tool summary is now in messages
|
|
131
|
+
this.debouncedStreamNotify.flush();
|
|
132
|
+
this.streamBuf = '';
|
|
133
|
+
this.streamingText = '';
|
|
134
|
+
this.activeTools = [];
|
|
135
|
+
this.notify();
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
onError = (): void => {
|
|
139
|
+
// Tool summary is now in messages
|
|
140
|
+
this.debouncedStreamNotify.flush();
|
|
141
|
+
this.streamBuf = '';
|
|
142
|
+
this.streamingText = '';
|
|
143
|
+
this.activeTools = [];
|
|
144
|
+
this.notify();
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
onContextUpdate = (state: IContextWindowState): void => {
|
|
148
|
+
this.setContextState({
|
|
149
|
+
percentage: state.usedPercentage,
|
|
150
|
+
usedTokens: state.usedTokens,
|
|
151
|
+
maxTokens: state.maxTokens,
|
|
152
|
+
});
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// ── State updates from external sources ───────────────────────
|
|
156
|
+
|
|
157
|
+
/** Sync history from InteractiveSession */
|
|
158
|
+
syncHistory(entries: IHistoryEntry[]): void {
|
|
159
|
+
if (entries.length === 0) return;
|
|
160
|
+
this.history =
|
|
161
|
+
entries.length > MAX_RENDERED_MESSAGES ? entries.slice(-MAX_RENDERED_MESSAGES) : [...entries];
|
|
162
|
+
this.notify();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Add a single history entry */
|
|
166
|
+
addEntry(entry: IHistoryEntry): void {
|
|
167
|
+
const updated = [...this.history, entry];
|
|
168
|
+
this.history =
|
|
169
|
+
updated.length > MAX_RENDERED_MESSAGES ? updated.slice(-MAX_RENDERED_MESSAGES) : updated;
|
|
170
|
+
this.notify();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
clearHistory(): void {
|
|
174
|
+
this.history = [];
|
|
175
|
+
this.debouncedStreamNotify.flush();
|
|
176
|
+
this.streamBuf = '';
|
|
177
|
+
this.streamingText = '';
|
|
178
|
+
this.activeTools = [];
|
|
179
|
+
this.notify();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Update pending prompt state */
|
|
183
|
+
setPendingPrompt(prompt: string | null): void {
|
|
184
|
+
this.pendingPrompt = prompt;
|
|
185
|
+
this.notify();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Set aborting flag */
|
|
189
|
+
setAborting(aborting: boolean): void {
|
|
190
|
+
this.isAborting = aborting;
|
|
191
|
+
this.notify();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Update context state */
|
|
195
|
+
setContextState(state: IContextState): void {
|
|
196
|
+
this.contextState = state;
|
|
197
|
+
this.notify();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
syncExecutionWorkspaceSnapshot(snapshot: IExecutionWorkspaceSnapshot): void {
|
|
201
|
+
const currentSelection = this.selectedExecutionEntryId;
|
|
202
|
+
const hasCurrentSelection =
|
|
203
|
+
currentSelection !== undefined &&
|
|
204
|
+
snapshot.entries.some((entry) => entry.id === currentSelection);
|
|
205
|
+
const selectedExecutionEntryId = hasCurrentSelection
|
|
206
|
+
? currentSelection
|
|
207
|
+
: (snapshot.selectedEntryId ?? snapshot.entries[0]?.id);
|
|
208
|
+
this.executionWorkspaceSnapshot = {
|
|
209
|
+
...snapshot,
|
|
210
|
+
...(selectedExecutionEntryId ? { selectedEntryId: selectedExecutionEntryId } : {}),
|
|
211
|
+
};
|
|
212
|
+
this.selectedExecutionEntryId = selectedExecutionEntryId;
|
|
213
|
+
this.notify();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
selectExecutionWorkspaceEntry(entryId: string): void {
|
|
217
|
+
if (!this.executionWorkspaceSnapshot?.entries.some((entry) => entry.id === entryId)) return;
|
|
218
|
+
this.selectedExecutionEntryId = entryId;
|
|
219
|
+
this.executionWorkspaceSnapshot = {
|
|
220
|
+
...this.executionWorkspaceSnapshot,
|
|
221
|
+
selectedEntryId: entryId,
|
|
222
|
+
};
|
|
223
|
+
this.notify();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { IInteractiveSession } from '@robota-sdk/agent-framework';
|
|
2
|
+
import type { IConfigurableTransport } from '@robota-sdk/agent-interface-transport';
|
|
3
|
+
import type { TUniversalValue } from '@robota-sdk/agent-core';
|
|
4
|
+
import { renderApp, type IRenderOptions } from './render.js';
|
|
5
|
+
|
|
6
|
+
export class TuiTransport implements IConfigurableTransport<IInteractiveSession> {
|
|
7
|
+
readonly name = 'tui';
|
|
8
|
+
readonly defaultEnabled = true;
|
|
9
|
+
readonly optionsSchema = {};
|
|
10
|
+
|
|
11
|
+
private readonly options: IRenderOptions;
|
|
12
|
+
|
|
13
|
+
constructor(options: IRenderOptions) {
|
|
14
|
+
this.options = options;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
attach(_session: IInteractiveSession): void {
|
|
18
|
+
// TuiTransport creates its own InteractiveSession internally via useInteractiveSession.
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async start(): Promise<void> {
|
|
22
|
+
await renderApp(this.options);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async stop(): Promise<void> {
|
|
26
|
+
// Ink exits when the user triggers shutdown from within the TUI.
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
validateOptions(_options: Record<string, TUniversalValue>): boolean {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/tui/types.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** UI-layer permission types for the Ink TUI */
|
|
2
|
+
|
|
3
|
+
import type { TToolArgs } from '@robota-sdk/agent-core';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Permission result: true (allow once), false (deny), or 'allow-session' (remember for session).
|
|
7
|
+
*/
|
|
8
|
+
export type TPermissionResult = boolean | 'allow-session';
|
|
9
|
+
|
|
10
|
+
export interface IPermissionRequest {
|
|
11
|
+
toolName: string;
|
|
12
|
+
toolArgs: TToolArgs;
|
|
13
|
+
resolve: (result: TPermissionResult) => void;
|
|
14
|
+
}
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { generateDiffLines, generateDiffLinesWithContext, extractEditDiff } from '../edit-diff.js';
|
|
6
|
+
|
|
7
|
+
describe('generateDiffLines', () => {
|
|
8
|
+
it('single line change: 1 remove + 1 add', () => {
|
|
9
|
+
const lines = generateDiffLines('hello', 'world');
|
|
10
|
+
expect(lines).toEqual([
|
|
11
|
+
{ type: 'remove', text: 'hello', lineNumber: 1 },
|
|
12
|
+
{ type: 'add', text: 'world', lineNumber: 1 },
|
|
13
|
+
]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('multi-line change: each old line is remove, each new line is add', () => {
|
|
17
|
+
const lines = generateDiffLines('line1\nline2\nline3', 'lineA\nlineB');
|
|
18
|
+
expect(lines.filter((l) => l.type === 'remove')).toHaveLength(3);
|
|
19
|
+
expect(lines.filter((l) => l.type === 'add')).toHaveLength(2);
|
|
20
|
+
expect(lines[0]).toEqual({ type: 'remove', text: 'line1', lineNumber: 1 });
|
|
21
|
+
expect(lines[1]).toEqual({ type: 'remove', text: 'line2', lineNumber: 2 });
|
|
22
|
+
expect(lines[2]).toEqual({ type: 'remove', text: 'line3', lineNumber: 3 });
|
|
23
|
+
expect(lines[3]).toEqual({ type: 'add', text: 'lineA', lineNumber: 1 });
|
|
24
|
+
expect(lines[4]).toEqual({ type: 'add', text: 'lineB', lineNumber: 2 });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('identical strings return empty array', () => {
|
|
28
|
+
expect(generateDiffLines('same', 'same')).toEqual([]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('empty old string (new content) returns only add lines', () => {
|
|
32
|
+
const lines = generateDiffLines('', 'new line');
|
|
33
|
+
expect(lines.filter((l) => l.type === 'remove')).toHaveLength(1); // '' splits to ['']
|
|
34
|
+
expect(lines.filter((l) => l.type === 'add')).toHaveLength(1);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('empty new string (deletion) returns only remove lines', () => {
|
|
38
|
+
const lines = generateDiffLines('old line', '');
|
|
39
|
+
expect(lines.filter((l) => l.type === 'remove')).toHaveLength(1);
|
|
40
|
+
expect(lines.filter((l) => l.type === 'add')).toHaveLength(1); // '' splits to ['']
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('marks ALL old lines as remove and ALL new lines as add (no smart diff)', () => {
|
|
44
|
+
// Even when some lines are the same, the function does not detect unchanged lines
|
|
45
|
+
const lines = generateDiffLines('shared\nold', 'shared\nnew');
|
|
46
|
+
expect(lines).toEqual([
|
|
47
|
+
{ type: 'remove', text: 'shared', lineNumber: 1 },
|
|
48
|
+
{ type: 'remove', text: 'old', lineNumber: 2 },
|
|
49
|
+
{ type: 'add', text: 'shared', lineNumber: 1 },
|
|
50
|
+
{ type: 'add', text: 'new', lineNumber: 2 },
|
|
51
|
+
]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('default startLine is 1', () => {
|
|
55
|
+
const lines = generateDiffLines('a', 'b');
|
|
56
|
+
expect(lines[0]).toEqual({ type: 'remove', text: 'a', lineNumber: 1 });
|
|
57
|
+
expect(lines[1]).toEqual({ type: 'add', text: 'b', lineNumber: 1 });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('custom startLine produces correct line numbers', () => {
|
|
61
|
+
const lines = generateDiffLines('a', 'b', 42);
|
|
62
|
+
expect(lines[0]).toEqual({ type: 'remove', text: 'a', lineNumber: 42 });
|
|
63
|
+
expect(lines[1]).toEqual({ type: 'add', text: 'b', lineNumber: 42 });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('multi-line with startLine: remove lines get startLine, startLine+1, etc.', () => {
|
|
67
|
+
const lines = generateDiffLines('line1\nline2\nline3', 'lineA\nlineB', 10);
|
|
68
|
+
expect(lines[0]).toEqual({ type: 'remove', text: 'line1', lineNumber: 10 });
|
|
69
|
+
expect(lines[1]).toEqual({ type: 'remove', text: 'line2', lineNumber: 11 });
|
|
70
|
+
expect(lines[2]).toEqual({ type: 'remove', text: 'line3', lineNumber: 12 });
|
|
71
|
+
expect(lines[3]).toEqual({ type: 'add', text: 'lineA', lineNumber: 10 });
|
|
72
|
+
expect(lines[4]).toEqual({ type: 'add', text: 'lineB', lineNumber: 11 });
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('extractEditDiff', () => {
|
|
77
|
+
it('Edit tool with valid args returns file and lines', () => {
|
|
78
|
+
const result = extractEditDiff('Edit', {
|
|
79
|
+
file_path: '/src/index.ts',
|
|
80
|
+
old_string: 'hello',
|
|
81
|
+
new_string: 'world',
|
|
82
|
+
});
|
|
83
|
+
expect(result).not.toBeNull();
|
|
84
|
+
expect(result!.file).toBe('/src/index.ts');
|
|
85
|
+
expect(result!.lines).toEqual([
|
|
86
|
+
{ type: 'remove', text: 'hello', lineNumber: 1 },
|
|
87
|
+
{ type: 'add', text: 'world', lineNumber: 1 },
|
|
88
|
+
]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('non-Edit tool returns null', () => {
|
|
92
|
+
expect(
|
|
93
|
+
extractEditDiff('Read', {
|
|
94
|
+
file_path: '/src/index.ts',
|
|
95
|
+
old_string: 'a',
|
|
96
|
+
new_string: 'b',
|
|
97
|
+
}),
|
|
98
|
+
).toBeNull();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('missing toolArgs returns null', () => {
|
|
102
|
+
expect(extractEditDiff('Edit')).toBeNull();
|
|
103
|
+
expect(extractEditDiff('Edit', undefined)).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('missing file_path returns null', () => {
|
|
107
|
+
expect(
|
|
108
|
+
extractEditDiff('Edit', {
|
|
109
|
+
old_string: 'a',
|
|
110
|
+
new_string: 'b',
|
|
111
|
+
}),
|
|
112
|
+
).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('missing old_string returns null', () => {
|
|
116
|
+
expect(
|
|
117
|
+
extractEditDiff('Edit', {
|
|
118
|
+
file_path: '/src/index.ts',
|
|
119
|
+
new_string: 'b',
|
|
120
|
+
}),
|
|
121
|
+
).toBeNull();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('missing new_string returns null', () => {
|
|
125
|
+
expect(
|
|
126
|
+
extractEditDiff('Edit', {
|
|
127
|
+
file_path: '/src/index.ts',
|
|
128
|
+
old_string: 'a',
|
|
129
|
+
}),
|
|
130
|
+
).toBeNull();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('identical old_string and new_string returns null', () => {
|
|
134
|
+
expect(
|
|
135
|
+
extractEditDiff('Edit', {
|
|
136
|
+
file_path: '/src/index.ts',
|
|
137
|
+
old_string: 'same',
|
|
138
|
+
new_string: 'same',
|
|
139
|
+
}),
|
|
140
|
+
).toBeNull();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('handles camelCase field names (filePath, oldString, newString)', () => {
|
|
144
|
+
const result = extractEditDiff('Edit', {
|
|
145
|
+
filePath: '/src/index.ts',
|
|
146
|
+
oldString: 'old',
|
|
147
|
+
newString: 'new',
|
|
148
|
+
});
|
|
149
|
+
expect(result).not.toBeNull();
|
|
150
|
+
expect(result!.file).toBe('/src/index.ts');
|
|
151
|
+
expect(result!.lines).toHaveLength(2);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('handles snake_case field names (file_path, old_string, new_string)', () => {
|
|
155
|
+
const result = extractEditDiff('Edit', {
|
|
156
|
+
file_path: '/src/main.ts',
|
|
157
|
+
old_string: 'foo',
|
|
158
|
+
new_string: 'bar',
|
|
159
|
+
});
|
|
160
|
+
expect(result).not.toBeNull();
|
|
161
|
+
expect(result!.file).toBe('/src/main.ts');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('prefers snake_case over camelCase when both present', () => {
|
|
165
|
+
const result = extractEditDiff('Edit', {
|
|
166
|
+
file_path: '/snake.ts',
|
|
167
|
+
filePath: '/camel.ts',
|
|
168
|
+
old_string: 'a',
|
|
169
|
+
oldString: 'x',
|
|
170
|
+
new_string: 'b',
|
|
171
|
+
newString: 'y',
|
|
172
|
+
});
|
|
173
|
+
expect(result).not.toBeNull();
|
|
174
|
+
expect(result!.file).toBe('/snake.ts');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('accepts optional startLine parameter', () => {
|
|
178
|
+
const result = extractEditDiff(
|
|
179
|
+
'Edit',
|
|
180
|
+
{
|
|
181
|
+
file_path: '/src/index.ts',
|
|
182
|
+
old_string: 'hello',
|
|
183
|
+
new_string: 'world',
|
|
184
|
+
},
|
|
185
|
+
5,
|
|
186
|
+
);
|
|
187
|
+
expect(result).not.toBeNull();
|
|
188
|
+
expect(result!.lines).toEqual([
|
|
189
|
+
{ type: 'remove', text: 'hello', lineNumber: 5 },
|
|
190
|
+
{ type: 'add', text: 'world', lineNumber: 5 },
|
|
191
|
+
]);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('defaults startLine to 1 when not provided', () => {
|
|
195
|
+
const result = extractEditDiff('Edit', {
|
|
196
|
+
file_path: '/src/index.ts',
|
|
197
|
+
old_string: 'a',
|
|
198
|
+
new_string: 'b',
|
|
199
|
+
});
|
|
200
|
+
expect(result).not.toBeNull();
|
|
201
|
+
expect(result!.lines[0].lineNumber).toBe(1);
|
|
202
|
+
expect(result!.lines[1].lineNumber).toBe(1);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// ─── Regression tests: context lines, absolute line numbers, startLine resolution ───
|
|
207
|
+
|
|
208
|
+
describe('generateDiffLinesWithContext', () => {
|
|
209
|
+
let tmpDir: string;
|
|
210
|
+
|
|
211
|
+
afterEach(() => {
|
|
212
|
+
if (tmpDir) {
|
|
213
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
function makeTempFile(content: string): string {
|
|
218
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'edit-diff-test-'));
|
|
219
|
+
const filePath = join(tmpDir, 'test.ts');
|
|
220
|
+
writeFileSync(filePath, content, 'utf-8');
|
|
221
|
+
return filePath;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
it('should include 3 context lines before the change', () => {
|
|
225
|
+
// 10-line file, edit line 5 (replaced already in file)
|
|
226
|
+
const fileLines = Array.from({ length: 10 }, (_, i) => `line${i + 1}`);
|
|
227
|
+
// Simulate: line5 was replaced with lineNEW
|
|
228
|
+
const modifiedLines = [...fileLines];
|
|
229
|
+
modifiedLines[4] = 'lineNEW';
|
|
230
|
+
const filePath = makeTempFile(modifiedLines.join('\n'));
|
|
231
|
+
|
|
232
|
+
const result = generateDiffLinesWithContext('line5', 'lineNEW', 5, filePath);
|
|
233
|
+
|
|
234
|
+
// Context before: line2, line3, line4
|
|
235
|
+
const contextBefore = result.filter((l) => l.type === 'context' && l.lineNumber < 5);
|
|
236
|
+
expect(contextBefore).toHaveLength(3);
|
|
237
|
+
expect(contextBefore[0]).toEqual({ type: 'context', text: 'line2', lineNumber: 2 });
|
|
238
|
+
expect(contextBefore[1]).toEqual({ type: 'context', text: 'line3', lineNumber: 3 });
|
|
239
|
+
expect(contextBefore[2]).toEqual({ type: 'context', text: 'line4', lineNumber: 4 });
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('should include 3 context lines after the change', () => {
|
|
243
|
+
const fileLines = Array.from({ length: 10 }, (_, i) => `line${i + 1}`);
|
|
244
|
+
const modifiedLines = [...fileLines];
|
|
245
|
+
modifiedLines[4] = 'lineNEW';
|
|
246
|
+
const filePath = makeTempFile(modifiedLines.join('\n'));
|
|
247
|
+
|
|
248
|
+
const result = generateDiffLinesWithContext('line5', 'lineNEW', 5, filePath);
|
|
249
|
+
|
|
250
|
+
// Context after: line6, line7, line8
|
|
251
|
+
const contextAfter = result.filter((l) => l.type === 'context' && l.lineNumber > 5);
|
|
252
|
+
expect(contextAfter).toHaveLength(3);
|
|
253
|
+
expect(contextAfter[0]).toEqual({ type: 'context', text: 'line6', lineNumber: 6 });
|
|
254
|
+
expect(contextAfter[1]).toEqual({ type: 'context', text: 'line7', lineNumber: 7 });
|
|
255
|
+
expect(contextAfter[2]).toEqual({ type: 'context', text: 'line8', lineNumber: 8 });
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('should handle edit at start of file (no lines before)', () => {
|
|
259
|
+
const fileLines = Array.from({ length: 5 }, (_, i) => `line${i + 1}`);
|
|
260
|
+
const modifiedLines = [...fileLines];
|
|
261
|
+
modifiedLines[0] = 'lineNEW';
|
|
262
|
+
const filePath = makeTempFile(modifiedLines.join('\n'));
|
|
263
|
+
|
|
264
|
+
const result = generateDiffLinesWithContext('line1', 'lineNEW', 1, filePath);
|
|
265
|
+
|
|
266
|
+
const contextBefore = result.filter((l) => l.type === 'context' && l.lineNumber < 1);
|
|
267
|
+
expect(contextBefore).toHaveLength(0);
|
|
268
|
+
|
|
269
|
+
// Context after: line2, line3, line4
|
|
270
|
+
const contextAfter = result.filter((l) => l.type === 'context' && l.lineNumber > 1);
|
|
271
|
+
expect(contextAfter).toHaveLength(3);
|
|
272
|
+
expect(contextAfter[0]).toEqual({ type: 'context', text: 'line2', lineNumber: 2 });
|
|
273
|
+
expect(contextAfter[1]).toEqual({ type: 'context', text: 'line3', lineNumber: 3 });
|
|
274
|
+
expect(contextAfter[2]).toEqual({ type: 'context', text: 'line4', lineNumber: 4 });
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('should handle edit at end of file (no lines after)', () => {
|
|
278
|
+
const fileLines = Array.from({ length: 5 }, (_, i) => `line${i + 1}`);
|
|
279
|
+
const modifiedLines = [...fileLines];
|
|
280
|
+
modifiedLines[4] = 'lineNEW';
|
|
281
|
+
const filePath = makeTempFile(modifiedLines.join('\n'));
|
|
282
|
+
|
|
283
|
+
const result = generateDiffLinesWithContext('line5', 'lineNEW', 5, filePath);
|
|
284
|
+
|
|
285
|
+
// Context before: line2, line3, line4
|
|
286
|
+
const contextBefore = result.filter((l) => l.type === 'context' && l.lineNumber < 5);
|
|
287
|
+
expect(contextBefore).toHaveLength(3);
|
|
288
|
+
|
|
289
|
+
// Context after: none (line5 is the last line)
|
|
290
|
+
const contextAfter = result.filter((l) => l.type === 'context' && l.lineNumber > 5);
|
|
291
|
+
expect(contextAfter).toHaveLength(0);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('context lines should have type "context"', () => {
|
|
295
|
+
const fileLines = Array.from({ length: 10 }, (_, i) => `line${i + 1}`);
|
|
296
|
+
const modifiedLines = [...fileLines];
|
|
297
|
+
modifiedLines[4] = 'lineNEW';
|
|
298
|
+
const filePath = makeTempFile(modifiedLines.join('\n'));
|
|
299
|
+
|
|
300
|
+
const result = generateDiffLinesWithContext('line5', 'lineNEW', 5, filePath);
|
|
301
|
+
|
|
302
|
+
const contextLines = result.filter((l) => l.type === 'context');
|
|
303
|
+
expect(contextLines.length).toBeGreaterThan(0);
|
|
304
|
+
for (const line of contextLines) {
|
|
305
|
+
expect(line.type).toBe('context');
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('starts readable context diffs with a hunk header', () => {
|
|
310
|
+
const fileLines = Array.from({ length: 10 }, (_, i) => `line${i + 1}`);
|
|
311
|
+
const modifiedLines = [...fileLines];
|
|
312
|
+
modifiedLines[4] = 'lineNEW';
|
|
313
|
+
const filePath = makeTempFile(modifiedLines.join('\n'));
|
|
314
|
+
|
|
315
|
+
const result = generateDiffLinesWithContext('line5', 'lineNEW', 5, filePath);
|
|
316
|
+
|
|
317
|
+
expect(result[0]).toEqual({
|
|
318
|
+
type: 'hunk',
|
|
319
|
+
text: '@@ -2,7 +2,7 @@',
|
|
320
|
+
lineNumber: 2,
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('all lines should have absolute lineNumber', () => {
|
|
325
|
+
const fileLines = Array.from({ length: 10 }, (_, i) => `line${i + 1}`);
|
|
326
|
+
const modifiedLines = [...fileLines];
|
|
327
|
+
modifiedLines[4] = 'lineNEW';
|
|
328
|
+
const filePath = makeTempFile(modifiedLines.join('\n'));
|
|
329
|
+
|
|
330
|
+
const result = generateDiffLinesWithContext('line5', 'lineNEW', 5, filePath);
|
|
331
|
+
|
|
332
|
+
for (const line of result) {
|
|
333
|
+
expect(typeof line.lineNumber).toBe('number');
|
|
334
|
+
expect(line.lineNumber).toBeGreaterThan(0);
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('returns diff without context when file is not readable', () => {
|
|
339
|
+
const result = generateDiffLinesWithContext('old', 'new', 5, '/nonexistent/path/file.ts');
|
|
340
|
+
|
|
341
|
+
// Should still return diff lines, just no context
|
|
342
|
+
expect(result.length).toBeGreaterThan(0);
|
|
343
|
+
const contextLines = result.filter((l) => l.type === 'context');
|
|
344
|
+
expect(contextLines).toHaveLength(0);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('identical strings return empty array', () => {
|
|
348
|
+
const filePath = makeTempFile('some content');
|
|
349
|
+
const result = generateDiffLinesWithContext('same', 'same', 1, filePath);
|
|
350
|
+
expect(result).toEqual([]);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
describe('extractEditDiff startLine resolution', () => {
|
|
355
|
+
let tmpDir: string;
|
|
356
|
+
|
|
357
|
+
afterEach(() => {
|
|
358
|
+
if (tmpDir) {
|
|
359
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
function makeTempFile(content: string): string {
|
|
364
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'edit-diff-test-'));
|
|
365
|
+
const filePath = join(tmpDir, 'test.ts');
|
|
366
|
+
writeFileSync(filePath, content, 'utf-8');
|
|
367
|
+
return filePath;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
it('should use provided startLine', () => {
|
|
371
|
+
const result = extractEditDiff(
|
|
372
|
+
'Edit',
|
|
373
|
+
{
|
|
374
|
+
file_path: '/nonexistent/file.ts',
|
|
375
|
+
old_string: 'hello',
|
|
376
|
+
new_string: 'world',
|
|
377
|
+
},
|
|
378
|
+
42,
|
|
379
|
+
);
|
|
380
|
+
expect(result).not.toBeNull();
|
|
381
|
+
const removeLines = result!.lines.filter((l) => l.type === 'remove');
|
|
382
|
+
expect(removeLines[0].lineNumber).toBe(42);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('should resolve startLine from file when not provided', () => {
|
|
386
|
+
// Create file where newStr appears at line 4
|
|
387
|
+
const content = 'line1\nline2\nline3\nREPLACED\nline5';
|
|
388
|
+
const filePath = makeTempFile(content);
|
|
389
|
+
|
|
390
|
+
const result = extractEditDiff('Edit', {
|
|
391
|
+
file_path: filePath,
|
|
392
|
+
old_string: 'original',
|
|
393
|
+
new_string: 'REPLACED',
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
expect(result).not.toBeNull();
|
|
397
|
+
// newStr "REPLACED" is at line 4 in the file
|
|
398
|
+
const removeLines = result!.lines.filter((l) => l.type === 'remove');
|
|
399
|
+
expect(removeLines[0].lineNumber).toBe(4);
|
|
400
|
+
const addLines = result!.lines.filter((l) => l.type === 'add');
|
|
401
|
+
expect(addLines[0].lineNumber).toBe(4);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('should fall back to line 1 when file not readable', () => {
|
|
405
|
+
const result = extractEditDiff('Edit', {
|
|
406
|
+
file_path: '/nonexistent/path/file.ts',
|
|
407
|
+
old_string: 'a',
|
|
408
|
+
new_string: 'b',
|
|
409
|
+
});
|
|
410
|
+
expect(result).not.toBeNull();
|
|
411
|
+
expect(result!.lines[0].lineNumber).toBe(1);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('should fall back to line 1 when newString not found in file', () => {
|
|
415
|
+
const content = 'line1\nline2\nline3';
|
|
416
|
+
const filePath = makeTempFile(content);
|
|
417
|
+
|
|
418
|
+
const result = extractEditDiff('Edit', {
|
|
419
|
+
file_path: filePath,
|
|
420
|
+
old_string: 'something',
|
|
421
|
+
new_string: 'NOT_IN_FILE_ANYWHERE',
|
|
422
|
+
});
|
|
423
|
+
expect(result).not.toBeNull();
|
|
424
|
+
expect(result!.lines[0].lineNumber).toBe(1);
|
|
425
|
+
});
|
|
426
|
+
});
|