Skip to content

Commit 4108f30

Browse files
committed
fix: track terminal working directory changes across commands
- Add lastUsedTerminal property to Task to persist terminal reference - Update executeCommandTool to use terminal current directory for subsequent commands - Resolve relative paths based on terminal cwd instead of task cwd - Add comprehensive tests for directory tracking scenarios Fixes #7567
1 parent 63b71d8 commit 4108f30

File tree

3 files changed

+304
-3
lines changed

3 files changed

+304
-3
lines changed

src/core/task/Task.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ import { RepoPerTaskCheckpointService } from "../../services/checkpoints"
6666
// integrations
6767
import { DiffViewProvider } from "../../integrations/editor/DiffViewProvider"
6868
import { findToolName, formatContentBlockToMarkdown } from "../../integrations/misc/export-markdown"
69-
import { RooTerminalProcess } from "../../integrations/terminal/types"
69+
import { RooTerminalProcess, RooTerminal } from "../../integrations/terminal/types"
7070
import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry"
7171

7272
// utils
@@ -224,6 +224,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
224224
fileContextTracker: FileContextTracker
225225
urlContentFetcher: UrlContentFetcher
226226
terminalProcess?: RooTerminalProcess
227+
lastUsedTerminal?: RooTerminal
227228

228229
// Computer User
229230
browserSession: BrowserSession
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
/**
2+
* Test for GitHub Issue #7567: Regression: task directory is not followed on terminal requests
3+
*
4+
* This test verifies that when a command changes directory (e.g., `cd subdir`),
5+
* subsequent commands use the terminal's updated working directory instead of
6+
* spawning a new terminal.
7+
*/
8+
9+
import * as path from "path"
10+
import * as fs from "fs/promises"
11+
import { vi, describe, it, expect, beforeEach } from "vitest"
12+
13+
import { ExecuteCommandOptions, executeCommand } from "../executeCommandTool"
14+
import { TerminalRegistry } from "../../../integrations/terminal/TerminalRegistry"
15+
import { Terminal } from "../../../integrations/terminal/Terminal"
16+
import type { RooTerminalCallbacks, RooTerminal } from "../../../integrations/terminal/types"
17+
18+
// Mock fs to control directory existence checks
19+
vi.mock("fs/promises")
20+
21+
// Mock TerminalRegistry to control terminal creation
22+
vi.mock("../../../integrations/terminal/TerminalRegistry")
23+
24+
// Mock Terminal class
25+
vi.mock("../../../integrations/terminal/Terminal")
26+
27+
describe("Terminal Directory Tracking (Issue #7567)", () => {
28+
let mockTask: any
29+
let mockTerminal: any
30+
let mockProcess: any
31+
let mockProvider: any
32+
33+
beforeEach(() => {
34+
vi.clearAllMocks()
35+
36+
// Mock fs.access to simulate directory existence
37+
;(fs.access as any).mockResolvedValue(undefined)
38+
39+
// Create mock provider
40+
mockProvider = {
41+
postMessageToWebview: vi.fn(),
42+
getState: vi.fn().mockResolvedValue({
43+
terminalOutputLineLimit: 500,
44+
terminalShellIntegrationDisabled: false,
45+
}),
46+
}
47+
48+
// Create mock task
49+
mockTask = {
50+
cwd: "/test/project",
51+
taskId: "test-task-123",
52+
providerRef: {
53+
deref: vi.fn().mockResolvedValue(mockProvider),
54+
},
55+
say: vi.fn().mockResolvedValue(undefined),
56+
terminalProcess: undefined,
57+
lastUsedTerminal: undefined, // This is the new property we're testing
58+
}
59+
60+
// Create mock process that resolves immediately
61+
mockProcess = Promise.resolve()
62+
mockProcess.continue = vi.fn()
63+
mockProcess.abort = vi.fn()
64+
65+
// Create mock terminal with getCurrentWorkingDirectory method
66+
mockTerminal = {
67+
provider: "vscode",
68+
id: 1,
69+
initialCwd: "/test/project",
70+
getCurrentWorkingDirectory: vi.fn().mockReturnValue("/test/project"),
71+
isClosed: vi.fn().mockReturnValue(false),
72+
runCommand: vi.fn().mockReturnValue(mockProcess),
73+
terminal: {
74+
show: vi.fn(),
75+
shellIntegration: {
76+
cwd: { fsPath: "/test/project" },
77+
},
78+
},
79+
}
80+
})
81+
82+
it("should track terminal working directory changes across multiple commands", async () => {
83+
// Setup: First command will be `cd subdir`
84+
const initialCwd = "/test/project"
85+
const subdirCwd = "/test/project/subdir"
86+
87+
// Mock terminal behavior for first command (cd subdir)
88+
let currentWorkingDir = initialCwd
89+
mockTerminal.getCurrentWorkingDirectory.mockImplementation(() => currentWorkingDir)
90+
91+
// First command execution
92+
mockTerminal.runCommand.mockImplementationOnce((command: string, callbacks: RooTerminalCallbacks) => {
93+
// Simulate directory change
94+
currentWorkingDir = subdirCwd
95+
mockTerminal.terminal.shellIntegration.cwd.fsPath = subdirCwd
96+
97+
setTimeout(() => {
98+
callbacks.onCompleted("", mockProcess)
99+
callbacks.onShellExecutionComplete({ exitCode: 0 }, mockProcess)
100+
}, 0)
101+
return mockProcess
102+
})
103+
;(TerminalRegistry.getOrCreateTerminal as any).mockResolvedValueOnce(mockTerminal)
104+
105+
// Execute first command: cd subdir
106+
const options1: ExecuteCommandOptions = {
107+
executionId: "test-123",
108+
command: "cd subdir",
109+
terminalShellIntegrationDisabled: false,
110+
terminalOutputLineLimit: 500,
111+
}
112+
113+
const [rejected1, result1] = await executeCommand(mockTask, options1)
114+
115+
// Verify first command
116+
expect(rejected1).toBe(false)
117+
expect(TerminalRegistry.getOrCreateTerminal).toHaveBeenCalledWith(initialCwd, mockTask.taskId, "vscode")
118+
expect(mockTask.lastUsedTerminal).toBe(mockTerminal) // Terminal should be stored
119+
expect(result1).toContain(`within working directory '${subdirCwd}'`)
120+
121+
// Reset mocks for second command
122+
vi.mocked(TerminalRegistry.getOrCreateTerminal).mockClear()
123+
124+
// Second command execution - should use the updated directory
125+
mockTerminal.runCommand.mockImplementationOnce((command: string, callbacks: RooTerminalCallbacks) => {
126+
setTimeout(() => {
127+
callbacks.onCompleted("command_from_subdir output", mockProcess)
128+
callbacks.onShellExecutionComplete({ exitCode: 0 }, mockProcess)
129+
}, 0)
130+
return mockProcess
131+
})
132+
;(TerminalRegistry.getOrCreateTerminal as any).mockResolvedValueOnce(mockTerminal)
133+
134+
// Execute second command: command_from_subdir
135+
const options2: ExecuteCommandOptions = {
136+
executionId: "test-456",
137+
command: "command_from_subdir",
138+
terminalShellIntegrationDisabled: false,
139+
terminalOutputLineLimit: 500,
140+
}
141+
142+
const [rejected2, result2] = await executeCommand(mockTask, options2)
143+
144+
// Verify second command uses the subdirectory
145+
expect(rejected2).toBe(false)
146+
// IMPORTANT: This should be called with subdirCwd, not initialCwd
147+
// This is the key assertion - the second command should use the updated directory from the first command
148+
expect(TerminalRegistry.getOrCreateTerminal).toHaveBeenCalledWith(subdirCwd, mockTask.taskId, "vscode")
149+
expect(result2).toContain(`within working directory '${subdirCwd}'`)
150+
})
151+
152+
it("should handle relative paths based on terminal's current directory", async () => {
153+
// Setup: Terminal is in a subdirectory
154+
const subdirCwd = "/test/project/src"
155+
mockTask.lastUsedTerminal = mockTerminal
156+
mockTerminal.getCurrentWorkingDirectory.mockReturnValue(subdirCwd)
157+
158+
mockTerminal.runCommand.mockImplementation((command: string, callbacks: RooTerminalCallbacks) => {
159+
setTimeout(() => {
160+
callbacks.onCompleted("Command output", mockProcess)
161+
callbacks.onShellExecutionComplete({ exitCode: 0 }, mockProcess)
162+
}, 0)
163+
return mockProcess
164+
})
165+
166+
// Create a new mock terminal that will be returned with the resolved path
167+
const newMockTerminal = {
168+
...mockTerminal,
169+
getCurrentWorkingDirectory: vi.fn().mockReturnValue(path.resolve(subdirCwd, "components")),
170+
}
171+
172+
const resolvedPath = path.resolve(subdirCwd, "components")
173+
;(TerminalRegistry.getOrCreateTerminal as any).mockResolvedValue(newMockTerminal)
174+
175+
// Execute command with relative custom cwd
176+
const options: ExecuteCommandOptions = {
177+
executionId: "test-789",
178+
command: "ls",
179+
customCwd: "components", // Relative to terminal's current directory
180+
terminalShellIntegrationDisabled: false,
181+
terminalOutputLineLimit: 500,
182+
}
183+
184+
const [rejected, result] = await executeCommand(mockTask, options)
185+
186+
// Verify it resolves relative to terminal's cwd, not task's cwd
187+
expect(rejected).toBe(false)
188+
expect(TerminalRegistry.getOrCreateTerminal).toHaveBeenCalledWith(resolvedPath, mockTask.taskId, "vscode")
189+
expect(result).toContain(`within working directory '${resolvedPath.toPosix()}'`)
190+
})
191+
192+
it("should fallback to task cwd when terminal is closed", async () => {
193+
// Setup: Previous terminal exists but is closed
194+
mockTask.lastUsedTerminal = mockTerminal
195+
mockTerminal.isClosed.mockReturnValue(true)
196+
197+
// Create a new terminal for this test
198+
const newMockTerminal = {
199+
...mockTerminal,
200+
id: 2,
201+
isClosed: vi.fn().mockReturnValue(false),
202+
}
203+
204+
newMockTerminal.runCommand.mockImplementation((command: string, callbacks: RooTerminalCallbacks) => {
205+
setTimeout(() => {
206+
callbacks.onCompleted("Command output", mockProcess)
207+
callbacks.onShellExecutionComplete({ exitCode: 0 }, mockProcess)
208+
}, 0)
209+
return mockProcess
210+
})
211+
;(TerminalRegistry.getOrCreateTerminal as any).mockResolvedValue(newMockTerminal)
212+
213+
const options: ExecuteCommandOptions = {
214+
executionId: "test-999",
215+
command: "echo test",
216+
terminalShellIntegrationDisabled: false,
217+
terminalOutputLineLimit: 500,
218+
}
219+
220+
const [rejected, result] = await executeCommand(mockTask, options)
221+
222+
// Verify it falls back to task.cwd when terminal is closed
223+
expect(rejected).toBe(false)
224+
expect(TerminalRegistry.getOrCreateTerminal).toHaveBeenCalledWith(mockTask.cwd, mockTask.taskId, "vscode")
225+
expect(mockTask.lastUsedTerminal).toBe(newMockTerminal) // Should update to new terminal
226+
})
227+
228+
it("should handle sequence of cd commands correctly", async () => {
229+
// This test simulates: cd dir1 && command1, then cd ../dir2 && command2
230+
let currentWorkingDir = "/test/project"
231+
232+
mockTerminal.getCurrentWorkingDirectory.mockImplementation(() => currentWorkingDir)
233+
;(TerminalRegistry.getOrCreateTerminal as any).mockResolvedValue(mockTerminal)
234+
235+
// First command: cd dir1 && command1
236+
mockTerminal.runCommand.mockImplementationOnce((command: string, callbacks: RooTerminalCallbacks) => {
237+
currentWorkingDir = "/test/project/dir1"
238+
setTimeout(() => {
239+
callbacks.onCompleted("", mockProcess)
240+
callbacks.onShellExecutionComplete({ exitCode: 0 }, mockProcess)
241+
}, 0)
242+
return mockProcess
243+
})
244+
245+
const options1: ExecuteCommandOptions = {
246+
executionId: "test-seq-1",
247+
command: "cd dir1 && command1",
248+
terminalShellIntegrationDisabled: false,
249+
terminalOutputLineLimit: 500,
250+
}
251+
252+
await executeCommand(mockTask, options1)
253+
expect(mockTask.lastUsedTerminal).toBe(mockTerminal)
254+
255+
// Clear mock for next call
256+
vi.mocked(TerminalRegistry.getOrCreateTerminal).mockClear()
257+
258+
// Second command: cd ../dir2 && command2
259+
mockTerminal.runCommand.mockImplementationOnce((command: string, callbacks: RooTerminalCallbacks) => {
260+
currentWorkingDir = "/test/project/dir2"
261+
setTimeout(() => {
262+
callbacks.onCompleted("", mockProcess)
263+
callbacks.onShellExecutionComplete({ exitCode: 0 }, mockProcess)
264+
}, 0)
265+
return mockProcess
266+
})
267+
268+
const options2: ExecuteCommandOptions = {
269+
executionId: "test-seq-2",
270+
command: "cd ../dir2 && command2",
271+
terminalShellIntegrationDisabled: false,
272+
terminalOutputLineLimit: 500,
273+
}
274+
275+
const [rejected2, result2] = await executeCommand(mockTask, options2)
276+
277+
// Should request terminal with dir1 path (from previous command)
278+
expect(TerminalRegistry.getOrCreateTerminal).toHaveBeenCalledWith(
279+
"/test/project/dir1",
280+
mockTask.taskId,
281+
"vscode",
282+
)
283+
// But result should show dir2 (after cd command)
284+
expect(result2).toContain("within working directory '/test/project/dir2'")
285+
})
286+
})

src/core/tools/executeCommandTool.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,11 +160,22 @@ export async function executeCommand(
160160
let workingDir: string
161161

162162
if (!customCwd) {
163-
workingDir = task.cwd
163+
// If we have a previously used terminal, use its current working directory
164+
// This ensures that cd commands are properly tracked across execute_command calls
165+
if (task.lastUsedTerminal && !task.lastUsedTerminal.isClosed()) {
166+
workingDir = task.lastUsedTerminal.getCurrentWorkingDirectory()
167+
} else {
168+
workingDir = task.cwd
169+
}
164170
} else if (path.isAbsolute(customCwd)) {
165171
workingDir = customCwd
166172
} else {
167-
workingDir = path.resolve(task.cwd, customCwd)
173+
// Resolve relative paths against the last terminal's cwd or task cwd
174+
const basePath =
175+
task.lastUsedTerminal && !task.lastUsedTerminal.isClosed()
176+
? task.lastUsedTerminal.getCurrentWorkingDirectory()
177+
: task.cwd
178+
workingDir = path.resolve(basePath, customCwd)
168179
}
169180

170181
try {
@@ -240,6 +251,9 @@ export async function executeCommand(
240251

241252
const terminal = await TerminalRegistry.getOrCreateTerminal(workingDir, task.taskId, terminalProvider)
242253

254+
// Store the terminal reference in the task for future commands
255+
task.lastUsedTerminal = terminal
256+
243257
if (terminal instanceof Terminal) {
244258
terminal.terminal.show(true)
245259

0 commit comments

Comments
 (0)