Skip to content

Commit 567676a

Browse files
authored
Preserve ACP agent pane working directories across sessions (#744)
* Preserve host workspace cwd for ACP sessions * Prefer source pane cwd for ACP sessions * Separate ACP and helper working directories * Preserve normalized cwd across helper sessions * Add packaged agent pane cwd coverage
1 parent 0c2062b commit 567676a

10 files changed

Lines changed: 428 additions & 54 deletions

File tree

src/cascadia/TerminalApp/TerminalPage.cpp

Lines changed: 39 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
#include "../inc/AgentRegistry.h"
2323
#include "../inc/AgentPolicy.h"
2424
#include "../inc/AgentPaneBackend.h"
25+
#include "../inc/AgentSourceUtils.h"
2526
#include "../TerminalSettingsAppAdapterLib/TerminalSettings.h"
2627
#include "../inc/CustomModelProviderUtils.h"
2728
#include "AgentPaneContent.h"
@@ -1599,11 +1600,9 @@ namespace winrt::TerminalApp::implementation
15991600
}
16001601
if (activeCwd.empty())
16011602
{
1602-
wchar_t homePath[MAX_PATH];
1603-
if (GetEnvironmentVariableW(L"USERPROFILE", homePath, MAX_PATH) > 0)
1604-
{
1605-
activeCwd = winrt::hstring{ homePath };
1606-
}
1603+
activeCwd = winrt::hstring{
1604+
::Microsoft::Terminal::AgentSource::ReadEnvironmentVariable(L"USERPROFILE")
1605+
};
16071606
}
16081607
if (!activeCwd.empty())
16091608
{
@@ -3114,9 +3113,10 @@ namespace winrt::TerminalApp::implementation
31143113
}
31153114
}
31163115

3117-
// Resolve cwd. Priority matches the legacy spawn:
3118-
// a) VirtualWorkingDirectory (CLI-remoted commands like `wt agent`)
3119-
// b) Active pane CWD of THIS tab (from shell integration / OSC 9;9)
3116+
// Resolve the source-aware ACP cwd and the Win32 helper launch cwd separately:
3117+
// a) Active pane CWD of THIS tab (pre-seeded from its starting directory,
3118+
// then updated by shell integration / OSC 9;9)
3119+
// b) VirtualWorkingDirectory (CLI-remoted commands like `wt agent`)
31203120
// c) Profile's configured starting directory
31213121
// d) User's home directory
31223122
//
@@ -3127,43 +3127,46 @@ namespace winrt::TerminalApp::implementation
31273127
// the helper would start in the wrong directory (autofix and
31283128
// agent context would attribute to the wrong project). Reading
31293129
// directly from `tab` resolves to whichever pane is active on
3130-
// this specific tab. If shell integration hasn't reported a cwd
3131-
// yet (common for a just-spawned background tab) we fall through
3132-
// to (c)/(d) below.
3133-
winrt::hstring startingDirectory = _WindowProperties.VirtualWorkingDirectory();
3134-
if (startingDirectory.empty())
3135-
{
3136-
if (const auto activeControl = tab->GetActiveTerminalControl())
3137-
{
3138-
startingDirectory = activeControl.WorkingDirectory();
3139-
}
3140-
}
3141-
if (startingDirectory.empty())
3142-
{
3143-
if (sourceProfile)
3144-
{
3145-
startingDirectory = sourceProfile.EvaluatedStartingDirectory();
3146-
}
3147-
}
3148-
if (startingDirectory.empty())
3130+
// this specific tab. For a WSL agent, that source cwd may be POSIX and
3131+
// must not become the Windows wta-helper process's starting directory.
3132+
// The pane cwd must also win over the window cwd for agent context: deferred
3133+
// pre-warm runs after startup actions restore that property to the
3134+
// launcher directory, which is System32 for an AUMID activation.
3135+
winrt::hstring paneDirectory;
3136+
if (const auto activeControl = tab->GetActiveTerminalControl())
3137+
{
3138+
paneDirectory = activeControl.WorkingDirectory();
3139+
}
3140+
const auto windowDirectory = _WindowProperties.VirtualWorkingDirectory();
3141+
winrt::hstring profileDirectory;
3142+
if (sourceProfile)
31493143
{
3150-
wchar_t homePath[MAX_PATH];
3151-
if (GetEnvironmentVariableW(L"USERPROFILE", homePath, MAX_PATH) > 0)
3152-
{
3153-
startingDirectory = winrt::hstring{ homePath };
3154-
}
3144+
profileDirectory = sourceProfile.EvaluatedStartingDirectory();
31553145
}
3156-
if (effectiveAgentSource == L"wsl" && !startingDirectory.empty())
3146+
const winrt::hstring homeDirectory{
3147+
::Microsoft::Terminal::AgentSource::ReadEnvironmentVariable(L"USERPROFILE")
3148+
};
3149+
const auto resolvedWorkingDirectories = ::Microsoft::Terminal::AgentSource::ResolveAgentAndHelperWorkingDirectories(
3150+
effectiveAgentSource == L"wsl",
3151+
std::wstring_view{ paneDirectory },
3152+
std::wstring_view{ windowDirectory },
3153+
std::wstring_view{ profileDirectory },
3154+
std::wstring_view{ homeDirectory },
3155+
[](const std::wstring_view candidate) {
3156+
const std::wstring path{ candidate };
3157+
return Utils::IsValidDirectory(path.c_str());
3158+
});
3159+
if (!resolvedWorkingDirectories.agent.empty())
31573160
{
3158-
appendHelperFlagValue(L"--agent-source-cwd", startingDirectory);
3161+
appendHelperFlagValue(L"--agent-source-cwd", resolvedWorkingDirectories.agent);
31593162
}
31603163

31613164
NewTerminalArgs args;
31623165
args.Commandline(winrt::hstring{ helperCmd });
31633166
args.Profile(globals.AiCoordinatorProfile());
3164-
if (!startingDirectory.empty())
3167+
if (!resolvedWorkingDirectories.helper.empty())
31653168
{
3166-
args.StartingDirectory(startingDirectory);
3169+
args.StartingDirectory(winrt::hstring{ resolvedWorkingDirectories.helper });
31673170
}
31683171

31693172
auto rawPane = _MakeTerminalPane(args, nullptr, nullptr);
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
#pragma once
5+
6+
#include <wil/win32_helpers.h>
7+
8+
#include <string>
9+
#include <string_view>
10+
#include <utility>
11+
12+
namespace Microsoft::Terminal::AgentSource
13+
{
14+
struct ResolvedWorkingDirectories
15+
{
16+
std::wstring agent;
17+
std::wstring helper;
18+
};
19+
20+
inline std::wstring ReadEnvironmentVariable(const wchar_t* name)
21+
{
22+
return wil::TryGetEnvironmentVariableW<std::wstring>(name);
23+
}
24+
25+
inline std::wstring ResolveCwd(
26+
const std::wstring_view paneCwd,
27+
const std::wstring_view windowCwd,
28+
const std::wstring_view profileCwd,
29+
const std::wstring_view homeCwd)
30+
{
31+
for (const auto candidate : { paneCwd, windowCwd, profileCwd, homeCwd })
32+
{
33+
if (!candidate.empty())
34+
{
35+
return std::wstring{ candidate };
36+
}
37+
}
38+
return {};
39+
}
40+
41+
template<typename IsWindowsDirectory>
42+
inline ResolvedWorkingDirectories ResolveAgentAndHelperWorkingDirectories(
43+
const bool agentRunsInWsl,
44+
const std::wstring_view paneCwd,
45+
const std::wstring_view windowCwd,
46+
const std::wstring_view profileCwd,
47+
const std::wstring_view homeCwd,
48+
IsWindowsDirectory&& isWindowsDirectory)
49+
{
50+
std::wstring helperCwd;
51+
for (const auto candidate : { paneCwd, windowCwd, profileCwd, homeCwd })
52+
{
53+
if (!candidate.empty() && isWindowsDirectory(candidate))
54+
{
55+
helperCwd = candidate;
56+
break;
57+
}
58+
}
59+
60+
auto agentCwd = agentRunsInWsl ?
61+
ResolveCwd(paneCwd, windowCwd, profileCwd, homeCwd) :
62+
helperCwd;
63+
return { std::move(agentCwd), std::move(helperCwd) };
64+
}
65+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
#include "precomp.h"
5+
6+
#include "../inc/AgentSourceUtils.h"
7+
8+
using namespace WEX::TestExecution;
9+
10+
namespace TerminalAppUnitTests
11+
{
12+
class AgentSourceUtilsTests
13+
{
14+
TEST_CLASS(AgentSourceUtilsTests);
15+
16+
TEST_METHOD(ReadEnvironmentVariableSupportsLongValues);
17+
TEST_METHOD(PrefersPaneCwdOverWindowLaunchCwd);
18+
TEST_METHOD(SeparatesAgentCwdFromHelperLaunchCwd);
19+
};
20+
21+
void AgentSourceUtilsTests::ReadEnvironmentVariableSupportsLongValues()
22+
{
23+
constexpr auto name = L"WT_AGENT_SOURCE_UTILS_LONG_ENV";
24+
SetLastError(ERROR_SUCCESS);
25+
const auto priorLength = GetEnvironmentVariableW(name, nullptr, 0);
26+
const auto priorMissing = priorLength == 0 && GetLastError() == ERROR_ENVVAR_NOT_FOUND;
27+
const auto priorValue = priorMissing ? std::wstring{} : Microsoft::Terminal::AgentSource::ReadEnvironmentVariable(name);
28+
const std::wstring expected(MAX_PATH + 32, L'x');
29+
VERIFY_WIN32_BOOL_SUCCEEDED(SetEnvironmentVariableW(name, expected.c_str()));
30+
const auto cleanup = wil::scope_exit([=]() {
31+
VERIFY_WIN32_BOOL_SUCCEEDED(SetEnvironmentVariableW(name, priorMissing ? nullptr : priorValue.c_str()));
32+
});
33+
34+
VERIFY_ARE_EQUAL(expected, Microsoft::Terminal::AgentSource::ReadEnvironmentVariable(name));
35+
}
36+
37+
void AgentSourceUtilsTests::PrefersPaneCwdOverWindowLaunchCwd()
38+
{
39+
namespace AgentSource = Microsoft::Terminal::AgentSource;
40+
VERIFY_ARE_EQUAL(
41+
std::wstring{ L"C:\\work" },
42+
AgentSource::ResolveCwd(
43+
L"C:\\work",
44+
L"C:\\Windows\\System32",
45+
L"C:\\profile",
46+
L"C:\\Users\\user"));
47+
VERIFY_ARE_EQUAL(
48+
std::wstring{ L"C:\\window" },
49+
AgentSource::ResolveCwd({}, L"C:\\window", L"C:\\profile", L"C:\\Users\\user"));
50+
VERIFY_ARE_EQUAL(
51+
std::wstring{ L"C:\\profile" },
52+
AgentSource::ResolveCwd({}, {}, L"C:\\profile", L"C:\\Users\\user"));
53+
VERIFY_ARE_EQUAL(
54+
std::wstring{ L"C:\\Users\\user" },
55+
AgentSource::ResolveCwd({}, {}, {}, L"C:\\Users\\user"));
56+
VERIFY_ARE_EQUAL(std::wstring{}, AgentSource::ResolveCwd({}, {}, {}, {}));
57+
}
58+
59+
void AgentSourceUtilsTests::SeparatesAgentCwdFromHelperLaunchCwd()
60+
{
61+
namespace AgentSource = Microsoft::Terminal::AgentSource;
62+
const auto isWindowsDirectory = [](const std::wstring_view candidate) {
63+
return candidate == L"C:\\window" ||
64+
candidate == L"C:\\profile" ||
65+
candidate == L"C:\\Users\\user";
66+
};
67+
68+
const auto wsl = AgentSource::ResolveAgentAndHelperWorkingDirectories(
69+
true,
70+
L"/home/user/project",
71+
L"C:\\window",
72+
L"C:\\profile",
73+
L"C:\\Users\\user",
74+
isWindowsDirectory);
75+
VERIFY_ARE_EQUAL(std::wstring{ L"/home/user/project" }, wsl.agent);
76+
VERIFY_ARE_EQUAL(std::wstring{ L"C:\\window" }, wsl.helper);
77+
78+
const auto host = AgentSource::ResolveAgentAndHelperWorkingDirectories(
79+
false,
80+
L"/home/user/project",
81+
L"C:\\window",
82+
L"C:\\profile",
83+
L"C:\\Users\\user",
84+
isWindowsDirectory);
85+
VERIFY_ARE_EQUAL(std::wstring{ L"C:\\window" }, host.agent);
86+
VERIFY_ARE_EQUAL(std::wstring{ L"C:\\window" }, host.helper);
87+
88+
const auto noWindowsDirectory = [](std::wstring_view) { return false; };
89+
const auto wslWithoutHelperCwd = AgentSource::ResolveAgentAndHelperWorkingDirectories(
90+
true, L"/home/user/project", {}, {}, {}, noWindowsDirectory);
91+
VERIFY_ARE_EQUAL(std::wstring{ L"/home/user/project" }, wslWithoutHelperCwd.agent);
92+
VERIFY_ARE_EQUAL(std::wstring{}, wslWithoutHelperCwd.helper);
93+
94+
const auto hostWithoutWindowsCwd = AgentSource::ResolveAgentAndHelperWorkingDirectories(
95+
false, L"/home/user/project", {}, {}, {}, noWindowsDirectory);
96+
VERIFY_ARE_EQUAL(std::wstring{}, hostWithoutWindowsCwd.agent);
97+
VERIFY_ARE_EQUAL(std::wstring{}, hostWithoutWindowsCwd.helper);
98+
}
99+
}

src/cascadia/ut_app/TerminalApp.UnitTests.vcxproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
<ClCompile Include="FzfTests.cpp" />
2626
<ClCompile Include="AgentHooksStatusTests.cpp" />
2727
<ClCompile Include="AcpModelUtilsTests.cpp" />
28+
<ClCompile Include="AgentSourceUtilsTests.cpp" />
2829
<ClCompile Include="AgentUsageTests.cpp" />
2930
<ClCompile Include="BoundedDispatchQueueTests.cpp" />
3031
<ClCompile Include="CustomAgentIdTests.cpp" />

test/e2e/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ authenticated ACP agents. Current status (run on the Store package):
3434
| `Feature.CommandResolution.Tests.ps1` | PR #418: packaged WTA resolves PowerShell profile-only aliases to their real targets | 1 |
3535
| `Feature.SessionList.Tests.ps1` | session view (button + `/sessions` slash), session states, view switching (incl. draft-preservation), focus/restore | 13 (+1 skip) |
3636
| `Feature.NonAsciiCwd.Tests.ps1` | issue #641: a non-ASCII starting directory survives `wtcli` argv → COM → `CreateProcessW`, so the resume launch path connects and starts in that directory | 2 |
37+
| `Feature.AgentPaneCwd.Tests.ps1` | agent-pane source workspace reaches ACP `session/new` and remains stable across `/new` without a model prompt | 1 |
3738
| `Feature.AgentRestart.Tests.ps1` | agent restart after a settings change (/restart reconnects and answers) | 1 |
3839
| `Feature.ShellIntegration.Tests.ps1` | §3 shell-integration OSC 133 marks (success/failure, ParserError dedup, handled errors, WinPS 5.1 errors) + non-integrated cmd.exe safety | 6 |
3940
| `Feature.BashPromptIntegration.Tests.ps1` | PR #468: Bash `PROMPT_COMMAND` PS1 rewrites preserve D/A/B boundaries; non-IT hosts remain gated | 1 (Git Bash-gated) |

test/e2e/fixtures/Mock-AcpInteractionAgent.ps1

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,13 @@ while ($null -ne ($line = [Console]::In.ReadLine())) {
157157
$server = @($request.params.mcpServers) | Select-Object -First 1
158158
$sessionMcpServers[$sessionId] = $server
159159
Write-FixtureLog -Message "session/new|$sessionId|mcp_server=$([string]$server.name)"
160+
$cwdJson = if ($null -eq $request.params.cwd) {
161+
'null'
162+
}
163+
else {
164+
ConvertTo-Json -InputObject ([string]$request.params.cwd) -Compress
165+
}
166+
Write-FixtureLog -Message "session/new-cwd|$sessionId|$cwdJson"
160167
Send-AcpMessage @{
161168
jsonrpc = '2.0'
162169
id = $request.id

0 commit comments

Comments
 (0)