From 2d6d92d406c56254e313ed5026a1c04d7fc1499c Mon Sep 17 00:00:00 2001 From: "Yuandi.Zhang" Date: Fri, 24 Jul 2026 17:55:37 +0800 Subject: [PATCH 1/6] Restore persisted agent sessions and panes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/cascadia/TerminalApp/AgentPaneContent.h | 5 + src/cascadia/TerminalApp/Pane.cpp | 7 +- src/cascadia/TerminalApp/Pane.h | 3 +- src/cascadia/TerminalApp/Tab.cpp | 19 +- src/cascadia/TerminalApp/Tab.h | 4 +- src/cascadia/TerminalApp/TabManagement.cpp | 80 +++- src/cascadia/TerminalApp/TerminalPage.cpp | 434 +++++++++++++++++- src/cascadia/TerminalApp/TerminalPage.h | 30 +- src/cascadia/TerminalApp/TerminalPage.idl | 4 + .../TerminalSettingsModel/ActionArgs.h | 82 ++++ .../TerminalSettingsModel/ActionArgs.idl | 9 + .../UnitTests_SettingsModel/CommandTests.cpp | 46 ++ .../TerminalProtocolComServer.cpp | 37 +- .../TerminalProtocolComServer.h | 1 + tools/wta/src/app.rs | 57 ++- tools/wta/src/cli_tests.rs | 8 + tools/wta/src/main.rs | 11 + 17 files changed, 814 insertions(+), 23 deletions(-) diff --git a/src/cascadia/TerminalApp/AgentPaneContent.h b/src/cascadia/TerminalApp/AgentPaneContent.h index 1a8e86c0d8..f11b520462 100644 --- a/src/cascadia/TerminalApp/AgentPaneContent.h +++ b/src/cascadia/TerminalApp/AgentPaneContent.h @@ -65,6 +65,8 @@ namespace winrt::TerminalApp::implementation // Update the cached pane-position. Fires StateChanged so the // bottom bar can refresh its toggle-icon orientation. void SetAgentPanePosition(const winrt::hstring& position); + void SetDurableSessionId(const winrt::hstring& sessionId) noexcept { _durableSessionId = sessionId; } + winrt::hstring DurableSessionId() const noexcept { return _durableSessionId; } // --- Cross-window drag rename plumbing --- // When a tab is dragged into this window, the source side has stashed // the originating tab's StableId keyed by ContentId. The target @@ -148,6 +150,9 @@ namespace winrt::TerminalApp::implementation winrt::hstring _agentModel{}; winrt::hstring _agentState{}; winrt::hstring _agentBackend{}; + // Empty for a pre-warmed session that has not processed a prompt. + // WTA only projects an ID after a real turn starts or a session is loaded. + winrt::hstring _durableSessionId{}; // When true, the bar replaces " " with "Agent sessions" // and hides the agent logo. Driven by TerminalPage::OnAgentStateChanged diff --git a/src/cascadia/TerminalApp/Pane.cpp b/src/cascadia/TerminalApp/Pane.cpp index 3b6c09f9db..978627328f 100644 --- a/src/cascadia/TerminalApp/Pane.cpp +++ b/src/cascadia/TerminalApp/Pane.cpp @@ -1355,10 +1355,13 @@ void Pane::UpdateSettings(const CascadiaSettings& settings) // - splitType: How the pane should be attached // Return Value: // - the new reference to the child created from the current pane. -std::shared_ptr Pane::AttachPane(std::shared_ptr pane, SplitDirection splitType) +std::shared_ptr Pane::AttachPane(std::shared_ptr pane, SplitDirection splitType, const float splitSize) { // Splice the new pane into the tree - const auto [first, _] = _Split(splitType, .5, pane); + const auto requestedSize = splitType == SplitDirection::Left || splitType == SplitDirection::Up ? + 1.0f - splitSize : + splitSize; + const auto [first, _] = _Split(splitType, requestedSize, pane); // If the new pane has a child that was the focus, re-focus it // to steal focus from the currently focused pane. diff --git a/src/cascadia/TerminalApp/Pane.h b/src/cascadia/TerminalApp/Pane.h index e74980c51e..edcb87a022 100644 --- a/src/cascadia/TerminalApp/Pane.h +++ b/src/cascadia/TerminalApp/Pane.h @@ -134,7 +134,8 @@ class Pane : public std::enable_shared_from_this void Close(); std::shared_ptr AttachPane(std::shared_ptr pane, - winrt::Microsoft::Terminal::Settings::Model::SplitDirection splitType); + winrt::Microsoft::Terminal::Settings::Model::SplitDirection splitType, + float splitSize = 0.5f); std::shared_ptr DetachPane(std::shared_ptr pane); bool RepositionAgentPane(winrt::Microsoft::Terminal::Settings::Model::SplitDirection splitDirection); diff --git a/src/cascadia/TerminalApp/Tab.cpp b/src/cascadia/TerminalApp/Tab.cpp index 844f17abcd..1df7c69606 100644 --- a/src/cascadia/TerminalApp/Tab.cpp +++ b/src/cascadia/TerminalApp/Tab.cpp @@ -774,7 +774,8 @@ namespace winrt::TerminalApp::implementation // Return Value: // - a pair of (the Pane wrapping the original tree, the new Pane) std::pair, std::shared_ptr> Tab::SplitPaneAtRoot(SplitDirection splitType, - std::shared_ptr pane) + std::shared_ptr pane, + const float splitSize) { ASSERT_UI_THREAD(); @@ -803,7 +804,7 @@ namespace winrt::TerminalApp::implementation // _firstChild and the new pane becomes _secondChild. Because _rootPane // is modified in-place, Content() (which points to _rootPane->GetRootElement()) // remains valid without any re-parenting. - auto originalTree = _rootPane->AttachPane(pane, splitType); + auto originalTree = _rootPane->AttachPane(pane, splitType, splitSize); // The split created a new wrapper pane for the original tree. Attach // Tab event handlers so that focus changes are tracked correctly — @@ -2521,6 +2522,20 @@ namespace winrt::TerminalApp::implementation return nullptr; } + float Tab::AgentPaneSize() const noexcept + { + if (const auto agentPane = FindAgentPane()) + { + if (const auto parent = _rootPane->_FindParentOfPane(agentPane)) + { + return parent->_firstChild == agentPane ? + parent->_desiredSplitPosition : + 1.0f - parent->_desiredSplitPosition; + } + } + return 0.5f; + } + // Hide the agent pane without destroying it. The pane stays in the tab // tree (so its TermControl + conpty + wta-helper child remain alive), // but its parent split is rewritten so the sibling occupies the full diff --git a/src/cascadia/TerminalApp/Tab.h b/src/cascadia/TerminalApp/Tab.h index 4343c879e1..db6ae4cd60 100644 --- a/src/cascadia/TerminalApp/Tab.h +++ b/src/cascadia/TerminalApp/Tab.h @@ -42,7 +42,8 @@ namespace winrt::TerminalApp::implementation std::shared_ptr newPane); std::pair, std::shared_ptr> SplitPaneAtRoot(winrt::Microsoft::Terminal::Settings::Model::SplitDirection splitType, - std::shared_ptr newPane); + std::shared_ptr newPane, + float splitSize = 0.5f); void ToggleSplitOrientation(); void UpdateIcon(const winrt::hstring& iconPath, const winrt::Microsoft::Terminal::Settings::Model::IconStyle iconStyle); @@ -109,6 +110,7 @@ namespace winrt::TerminalApp::implementation winrt::TerminalApp::AgentPaneContent FindAgentPaneContent() const; // Returns the Pane node hosting the AgentPaneContent, or nullptr. std::shared_ptr FindAgentPane() const; + float AgentPaneSize() const noexcept; // Hide the agent pane without detaching it from the tree. The pane // stays alive (so TermControl + conpty + wta-helper survive), but diff --git a/src/cascadia/TerminalApp/TabManagement.cpp b/src/cascadia/TerminalApp/TabManagement.cpp index 982e35b35a..696efb3db8 100644 --- a/src/cascadia/TerminalApp/TabManagement.cpp +++ b/src/cascadia/TerminalApp/TabManagement.cpp @@ -92,7 +92,30 @@ namespace winrt::TerminalApp::implementation // This call to _MakePane won't return nullptr, we already checked that // case above with the _maybeElevate call. - _CreateNewTabFromPane(_MakePane(newContentArgs, nullptr), -1, openInBackground); + const auto newTab = _CreateNewTabFromPane(_MakePane(newContentArgs, nullptr), -1, openInBackground); + if (_settings.GlobalSettings().FirstWindowPreference() == FirstWindowPreference::PersistedLayoutAndContent) + { + if (const auto newTerminalArgs = newContentArgs.try_as(); + newTerminalArgs && !newTerminalArgs.AgentPaneAgent().empty()) + { + if (const auto tabImpl = _GetTabImpl(newTab)) + { + const auto paneSize = newTerminalArgs.AgentPaneSize(); + _pendingDurableAgentPaneRestores.insert_or_assign( + tabImpl->StableId(), + _PendingDurableAgentPaneRestore{ + _currentStartupActionBatchId, + winrt::to_string(newTerminalArgs.AgentPaneSessionId()), + newTerminalArgs.AgentPaneAgent(), + newTerminalArgs.AgentPaneCustomCommand(), + winrt::to_string(newTerminalArgs.StartingDirectory()), + newTerminalArgs.AgentPaneView() == L"sessions", + newTerminalArgs.AgentPaneOpen(), + newTerminalArgs.AgentPanePosition(), + paneSize > 0.0f && paneSize < 1.0f ? paneSize : 0.5f }); + } + } + } return S_OK; } CATCH_RETURN(); @@ -369,6 +392,13 @@ namespace winrt::TerminalApp::implementation // unrelated new-tab pre-warm). if (agentLeavesSeen == 0) { + if (self->_pendingDurableAgentPaneRestores.contains(newTabId)) + { + _agentPaneLog( + std::string{ "_InitializeTab(deferred): durable agent pane restore pending on tab " } + + winrt::to_string(newTabId)); + return; + } _agentPaneLog( std::string{ "_InitializeTab(deferred): pre-warming stashed agent pane on tab " } + winrt::to_string(newTabId)); @@ -592,6 +622,54 @@ namespace winrt::TerminalApp::implementation _previouslyClosedPanesAndTabs.emplace_back(args); } + void TerminalPage::_AddDurableSessionMetadata(Tab* const tab, std::vector& actions) + { + for (const auto& action : actions) + { + INewContentArgs contentArgs{ nullptr }; + if (const auto args = action.Args().try_as()) + { + contentArgs = args.ContentArgs(); + } + else if (const auto args = action.Args().try_as()) + { + contentArgs = args.ContentArgs(); + } + + if (const auto terminalArgs = contentArgs.try_as()) + { + if (const auto binding = _paneAgentSessions.find(terminalArgs.SessionId()); + binding != _paneAgentSessions.end()) + { + terminalArgs.AgentSessionId(binding->second.sessionId); + terminalArgs.AgentSessionAgent(binding->second.agent); + } + } + } + + if (const auto agentContent = tab->FindAgentPaneContent()) + { + for (const auto& action : actions) + { + if (const auto newTabArgs = action.Args().try_as()) + { + if (const auto terminalArgs = newTabArgs.ContentArgs().try_as()) + { + const auto content = winrt::get_self(agentContent); + terminalArgs.AgentPaneSessionId(content->DurableSessionId()); + terminalArgs.AgentPaneAgent(_GetDurableAgentIdentity(tab)); + terminalArgs.AgentPaneCustomCommand(_GetDurableAgentCustomCommand(tab)); + terminalArgs.AgentPaneView(agentContent.IsSessionsView() ? L"sessions" : L"chat"); + terminalArgs.AgentPaneOpen(!tab->HasStashedAgentPane()); + terminalArgs.AgentPanePosition(content->GetAgentPanePosition()); + terminalArgs.AgentPaneSize(tab->AgentPaneSize()); + break; + } + } + } + } + } + // Method Description: // - If this window has a name, persist its current workspace layout to // ApplicationState. Intended to be called from the close-pane / close-tab diff --git a/src/cascadia/TerminalApp/TerminalPage.cpp b/src/cascadia/TerminalApp/TerminalPage.cpp index 376d088953..d8116d9e88 100644 --- a/src/cascadia/TerminalApp/TerminalPage.cpp +++ b/src/cascadia/TerminalApp/TerminalPage.cpp @@ -231,6 +231,116 @@ namespace clipboard namespace winrt::TerminalApp::implementation { + static winrt::hstring _BuildAgentResumeCommandline(const std::string_view cliSource, const std::string_view agentSessionId) + { + if (agentSessionId.empty() || + agentSessionId.starts_with("sidekick-") || + agentSessionId.size() > 256 || + !std::all_of(agentSessionId.begin(), agentSessionId.end(), [](const unsigned char ch) { + return std::isalnum(ch) || ch == '-' || ch == '_' || ch == '.' || ch == ':'; + })) + { + return {}; + } + + auto cli = std::string{ cliSource }; + std::wstring wslDistro; + if (const auto backend = ::Microsoft::Terminal::Settings::Model::AgentPaneBackend::Parse(winrt::to_hstring(cliSource))) + { + cli = til::u16u8(backend->agentId); + if (backend->source == ::Microsoft::Terminal::Settings::Model::AgentPaneBackendSource::Wsl) + { + wslDistro = backend->wslDistro; + } + } + std::transform(cli.begin(), cli.end(), cli.begin(), [](const unsigned char ch) { + return static_cast(std::tolower(ch)); + }); + + std::wstring_view executable; + std::wstring_view resumeArg; + if (cli == "copilot") + { + executable = L"copilot"; + resumeArg = L"--resume"; + } + else if (cli == "claude") + { + executable = L"claude"; + resumeArg = L"--resume"; + } + else if (cli == "codex") + { + executable = L"codex"; + resumeArg = L"resume"; + } + else if (cli == "gemini") + { + executable = L"gemini"; + resumeArg = L"--resume"; + } + else if (cli == "opencode") + { + executable = L"opencode"; + resumeArg = L"--session"; + } + else + { + return {}; + } + + if (!wslDistro.empty()) + { + std::wstring command{ L"wsl.exe -d " }; + QuoteAndEscapeCommandlineArg(wslDistro, command); + command.append(L" -- bash -lc "); + const auto inner = fmt::format( + FMT_COMPILE(L"{} {} {}"), + executable, + resumeArg, + winrt::to_hstring(agentSessionId)); + QuoteAndEscapeCommandlineArg(inner, command); + return winrt::hstring{ command }; + } + + return winrt::hstring{ fmt::format( + FMT_COMPILE(L"cmd.exe /d /s /c \"{} {} {}\""), + executable, + resumeArg, + winrt::to_hstring(agentSessionId)) }; + } + + static winrt::hstring _DurableShellAgentIdentity(const TermControl& control, const std::string_view agent) + { + if (control) + { + constexpr std::wstring_view wslPrefix{ L"wsl:" }; + const std::wstring_view shellName{ control.ShellName() }; + if (shellName.starts_with(wslPrefix) && shellName.size() > wslPrefix.size()) + { + return winrt::hstring{ ::Microsoft::Terminal::Settings::Model::AgentPaneBackend::Wsl( + shellName.substr(wslPrefix.size()), + winrt::to_hstring(agent)) }; + } + } + return winrt::to_hstring(agent); + } + + static std::optional _TryParsePaneSessionId(const std::string_view value) noexcept + { + try + { + const auto text = winrt::to_hstring(value); + return value.starts_with('{') ? + ::Microsoft::Console::Utils::GuidFromString(text.c_str()) : + ::Microsoft::Console::Utils::GuidFromPlainString(text.c_str()); + } + catch (...) + { + return std::nullopt; + } + } + TerminalPage::TerminalPage(TerminalApp::WindowProperties properties, const TerminalApp::ContentManager& manager) : _tabs{ winrt::single_threaded_observable_vector() }, _mruTabs{ winrt::single_threaded_observable_vector() }, @@ -1230,6 +1340,46 @@ namespace winrt::TerminalApp::implementation return _GetAgentSourceProfile(tab); } + winrt::hstring TerminalPage::_GetDurableAgentIdentity(Tab* const tab) const + { + if (tab->HasAgentOverride()) + { + if (tab->AgentSourceOverride() == L"wsl") + { + return winrt::hstring{ ::Microsoft::Terminal::Settings::Model::AgentPaneBackend::Wsl( + tab->AgentWslDistroOverride(), + tab->AgentIdOverride()) }; + } + return tab->AgentIdOverride(); + } + + if (const auto sourceProfile = _ResolveAgentSourceProfile(tab->get_strong(), _settings)) + { + if (const auto backend = ::Microsoft::Terminal::Settings::Model::AgentPaneBackend::Parse( + std::wstring_view{ sourceProfile.AgentPaneBackend() })) + { + return backend->source == ::Microsoft::Terminal::Settings::Model::AgentPaneBackendSource::Wsl ? + winrt::hstring{ ::Microsoft::Terminal::Settings::Model::AgentPaneBackend::Wsl(backend->wslDistro, backend->agentId) } : + winrt::hstring{ backend->agentId }; + } + } + + return _settings.GlobalSettings().EffectiveAcpAgent(); + } + + winrt::hstring TerminalPage::_GetDurableAgentCustomCommand(Tab* const tab) const + { + if (tab->HasAgentOverride()) + { + return tab->AgentCustomCommandOverride(); + } + + const auto& globals = _settings.GlobalSettings(); + return _IsCustomAgentId(globals.EffectiveAcpAgent()) ? + globals.AcpCustomCommand() : + winrt::hstring{}; + } + // Resolve the effective delegate agent name from structured settings. static winrt::hstring _ResolveEffectiveDelegateAgent( const winrt::Microsoft::Terminal::Settings::Model::GlobalAppSettings& globals) @@ -1922,7 +2072,9 @@ namespace winrt::TerminalApp::implementation bool autoStash, std::string_view initialLoadSessionId, std::string_view initialLoadCwd, - std::wstring_view initialAuthAgent) + std::wstring_view initialAuthAgent, + std::wstring_view initialPanePosition, + float initialPaneSize) { if (!tab || !tab->GetActiveTerminalControl()) { @@ -2180,6 +2332,10 @@ namespace winrt::TerminalApp::implementation { helperCmd.append(L" --initial-view sessions"); } + if (!initialPanePosition.empty()) + { + appendHelperFlagValue(L"--initial-pane-position", initialPanePosition); + } if (autoStash) { // Pre-warm path: the helper is spawned for a pane that C++ @@ -2289,7 +2445,7 @@ namespace winrt::TerminalApp::implementation if (const auto agentContent = newPane->GetContent().try_as()) { _WireAgentPaneEvents(agentContent, tab); - agentContent.SetAgentPanePosition(globals.AgentPanePosition()); + agentContent.SetAgentPanePosition(initialPanePosition.empty() ? globals.AgentPanePosition() : winrt::hstring{ initialPanePosition }); } { @@ -2307,8 +2463,10 @@ namespace winrt::TerminalApp::implementation // scope_exit so a successful return doesn't double-release. sharedAcquired.release(); - const auto splitDirection = _AgentPanePositionToSplitDirection(globals.AgentPanePosition()); - tab->SplitPaneAtRoot(splitDirection, newPane); + const auto panePosition = initialPanePosition.empty() ? globals.AgentPanePosition() : winrt::hstring{ initialPanePosition }; + const auto splitDirection = _AgentPanePositionToSplitDirection(panePosition); + const auto splitSize = initialPaneSize > 0.0f && initialPaneSize < 1.0f ? initialPaneSize : 0.5f; + tab->SplitPaneAtRoot(splitDirection, newPane, splitSize); if (autoStash) { @@ -3399,6 +3557,7 @@ namespace winrt::TerminalApp::implementation safe_void_coroutine TerminalPage::ProcessStartupActions(std::vector actions, const winrt::hstring cwd, const winrt::hstring env) { const auto strong = get_strong(); + const auto startupActionBatchId = ++_nextStartupActionBatchId; // If the caller provided a CWD, "switch" to that directory, then switch // back once we're done. @@ -3444,10 +3603,14 @@ namespace winrt::TerminalApp::implementation co_await wil::resume_foreground(Dispatcher(), CoreDispatcherPriority::Low); } + _currentStartupActionBatchId = startupActionBatchId; + auto clearBatchId = wil::scope_exit([&]() noexcept { _currentStartupActionBatchId = 0; }); _actionDispatch->DoAction(actions[i]); suspend = true; } + _RestorePendingDurableAgentPanes(startupActionBatchId); + // GH#6586: now that we're done processing all startup commands, // focus the active control. This will work as expected for both // commandline invocations and for `wt` action invocations. @@ -3460,6 +3623,50 @@ namespace winrt::TerminalApp::implementation } } + void TerminalPage::_RestorePendingDurableAgentPanes(const uint64_t startupActionBatchId) + { + for (auto it = _pendingDurableAgentPaneRestores.begin(); it != _pendingDurableAgentPaneRestores.end();) + { + if (it->second.startupActionBatchId != startupActionBatchId) + { + ++it; + continue; + } + + const auto targetTab = _FindTabByStableId(it->first); + if (!targetTab) + { + it = _pendingDurableAgentPaneRestores.erase(it); + continue; + } + + auto pending = std::move(it->second); + it = _pendingDurableAgentPaneRestores.erase(it); + if (const auto backend = ::Microsoft::Terminal::Settings::Model::AgentPaneBackend::Parse( + std::wstring_view{ pending.agent })) + { + targetTab->SetAgentOverride( + winrt::hstring{ backend->agentId }, + {}, + pending.customCommand, + backend->source == ::Microsoft::Terminal::Settings::Model::AgentPaneBackendSource::Wsl ? L"wsl" : L"host", + winrt::hstring{ backend->wslDistro }); + } + else + { + targetTab->SetAgentOverride(pending.agent, {}, pending.customCommand); + } + _AutoCreateHiddenAgentPaneShared(targetTab, + pending.sessionsView, + !pending.paneOpen, + pending.sessionId, + pending.cwd, + {}, + pending.panePosition, + pending.paneSize); + } + } + safe_void_coroutine TerminalPage::CreateTabFromConnection(ITerminalConnection connection) { const auto strong = get_strong(); @@ -4807,6 +5014,14 @@ namespace winrt::TerminalApp::implementation std::string logSuffix = " tab_id=" + winrt::to_string(tabId); + std::optional durableSessionId; + if (params.isMember("agent_session_id")) + { + durableSessionId = params["agent_session_id"].isString() ? + winrt::to_hstring(params["agent_session_id"].asString()) : + winrt::hstring{}; + } + std::optional wantOpen; if (params.isMember("pane_open") && params["pane_open"].isBool()) { @@ -4840,10 +5055,14 @@ namespace winrt::TerminalApp::implementation } _agentPaneLog(std::string{ "OnAgentStateChanged:" } + logSuffix); - // Apply view to the existing AgentPaneContent if any. - if (view.has_value()) + // Apply projected durable identity and view to the existing content. + if (const auto agentContent = targetTab->FindAgentPaneContent()) { - if (const auto agentContent = targetTab->FindAgentPaneContent()) + if (durableSessionId.has_value()) + { + winrt::get_self(agentContent)->SetDurableSessionId(*durableSessionId); + } + if (view.has_value()) { agentContent.SetSessionsView(*view == "sessions"); } @@ -5452,6 +5671,87 @@ namespace winrt::TerminalApp::implementation _RequestAgentStateForTab(newTab, std::nullopt, /*pane_open*/ true); } + void TerminalPage::OnPaneAgentSessionChanged(hstring eventJson) + { + if (_settings.GlobalSettings().FirstWindowPreference() != FirstWindowPreference::PersistedLayoutAndContent) + { + return; + } + + Json::Value evt; + Json::CharReaderBuilder reader; + std::string errors; + std::istringstream stream{ winrt::to_string(eventJson) }; + if (!Json::parseFromStream(reader, stream, &evt, &errors) || + !evt.isMember("params") || + !evt["params"].isObject()) + { + return; + } + + const auto& params = evt["params"]; + const auto paneSessionId = _TryParsePaneSessionId(params.get("pane_id", "").asString()); + if (!paneSessionId) + { + return; + } + + const auto eventName = params.get("event", "").asString(); + const auto agentSessionId = params.get("agent_session_id", "").asString(); + const auto agent = params.get("cli_source", "").asString(); + const bool sessionEnded = eventName == "agent.session.stopped" || + eventName == "agent.session.end"; + const bool sessionStarted = eventName == "agent.session.started" || + eventName == "agent.session.start" || + eventName == "agent.prompt.submit"; + if (!sessionEnded && !sessionStarted) + { + return; + } + if (!sessionEnded && + (agent.empty() || + agentSessionId.empty() || + agentSessionId.starts_with("sidekick-") || + _BuildAgentResumeCommandline(agent, agentSessionId).empty())) + { + return; + } + + for (const auto& tab : _tabs) + { + if (const auto tabImpl = _GetTabImpl(tab)) + { + if (const auto rootPane = tabImpl->GetRootPane(); + rootPane) + { + const auto pane = rootPane->FindPaneBySessionId(*paneSessionId); + if (!pane) + { + continue; + } + if (sessionEnded) + { + if (const auto binding = _paneAgentSessions.find(*paneSessionId); + binding != _paneAgentSessions.end() && + (agentSessionId.empty() || binding->second.sessionId == winrt::to_hstring(agentSessionId))) + { + _paneAgentSessions.erase(binding); + } + } + else + { + _paneAgentSessions.insert_or_assign( + *paneSessionId, + _PaneAgentSession{ + winrt::to_hstring(agentSessionId), + _DurableShellAgentIdentity(pane->GetTerminalControl(), agent) }); + } + return; + } + } + } + } + // Method Description: // - Called when the users pressed keyBindings while CommandPaletteElement is open. // - As of GH#8480, this is also bound to the TabRowControl's KeyUp event. @@ -5813,8 +6113,7 @@ namespace winrt::TerminalApp::implementation // `IsAutoFixPolicyLocked()` returns true only // for the Blocked policy state; Forced-on // states the user can change fall through. - if (page->_settings.GlobalSettings().IsAutoFixPolicyLocked()) - return; + const auto autoFixPolicyLocked = page->_settings.GlobalSettings().IsAutoFixPolicyLocked(); // Early filter: WTA only acts on osc:133;* // and AgentEvent payloads. Every other VT @@ -5861,6 +6160,46 @@ namespace winrt::TerminalApp::implementation agentParams.isMember("event") && agentParams["event"].isString()) { + if (page->_settings.GlobalSettings().FirstWindowPreference() == FirstWindowPreference::PersistedLayoutAndContent) + { + const auto eventName = agentParams["event"].asString(); + const auto agentSessionId = agentParams.get("agent_session_id", "").asString(); + if (const auto connection = term2.Connection()) + { + const auto paneSessionId = connection.SessionId(); + if (eventName == "agent.session.stopped" || eventName == "agent.session.end") + { + if (const auto binding = page->_paneAgentSessions.find(paneSessionId); + binding != page->_paneAgentSessions.end() && + (agentSessionId.empty() || binding->second.sessionId == winrt::to_hstring(agentSessionId))) + { + page->_paneAgentSessions.erase(binding); + } + } + else if ((eventName == "agent.session.started" || + eventName == "agent.session.start" || + eventName == "agent.prompt.submit") && + !agentSessionId.empty() && + !agentSessionId.starts_with("sidekick-")) + { + const auto agent = agentParams.get("cli_source", "").asString(); + if (!_BuildAgentResumeCommandline(agent, agentSessionId).empty()) + { + page->_paneAgentSessions.insert_or_assign( + paneSessionId, + _PaneAgentSession{ + winrt::to_hstring(agentSessionId), + _DurableShellAgentIdentity(term2, agent) }); + } + } + } + } + + if (autoFixPolicyLocked) + { + return; + } + agentParams["pane_id"] = paneIdStr; if (!tabIdStr.empty()) { @@ -5882,6 +6221,17 @@ namespace winrt::TerminalApp::implementation } // isOsc133 path — forward as vt_sequence. + if (seqStr.starts_with("osc:133;A")) + { + if (const auto connection = term2.Connection()) + { + page->_paneAgentSessions.erase(connection.SessionId()); + } + } + if (autoFixPolicyLocked) + { + return; + } // Detection gate: when the user turned error // detection off ("don't access my shell"), drop // the OSC 133 command marks here — no Detected @@ -5994,6 +6344,13 @@ namespace winrt::TerminalApp::implementation const auto tabIdStr = term2 ? page->_FindTabIdForControl(term2) : std::string{}; + if (stateStr == "closed") + { + if (const auto paneSessionId = _TryParsePaneSessionId(paneIdStr)) + { + page->_paneAgentSessions.erase(*paneSessionId); + } + } Json::Value evt; evt["type"] = "event"; @@ -6281,7 +6638,36 @@ namespace winrt::TerminalApp::implementation for (auto tab : _tabs) { auto t = winrt::get_self(tab); - auto tabActions = t->BuildStartupActions(BuildStartupKind::Persist); + const auto persistDurableSessions = _settings.GlobalSettings().FirstWindowPreference() == FirstWindowPreference::PersistedLayoutAndContent; + std::vector tabActions; + if (persistDurableSessions && + t->GetRootPane()->IsAgentPane() && + t->GetRootPane()->GetLeafPaneCount() == 1) + { + // A root agent pane contains a helper command line tied to the + // old master pipe. Recreate a default shell root, then restore + // the agent pane from its durable metadata. + NewTerminalArgs shellArgs; + if (const auto control = t->GetRootPane()->GetTerminalControl()) + { + if (const auto cwd = control.WorkingDirectory(); Utils::IsValidDirectory(cwd.c_str())) + { + shellArgs.StartingDirectory(cwd); + } + } + ActionAndArgs newTabAction; + newTabAction.Action(ShortcutAction::NewTab); + newTabAction.Args(NewTabArgs{ shellArgs }); + tabActions.emplace_back(std::move(newTabAction)); + } + else + { + tabActions = t->BuildStartupActions(BuildStartupKind::Persist); + } + if (persistDurableSessions) + { + _AddDurableSessionMetadata(t, tabActions); + } actions.insert(actions.end(), std::make_move_iterator(tabActions.begin()), std::make_move_iterator(tabActions.end())); } @@ -7969,6 +8355,22 @@ namespace winrt::TerminalApp::implementation return resultPane; } + auto resumingAgentSession = false; + if (_settings.GlobalSettings().FirstWindowPreference() == FirstWindowPreference::PersistedLayoutAndContent && + newTerminalArgs && + !newTerminalArgs.AgentSessionId().empty() && + !newTerminalArgs.AgentSessionAgent().empty()) + { + if (const auto resumeCommandline = _BuildAgentResumeCommandline( + winrt::to_string(newTerminalArgs.AgentSessionAgent()), + winrt::to_string(newTerminalArgs.AgentSessionId())); + !resumeCommandline.empty()) + { + newTerminalArgs.Commandline(resumeCommandline); + resumingAgentSession = true; + } + } + Settings::TerminalSettingsCreateResult controlSettings{ nullptr }; Profile profile{ nullptr }; @@ -8001,6 +8403,14 @@ namespace winrt::TerminalApp::implementation const auto sessionId = controlSettings.DefaultSettings()->SessionId(); const auto hasSessionId = sessionId != winrt::guid{}; + if (resumingAgentSession && hasSessionId) + { + _paneAgentSessions.insert_or_assign( + sessionId, + _PaneAgentSession{ + newTerminalArgs.AgentSessionId(), + newTerminalArgs.AgentSessionAgent() }); + } TerminalConnection::ITerminalConnection connection{ nullptr }; if (existingConnection) @@ -8029,7 +8439,9 @@ namespace winrt::TerminalApp::implementation const auto control = _CreateNewControlAndContent(controlSettings, connection); - if (hasSessionId) + // Agent CLIs replay their own transcript. Replaying the terminal buffer + // as well would duplicate the conversation on every restore cycle. + if (hasSessionId && !resumingAgentSession) { using namespace std::string_view_literals; diff --git a/src/cascadia/TerminalApp/TerminalPage.h b/src/cascadia/TerminalApp/TerminalPage.h index 493b48bfa6..2038eff50d 100644 --- a/src/cascadia/TerminalApp/TerminalPage.h +++ b/src/cascadia/TerminalApp/TerminalPage.h @@ -256,6 +256,7 @@ namespace winrt::TerminalApp::implementation void OnCloseAgentPaneRequested(hstring eventJson); void OnAgentStateChanged(hstring eventJson); void OnResumeInNewAgentTabRequested(hstring eventJson); + void OnPaneAgentSessionChanged(hstring eventJson); void OnAgentChipTargetChanged(hstring eventJson); void OnRestartAgentStackRequested(hstring eventJson); void OnAgentPaneRestartRequested(hstring eventJson); @@ -496,6 +497,21 @@ namespace winrt::TerminalApp::implementation std::string cwd; }; std::unordered_map _pendingLoadSessions; + struct _PendingDurableAgentPaneRestore + { + uint64_t startupActionBatchId{ 0 }; + std::string sessionId; + winrt::hstring agent; + winrt::hstring customCommand; + std::string cwd; + bool sessionsView{ false }; + bool paneOpen{ false }; + winrt::hstring panePosition; + float paneSize{ 0.5f }; + }; + std::unordered_map _pendingDurableAgentPaneRestores; + uint64_t _nextStartupActionBatchId{ 0 }; + uint64_t _currentStartupActionBatchId{ 0 }; // Short-lived marks keyed by tab StableId: set whenever an agent // pane is torn down deliberately (Ctrl+C×2, settings rebuild, // /restart, recovery re-warm). `OnAgentPaneRestartRequested` @@ -562,7 +578,10 @@ namespace winrt::TerminalApp::implementation bool autoStash = false, std::string_view initialLoadSessionId = {}, std::string_view initialLoadCwd = {}, - std::wstring_view initialAuthAgent = {}); + std::wstring_view initialAuthAgent = {}, + std::wstring_view initialPanePosition = {}, + float initialPaneSize = 0.5f); + void _RestorePendingDurableAgentPanes(uint64_t startupActionBatchId); // Wraps the raw terminal pane's TerminalPaneContent in an // AgentPaneContent so the leaf renders the 36px XAML agent bar // above the wta TermControl + the bottom-bar below. @@ -672,6 +691,15 @@ namespace winrt::TerminalApp::implementation void _RemoveTab(const winrt::TerminalApp::Tab& tab, bool movingAway = false); safe_void_coroutine _RemoveTabs(const std::vector tabs); void _SaveWorkspaceIfNeeded(); + void _AddDurableSessionMetadata(Tab* tab, std::vector& actions); + winrt::hstring _GetDurableAgentIdentity(Tab* tab) const; + winrt::hstring _GetDurableAgentCustomCommand(Tab* tab) const; + struct _PaneAgentSession + { + winrt::hstring sessionId; + winrt::hstring agent; + }; + std::unordered_map _paneAgentSessions; void _InitializeTab(winrt::com_ptr newTabImpl, uint32_t insertPosition = -1, bool openInBackground = false); void _RegisterTerminalEvents(Microsoft::Terminal::Control::TermControl term); diff --git a/src/cascadia/TerminalApp/TerminalPage.idl b/src/cascadia/TerminalApp/TerminalPage.idl index 7a82a28d52..7b1e97f8ac 100644 --- a/src/cascadia/TerminalApp/TerminalPage.idl +++ b/src/cascadia/TerminalApp/TerminalPage.idl @@ -188,6 +188,10 @@ namespace TerminalApp // tab's StableId so wta calls ACP `session/load` for that tab. void OnResumeInNewAgentTabRequested(String eventJson); + // Inbound shell-agent hook event carrying the pane's WT_SESSION, + // agent CLI id, and durable agent session id. + void OnPaneAgentSessionChanged(String eventJson); + // Inbound event from WTA carrying // {method:"set_agent_chip_target",params:{tab_id, pane_session_id?}}. // Helper override for which pane in the tab gets the blue "Agent" diff --git a/src/cascadia/TerminalSettingsModel/ActionArgs.h b/src/cascadia/TerminalSettingsModel/ActionArgs.h index fc20bf6575..ce9ee035d8 100644 --- a/src/cascadia/TerminalSettingsModel/ActionArgs.h +++ b/src/cascadia/TerminalSettingsModel/ActionArgs.h @@ -389,10 +389,28 @@ namespace winrt::Microsoft::Terminal::Settings::Model::implementation PARTIAL_ACTION_ARG_BODY(NewTerminalArgs, NEW_TERMINAL_ARGS); ACTION_ARG(winrt::hstring, Type, L""); ACTION_ARG(winrt::guid, SessionId, winrt::guid{}); + ACTION_ARG(winrt::hstring, AgentSessionId, L""); + ACTION_ARG(winrt::hstring, AgentSessionAgent, L""); + ACTION_ARG(winrt::hstring, AgentPaneSessionId, L""); + ACTION_ARG(winrt::hstring, AgentPaneAgent, L""); + ACTION_ARG(winrt::hstring, AgentPaneCustomCommand, L""); + ACTION_ARG(winrt::hstring, AgentPaneView, L""); + ACTION_ARG(bool, AgentPaneOpen, false); + ACTION_ARG(winrt::hstring, AgentPanePosition, L""); + ACTION_ARG(float, AgentPaneSize, 0.0f); ACTION_ARG(bool, AppendCommandLine, false); ACTION_ARG(uint64_t, ContentId); static constexpr std::string_view SessionIdKey{ "sessionId" }; + static constexpr std::string_view AgentSessionKey{ "agentSession" }; + static constexpr std::string_view AgentSessionIdKey{ "agentSessionId" }; + static constexpr std::string_view AgentKey{ "agent" }; + static constexpr std::string_view AgentPaneKey{ "agentPane" }; + static constexpr std::string_view CustomCommandKey{ "customCommand" }; + static constexpr std::string_view ViewKey{ "view" }; + static constexpr std::string_view OpenKey{ "open" }; + static constexpr std::string_view PositionKey{ "position" }; + static constexpr std::string_view SizeKey{ "size" }; static constexpr std::string_view AppendCommandLineKey{ "appendCommandLine" }; static constexpr std::string_view ContentKey{ "__content" }; @@ -419,6 +437,15 @@ namespace winrt::Microsoft::Terminal::Settings::Model::implementation otherAsUs->_ColorScheme == _ColorScheme && otherAsUs->_Elevate == _Elevate && otherAsUs->_ReloadEnvironmentVariables == _ReloadEnvironmentVariables && + otherAsUs->_AgentSessionId == _AgentSessionId && + otherAsUs->_AgentSessionAgent == _AgentSessionAgent && + otherAsUs->_AgentPaneSessionId == _AgentPaneSessionId && + otherAsUs->_AgentPaneAgent == _AgentPaneAgent && + otherAsUs->_AgentPaneCustomCommand == _AgentPaneCustomCommand && + otherAsUs->_AgentPaneView == _AgentPaneView && + otherAsUs->_AgentPaneOpen == _AgentPaneOpen && + otherAsUs->_AgentPanePosition == _AgentPanePosition && + otherAsUs->_AgentPaneSize == _AgentPaneSize && otherAsUs->_ContentId == _ContentId; } return false; @@ -433,6 +460,21 @@ namespace winrt::Microsoft::Terminal::Settings::Model::implementation JsonUtils::GetValueForKey(json, ProfileIndexKey, args->_ProfileIndex); JsonUtils::GetValueForKey(json, ProfileKey, args->_Profile); JsonUtils::GetValueForKey(json, SessionIdKey, args->_SessionId); + if (const auto& agentSession = json[AgentSessionKey.data()]; agentSession.isObject()) + { + JsonUtils::GetValueForKey(agentSession, AgentSessionIdKey, args->_AgentSessionId); + JsonUtils::GetValueForKey(agentSession, AgentKey, args->_AgentSessionAgent); + } + if (const auto& agentPane = json[AgentPaneKey.data()]; agentPane.isObject()) + { + JsonUtils::GetValueForKey(agentPane, AgentSessionIdKey, args->_AgentPaneSessionId); + JsonUtils::GetValueForKey(agentPane, AgentKey, args->_AgentPaneAgent); + JsonUtils::GetValueForKey(agentPane, CustomCommandKey, args->_AgentPaneCustomCommand); + JsonUtils::GetValueForKey(agentPane, ViewKey, args->_AgentPaneView); + JsonUtils::GetValueForKey(agentPane, OpenKey, args->_AgentPaneOpen); + JsonUtils::GetValueForKey(agentPane, PositionKey, args->_AgentPanePosition); + JsonUtils::GetValueForKey(agentPane, SizeKey, args->_AgentPaneSize); + } JsonUtils::GetValueForKey(json, TabColorKey, args->_TabColor); JsonUtils::GetValueForKey(json, SuppressApplicationTitleKey, args->_SuppressApplicationTitle); JsonUtils::GetValueForKey(json, ColorSchemeKey, args->_ColorScheme); @@ -455,6 +497,28 @@ namespace winrt::Microsoft::Terminal::Settings::Model::implementation JsonUtils::SetValueForKey(json, ProfileIndexKey, args->_ProfileIndex); JsonUtils::SetValueForKey(json, ProfileKey, args->_Profile); JsonUtils::SetValueForKey(json, SessionIdKey, args->_SessionId); + if (!args->AgentSessionId().empty() && !args->AgentSessionAgent().empty()) + { + Json::Value agentSession{ Json::objectValue }; + JsonUtils::SetValueForKey(agentSession, AgentSessionIdKey, args->_AgentSessionId); + JsonUtils::SetValueForKey(agentSession, AgentKey, args->_AgentSessionAgent); + json[AgentSessionKey.data()] = std::move(agentSession); + } + if (!args->AgentPaneAgent().empty()) + { + Json::Value agentPane{ Json::objectValue }; + JsonUtils::SetValueForKey(agentPane, AgentKey, args->_AgentPaneAgent); + JsonUtils::SetValueForKey(agentPane, AgentSessionIdKey, args->_AgentPaneSessionId); + JsonUtils::SetValueForKey(agentPane, CustomCommandKey, args->_AgentPaneCustomCommand); + JsonUtils::SetValueForKey(agentPane, ViewKey, args->_AgentPaneView); + JsonUtils::SetValueForKey(agentPane, OpenKey, args->_AgentPaneOpen); + JsonUtils::SetValueForKey(agentPane, PositionKey, args->_AgentPanePosition); + if (const auto size = args->AgentPaneSize(); size > 0.0f && size < 1.0f) + { + JsonUtils::SetValueForKey(agentPane, SizeKey, args->_AgentPaneSize); + } + json[AgentPaneKey.data()] = std::move(agentPane); + } JsonUtils::SetValueForKey(json, TabColorKey, args->_TabColor); JsonUtils::SetValueForKey(json, SuppressApplicationTitleKey, args->_SuppressApplicationTitle); JsonUtils::SetValueForKey(json, ColorSchemeKey, args->_ColorScheme); @@ -477,6 +541,15 @@ namespace winrt::Microsoft::Terminal::Settings::Model::implementation copy->_ColorScheme = _ColorScheme; copy->_Elevate = _Elevate; copy->_ReloadEnvironmentVariables = _ReloadEnvironmentVariables; + copy->_AgentSessionId = _AgentSessionId; + copy->_AgentSessionAgent = _AgentSessionAgent; + copy->_AgentPaneSessionId = _AgentPaneSessionId; + copy->_AgentPaneAgent = _AgentPaneAgent; + copy->_AgentPaneCustomCommand = _AgentPaneCustomCommand; + copy->_AgentPaneView = _AgentPaneView; + copy->_AgentPaneOpen = _AgentPaneOpen; + copy->_AgentPanePosition = _AgentPanePosition; + copy->_AgentPaneSize = _AgentPaneSize; copy->_ContentId = _ContentId; return *copy; } @@ -498,6 +571,15 @@ namespace winrt::Microsoft::Terminal::Settings::Model::implementation h.write(ColorScheme()); h.write(Elevate()); h.write(ReloadEnvironmentVariables()); + h.write(AgentSessionId()); + h.write(AgentSessionAgent()); + h.write(AgentPaneSessionId()); + h.write(AgentPaneAgent()); + h.write(AgentPaneCustomCommand()); + h.write(AgentPaneView()); + h.write(AgentPaneOpen()); + h.write(AgentPanePosition()); + h.write(AgentPaneSize()); h.write(ContentId()); } }; diff --git a/src/cascadia/TerminalSettingsModel/ActionArgs.idl b/src/cascadia/TerminalSettingsModel/ActionArgs.idl index 38c3c17c2c..a90f948d5d 100644 --- a/src/cascadia/TerminalSettingsModel/ActionArgs.idl +++ b/src/cascadia/TerminalSettingsModel/ActionArgs.idl @@ -174,6 +174,15 @@ namespace Microsoft.Terminal.Settings.Model Windows.Foundation.IReference TabColor; String Profile; // Either a GUID or a profile's name if the GUID isn't a match Guid SessionId; + String AgentSessionId; + String AgentSessionAgent; + String AgentPaneSessionId; + String AgentPaneAgent; + String AgentPaneCustomCommand; + String AgentPaneView; + Boolean AgentPaneOpen; + String AgentPanePosition; + Single AgentPaneSize; Boolean AppendCommandLine; // We use IReference<> to treat some args as nullable where null means diff --git a/src/cascadia/UnitTests_SettingsModel/CommandTests.cpp b/src/cascadia/UnitTests_SettingsModel/CommandTests.cpp index 4ef9c8a153..5bab331680 100644 --- a/src/cascadia/UnitTests_SettingsModel/CommandTests.cpp +++ b/src/cascadia/UnitTests_SettingsModel/CommandTests.cpp @@ -30,6 +30,7 @@ namespace SettingsModelUnitTests TEST_METHOD(TestLayerOnAutogeneratedName); TEST_METHOD(TestGenerateCommandline); + TEST_METHOD(TestDurableAgentMetadata); }; void CommandTests::ManyCommandsSameAction() @@ -602,4 +603,49 @@ namespace SettingsModelUnitTests VERIFY_ARE_EQUAL(LR"-(--title "\\\"\;foo\\")-", terminalArgs.ToCommandline()); } } + + void CommandTests::TestDurableAgentMetadata() + { + NewTerminalArgs args; + args.AgentSessionId(L"shell-session"); + args.AgentSessionAgent(L"copilot"); + args.AgentPaneAgent(L"claude"); + args.AgentPaneCustomCommand(L"custom-acp --stdio"); + args.AgentPaneView(L"chat"); + args.AgentPaneOpen(true); + args.AgentPanePosition(L"right"); + args.AgentPaneSize(0.35f); + + auto json = implementation::NewTerminalArgs::ToJson(args); + VERIFY_IS_TRUE(json["agentSession"].isObject()); + VERIFY_ARE_EQUAL("shell-session", json["agentSession"]["agentSessionId"].asString()); + VERIFY_ARE_EQUAL("copilot", json["agentSession"]["agent"].asString()); + VERIFY_IS_FALSE(json["agentSession"].isMember("paneSessionId")); + + VERIFY_IS_TRUE(json["agentPane"].isObject()); + VERIFY_ARE_EQUAL("claude", json["agentPane"]["agent"].asString()); + VERIFY_ARE_EQUAL("custom-acp --stdio", json["agentPane"]["customCommand"].asString()); + VERIFY_IS_FALSE(json["agentPane"].isMember("agentSessionId")); + VERIFY_ARE_EQUAL("chat", json["agentPane"]["view"].asString()); + VERIFY_IS_TRUE(json["agentPane"]["open"].asBool()); + VERIFY_ARE_EQUAL("right", json["agentPane"]["position"].asString()); + VERIFY_ARE_EQUAL(0.35f, json["agentPane"]["size"].asFloat()); + + const auto parsedWithoutSession = implementation::NewTerminalArgs::FromJson(json); + VERIFY_ARE_EQUAL(winrt::hstring{ L"claude" }, parsedWithoutSession.AgentPaneAgent()); + VERIFY_ARE_EQUAL(winrt::hstring{ L"custom-acp --stdio" }, parsedWithoutSession.AgentPaneCustomCommand()); + VERIFY_IS_TRUE(parsedWithoutSession.AgentPaneSessionId().empty()); + VERIFY_ARE_EQUAL(0.35f, parsedWithoutSession.AgentPaneSize()); + + args.AgentPaneSessionId(L"acp-session"); + json = implementation::NewTerminalArgs::ToJson(args); + VERIFY_ARE_EQUAL("acp-session", json["agentPane"]["agentSessionId"].asString()); + + const auto parsed = implementation::NewTerminalArgs::FromJson(json); + VERIFY_ARE_EQUAL(winrt::hstring{ L"shell-session" }, parsed.AgentSessionId()); + VERIFY_ARE_EQUAL(winrt::hstring{ L"copilot" }, parsed.AgentSessionAgent()); + VERIFY_ARE_EQUAL(winrt::hstring{ L"acp-session" }, parsed.AgentPaneSessionId()); + VERIFY_ARE_EQUAL(winrt::hstring{ L"claude" }, parsed.AgentPaneAgent()); + VERIFY_ARE_EQUAL(winrt::hstring{ L"right" }, parsed.AgentPanePosition()); + } } diff --git a/src/cascadia/WindowsTerminal/TerminalProtocolComServer.cpp b/src/cascadia/WindowsTerminal/TerminalProtocolComServer.cpp index 28884f94ca..af37e59bce 100644 --- a/src/cascadia/WindowsTerminal/TerminalProtocolComServer.cpp +++ b/src/cascadia/WindowsTerminal/TerminalProtocolComServer.cpp @@ -1108,7 +1108,9 @@ try { Json::StreamWriterBuilder wb; wb["indentation"] = ""; - s_NotifyEventToComClients(Json::writeString(wb, evt)); + const auto normalized = Json::writeString(wb, evt); + _dispatchPaneAgentSessionToPage(winrt::to_hstring(normalized)); + s_NotifyEventToComClients(normalized); return S_OK; } default: @@ -1406,6 +1408,39 @@ void TerminalProtocolComServer::_dispatchResumeInNewAgentTabToPage(const winrt:: } } +void TerminalProtocolComServer::_dispatchPaneAgentSessionToPage(const winrt::hstring& eventJson) +{ + if (!s_emperor) + { + return; + } + for (const auto& host : s_emperor->GetWindows()) + { + auto page = _getPage(host.get()); + if (!page) + { + continue; + } + const auto dispatcher = page.Dispatcher(); + if (!dispatcher) + { + continue; + } + dispatcher.RunAsync( + winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, + [page, eventJson]() { + try + { + page.OnPaneAgentSessionChanged(eventJson); + } + catch (...) + { + // Page may have been torn down during dispatch. + } + }); + } +} + void TerminalProtocolComServer::_dispatchAgentChipTargetToPage(const winrt::hstring& eventJson) { if (!s_emperor) diff --git a/src/cascadia/WindowsTerminal/TerminalProtocolComServer.h b/src/cascadia/WindowsTerminal/TerminalProtocolComServer.h index 44c78fdb24..1000e914fa 100644 --- a/src/cascadia/WindowsTerminal/TerminalProtocolComServer.h +++ b/src/cascadia/WindowsTerminal/TerminalProtocolComServer.h @@ -135,6 +135,7 @@ TerminalProtocolComServer : public Microsoft::WRL::RuntimeClass< static void _dispatchCloseAgentPaneToPage(const winrt::hstring& eventJson); static void _dispatchAgentStateChangedToPage(const winrt::hstring& eventJson); static void _dispatchResumeInNewAgentTabToPage(const winrt::hstring& eventJson); + static void _dispatchPaneAgentSessionToPage(const winrt::hstring& eventJson); static void _dispatchAgentChipTargetToPage(const winrt::hstring& eventJson); static void _dispatchRestartAgentStackToPage(const winrt::hstring& eventJson); static void _dispatchRestartAgentPaneToPage(const winrt::hstring& eventJson); diff --git a/tools/wta/src/app.rs b/tools/wta/src/app.rs index 242ed0f692..a863eeba32 100644 --- a/tools/wta/src/app.rs +++ b/tools/wta/src/app.rs @@ -1427,6 +1427,9 @@ pub struct TabSession { // Conversation history pub messages: Vec, pub completed_turns: Vec, + /// Latched after the first prompt or session/load. A pre-warmed session/new + /// alone must not become durable; `/clear` keeps the same session durable. + pub has_meaningful_conversation: bool, /// Tab/Shift+Tab selects a past turn (most recent first). Enter then /// toggles `CompletedTurn.expanded`. None means no selection — Enter /// goes to the input/prompt path as before. @@ -1606,6 +1609,16 @@ impl TabSession { self.chat_scroll.offset = 0; } + fn durable_session_id(&self) -> Option<&str> { + self.has_meaningful_conversation + .then(|| { + self.loading_target_session_id + .as_deref() + .or(self.session_id.as_deref()) + }) + .flatten() + } + /// Whether the input box is the live, enterable caret target. False when /// the user is browsing a completed turn, a recommendation card is showing, /// a permission card is up, a paste is pending, or a modal picker is open. @@ -5262,6 +5275,7 @@ impl App { } } self.publish_agent_status(); + self.project_tab_state(&tab_id); } AppEvent::TabError { tab_id, message } => { // Scoped error for a specific tab. Bypasses the global @@ -5271,6 +5285,7 @@ impl App { let tab = self.tab_mut(&tab_id); tab.loading_session = false; tab.loading_target_session_id = None; + tab.has_meaningful_conversation = false; tab.progress_status = None; tab.pending_agent_response.clear(); tab.pending_user_replay.clear(); @@ -5278,6 +5293,7 @@ impl App { tab.turn = TurnState::Idle; tab.messages.push(ChatMessage::Error(message)); tab.scroll_to_bottom(); + self.project_tab_state(&tab_id); } AppEvent::TabSystemMessage { tab_id, message } => { let tab = self.tab_mut(&tab_id); @@ -6269,6 +6285,7 @@ impl App { tab.completed_turns.clear(); tab.selected_completed_turn_idx = None; tab.session_id = None; + tab.has_meaningful_conversation = true; // Open the replay window: chunk handlers will // now accept session/update events for this // tab even though `turn` stays Idle. Closed by @@ -6292,9 +6309,7 @@ impl App { // not-yet-active tab (e.g. WT just created a fresh tab // and the `tab_changed` race still hasn't landed), the // imminent `tab_changed` to that tab will project then. - if tab_id == self.active_tab_key() { - self.project_active_tab_state(); - } + self.project_tab_state(tab_id); let _ = self.load_session_tx.send(LoadSessionForTab { tab_id: tab_id.to_string(), session_id: session_id.to_string(), @@ -8326,6 +8341,7 @@ impl App { tab.completed_turns.clear(); tab.selected_completed_turn_idx = None; tab.session_id = None; + tab.has_meaningful_conversation = false; tab.scroll_to_bottom(); } @@ -8505,6 +8521,7 @@ impl App { tab.completed_turns.clear(); tab.selected_completed_turn_idx = None; tab.session_id = None; + tab.has_meaningful_conversation = false; } let _ = self.restart_tx.send(RestartRequest { agent_cmd: None }); self.publish_agent_status(); @@ -8902,6 +8919,7 @@ impl App { tab.selected_completed_turn_idx = None; tab.scroll_to_bottom(); tab.session_id = None; + tab.has_meaningful_conversation = false; } // Prune the reverse SessionId → tab routing so late ACP chunks for @@ -9162,6 +9180,7 @@ impl App { tab.activity_frame = 0; tab.timing_note = None; tab.turn = TurnState::Submitted(prompt); + tab.has_meaningful_conversation = true; // Submitting a new prompt dismisses any prior leftover card (the // `selected_recommendation = 0` + turn reset above). If the helper @@ -9172,6 +9191,7 @@ impl App { // `turn_surface_*` callback once recommendations arrive. let owned_tab = tab_id.to_string(); self.recompute_chip_override(&owned_tab); + self.project_tab_state(&owned_tab); } /// Observe a streamed chunk. Thought chunks only advance the state @@ -10165,6 +10185,7 @@ impl App { View::Agents => "sessions", View::Chat => "chat", }; + let durable_session_id = tab.durable_session_id(); let evt = serde_json::json!({ "type": "event", "method": "agent_state_changed", @@ -10173,6 +10194,7 @@ impl App { "view": view, "pane_open": tab.pane_open, "pane_position": tab.agent_pane_position, + "agent_session_id": durable_session_id, } }); send_wt_protocol_event(evt.to_string()); @@ -17621,6 +17643,35 @@ mod tests { assert_eq!(known_cli_id(&CliSource::Unknown("anything".to_string())), None); } + #[test] + fn durable_session_id_requires_a_meaningful_conversation() { + let mut tab = TabSession { + session_id: Some("fresh-session".into()), + ..Default::default() + }; + assert_eq!(tab.durable_session_id(), None); + + tab.turn = TurnState::Submitted(SubmittedPrompt { + id: 1, + text: "hello".into(), + submitted_at_unix_s: 0.0, + autofix: None, + }); + tab.has_meaningful_conversation = true; + assert_eq!(tab.durable_session_id(), Some("fresh-session")); + } + + #[test] + fn durable_session_id_uses_the_load_target_during_replay() { + let tab = TabSession { + loading_session: true, + loading_target_session_id: Some("loaded-session".into()), + has_meaningful_conversation: true, + ..Default::default() + }; + assert_eq!(tab.durable_session_id(), Some("loaded-session")); + } + #[test] fn enter_on_wsl_history_row_resumes_inside_distro() { use crate::agent_sessions::{AgentStatus, CliSource, SessionLocation, SessionOrigin}; diff --git a/tools/wta/src/cli_tests.rs b/tools/wta/src/cli_tests.rs index 9fc9518ab0..aa9180e2b3 100644 --- a/tools/wta/src/cli_tests.rs +++ b/tools/wta/src/cli_tests.rs @@ -26,6 +26,14 @@ fn cli_initial_load_session_id_defaults_to_none() { let cli = Cli::try_parse_from(["wta"]).expect("no flags must parse"); assert!(cli.initial_load_session_id.is_none()); assert!(cli.initial_load_cwd.is_none()); + assert!(cli.initial_pane_position.is_none()); +} + +#[test] +fn cli_parses_initial_pane_position() { + let cli = Cli::try_parse_from(["wta", "--initial-pane-position", "right"]) + .expect("pane position must parse"); + assert_eq!(cli.initial_pane_position.as_deref(), Some("right")); } #[test] diff --git a/tools/wta/src/main.rs b/tools/wta/src/main.rs index 361f89a1c2..7b2616be18 100644 --- a/tools/wta/src/main.rs +++ b/tools/wta/src/main.rs @@ -227,6 +227,10 @@ struct Cli { #[arg(long, value_enum, default_value_t = InitialView::Chat)] initial_view: InitialView, + /// Initial per-tab agent pane position restored by Windows Terminal. + #[arg(long, hide = true)] + initial_pane_position: Option, + /// UI language override, passed by Windows Terminal from the /// `settings.json` `Language` field. When present, wta uses this /// directly for i18n instead of detecting the OS locale — ensuring @@ -3503,6 +3507,13 @@ async fn run_acp_app( .entry(owner_tab_id.clone()) .or_default(); tab.pane_open = !cli.start_stashed; + tab.agent_pane_position = match cli.initial_pane_position.as_deref() { + Some("left") => Some("left"), + Some("right") => Some("right"), + Some("top") | Some("up") => Some("up"), + Some("bottom") => Some("bottom"), + _ => None, + }; app_state.tab_id = Some(owner_tab_id.clone()); app_state.owner_tab_id = Some(owner_tab_id.clone()); } From 83a93e1102ebffd4f03878e3d54a854f25eefd62 Mon Sep 17 00:00:00 2001 From: "Yuandi.Zhang" Date: Tue, 21 Jul 2026 11:41:45 +0800 Subject: [PATCH 2/6] Restore agent pane history through ACP Reload durable agent pane sessions in a fresh ACP process when needed, isolate process generations, and rebuild replayed conversations with the existing TUI turn formatting. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 849b07ad-c08a-4108-89fa-94723b91021c --- tools/wta/src/app.rs | 123 ++++--- tools/wta/src/master/mod.rs | 626 ++++++++++++++++++++++++++++-------- 2 files changed, 585 insertions(+), 164 deletions(-) diff --git a/tools/wta/src/app.rs b/tools/wta/src/app.rs index a863eeba32..3c4ac0583d 100644 --- a/tools/wta/src/app.rs +++ b/tools/wta/src/app.rs @@ -478,6 +478,14 @@ pub fn collapsed_prompt_preview(text: &str) -> String { out } +fn replay_user_request(text: &str) -> &str { + const DELIMITER: &str = "## User Request\n"; + text.rsplit_once(DELIMITER) + .map(|(_, request)| request.trim()) + .filter(|request| !request.is_empty()) + .unwrap_or_else(|| text.trim()) +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct PlanEntry { pub content: String, @@ -1726,11 +1734,10 @@ impl TabSession { /// from the `SessionAttached` handler. /// /// Algorithm: walk `self.messages` left-to-right; each `User` opens a - /// new turn. The turn's `prompt` is a SHORT single-line preview of - /// the user text (so the collapsed `▶ > ` row stays at one - /// visual line even for huge system-prompt-as-user dumps); the full - /// original `User(text)` is stored as the first entry of `details`, - /// followed by subsequent non-User messages. Messages that come + /// new turn. ACP persists the fully composed planner prompt, so recover + /// the text after WTA's `## User Request` delimiter before building the + /// turn header. Agent recommendation JSON is parsed and formatted through + /// the same `RecommendationSet` display path used by live turns. Messages that come /// BEFORE the first User (e.g. the `System("Resuming session …")` /// marker, or a stray Agent dump) stay in `messages` as-is — only /// User-anchored turns get packed. Each packed turn has `expanded: @@ -1742,39 +1749,47 @@ impl TabSession { } let drained: Vec = std::mem::take(&mut self.messages); let mut kept: Vec = Vec::new(); - // `details` always opens with the full original ChatMessage::User - // so expanding the turn shows the entire prompt text. `prompt` - // is the short preview used in the collapsed header row. - let mut current: Option<(String, Vec)> = None; + let mut current: Option<(String, Vec, bool)> = None; for msg in drained { match msg { ChatMessage::User(text) => { - if let Some((prompt, details)) = current.take() { + if let Some((prompt, details, expanded)) = current.take() { self.completed_turns.push(CompletedTurn { prompt, details, - expanded: false, + expanded, trailing_marker: None, }); } - let preview = collapsed_prompt_preview(&text); - let details = vec![ChatMessage::User(text)]; - current = Some((preview, details)); + let prompt = replay_user_request(&text); + current = Some((collapsed_prompt_preview(prompt), Vec::new(), false)); } other => { - if let Some((_, details)) = current.as_mut() { - details.push(other); + if let Some((_, details, expanded)) = current.as_mut() { + match other { + ChatMessage::Agent(text) => { + if let Ok(recommendations) = parse_recommendation_set(&text) { + details.push(ChatMessage::AgentLiteral( + format_recommendations_for_chat(&recommendations), + )); + *expanded = true; + } else { + details.push(ChatMessage::Agent(text)); + } + } + other => details.push(other), + } } else { kept.push(other); } } } } - if let Some((prompt, details)) = current.take() { + if let Some((prompt, details, expanded)) = current.take() { self.completed_turns.push(CompletedTurn { prompt, details, - expanded: false, + expanded, trailing_marker: None, }); } @@ -11960,19 +11975,17 @@ mod tests { .is_none()); } - /// Replayed history must be packed into collapsed CompletedTurn rows - /// after session/load completes. Each User message opens a new turn; - /// the prompt header is a short preview (the full original User text - /// is kept as the first details entry so expanding shows everything). - /// Subsequent non-User messages become later details. Default - /// `expanded: false` so the resumed transcript doesn't dump as one - /// long wall. + /// Replayed history must be packed into CompletedTurn rows after + /// session/load completes. Each User message opens a new turn and WTA's + /// composed prompt is reduced back to the original user request. #[test] fn pack_replayed_messages_groups_into_collapsed_turns() { let mut tab = TabSession::default(); tab.messages = vec![ ChatMessage::System("Resuming session abc...".to_string()), - ChatMessage::User("# Terminal Agent\nYou are...".to_string()), + ChatMessage::User( + "# Terminal Agent\nYou are...\n\n## User Request\nget time".to_string(), + ), ChatMessage::Agent("Hello, I am ready.".to_string()), ChatMessage::User("list files".to_string()), ChatMessage::ToolCall { @@ -11993,26 +12006,62 @@ mod tests { assert_eq!(tab.completed_turns.len(), 2); let t0 = &tab.completed_turns[0]; - // Preview shows first non-empty line + ellipsis (extra lines below). - assert_eq!(t0.prompt, "# Terminal Agent…"); - // details = [original full User, Agent reply]. - assert_eq!(t0.details.len(), 2); - assert!(matches!(&t0.details[0], ChatMessage::User(s) if s.starts_with("# Terminal Agent\nYou are"))); - assert!(matches!(&t0.details[1], ChatMessage::Agent(_))); + assert_eq!(t0.prompt, "get time"); + assert_eq!(t0.details.len(), 1); + assert!(matches!(&t0.details[0], ChatMessage::Agent(_))); assert!(!t0.expanded, "replayed turn must default to collapsed"); assert!(t0.trailing_marker.is_none()); let t1 = &tab.completed_turns[1]; // Short single-line prompt — no ellipsis. assert_eq!(t1.prompt, "list files"); - // details = [original User, ToolCall, Agent]. - assert_eq!(t1.details.len(), 3); - assert!(matches!(&t1.details[0], ChatMessage::User(s) if s == "list files")); - assert!(matches!(&t1.details[1], ChatMessage::ToolCall { .. })); - assert!(matches!(&t1.details[2], ChatMessage::Agent(_))); + assert_eq!(t1.details.len(), 2); + assert!(matches!(&t1.details[0], ChatMessage::ToolCall { .. })); + assert!(matches!(&t1.details[1], ChatMessage::Agent(_))); assert!(!t1.expanded); } + #[test] + fn pack_replayed_recommendation_reuses_live_turn_formatting() { + let mut tab = TabSession::default(); + tab.messages = vec![ + ChatMessage::User( + "# Terminal Agent\n...\n\n## User Request\nget time".to_string(), + ), + ChatMessage::Agent( + r#"```json +{ + "recommended_choice": 1, + "choices": [{ + "choice": 1, + "title": "Get the current time", + "rationale": "Displays the current time.", + "actions": [{ + "type": "send", + "parent": "old-pane-id", + "input": "Get-Date -Format 'HH:mm:ss'" + }] + }] +} +```"# + .to_string(), + ), + ]; + + tab.pack_replayed_messages_into_turns(); + + assert_eq!(tab.completed_turns.len(), 1); + let turn = &tab.completed_turns[0]; + assert_eq!(turn.prompt, "get time"); + assert!(turn.expanded); + assert_eq!( + turn.details, + vec![ChatMessage::AgentLiteral( + "Suggested 1 option:\n ✓ 1. Run: Get-Date -Format 'HH:mm:ss'".to_string() + )] + ); + } + /// Preview logic: huge single-line prompt must clip to the cap with /// a trailing ellipsis; short single-line prompts stay verbatim. #[test] diff --git a/tools/wta/src/master/mod.rs b/tools/wta/src/master/mod.rs index c44b79636d..7dcea5775d 100644 --- a/tools/wta/src/master/mod.rs +++ b/tools/wta/src/master/mod.rs @@ -39,6 +39,7 @@ use std::collections::HashMap; use std::collections::HashSet; use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, OnceLock}; /// Per-helper notification channel capacity. Sized for bursty chunk @@ -50,6 +51,7 @@ use std::sync::{Arc, OnceLock}; const NOTIF_CHANNEL_CAPACITY: usize = 1024; const SESSION_NEW_TIMEOUT_SECS: u64 = 120; const MASTER_PIPE_DISCOVERY_FILE: &str = "master-pipe.txt"; +static NEXT_AGENT_GENERATION: AtomicU64 = AtomicU64::new(1); use agent_client_protocol as acp; use anyhow::{anyhow, Context, Result}; @@ -87,6 +89,7 @@ pub(crate) struct HelperId(u64); #[derive(Clone)] struct HelperRoute { helper_id: HelperId, + agent_generation: u64, notif_tx: mpsc::Sender, forwarder: Option, /// Per-route counter for back-pressure log rate-limiting. @@ -138,6 +141,10 @@ struct MasterStateInner { /// blocking would freeze notification delivery for every other /// helper sharing this master. session_to_helper: Mutex>, + /// Sessions currently inside an ACP `session/load` replay. Their + /// notifications must be forwarded synchronously so they reach the helper + /// before the load response closes its replay window. + ordered_replay_sessions: Mutex>, /// Authoritative live-session set, owned by master. Mirrors what /// helpers learn via ext-notifications and what the session management view sees /// via the standard ACP `session/list` request. Kept beside @@ -280,21 +287,14 @@ struct MasterStateInner { hook_owned: Mutex>, /// Sessions loaded on a shared agent CLI whose owning helper has /// disconnected (its tab/pane closed) — the CLI keeps them loaded as - /// "orphans". Keyed by `AgentCmdKey` so orphans belong to a specific - /// agent CLI, never a global pool: a window can run Copilot in one tab - /// and Gemini in another, and reaping one must not affect the other. + /// "orphans". Keyed by command and process generation so a replacement + /// process never mistakes an older process's resident session for its own. /// /// When a helper resumes such a session (`--initial-load-session-id` - /// re-warm or `/restart`), `load_session` re-binds routing to the new - /// helper *directly* — no fresh `session/load` — because the CLI already - /// has it (a re-load would be rejected "already loaded", or, if the - /// orphan turn is still running, wedge behind it and hang the pane on - /// "Resuming…"). Only recorded while the owning CLI *instance* is still - /// the live pool entry (checked via `Arc::ptr_eq`), and `reap_agent` - /// drops just that agent's set on CLI death, so a crashed-and-respawned - /// CLI under the same command line never re-binds to a session it never - /// had — such a resume falls back to a real `session/load` from disk. - orphaned_sessions: Mutex>>, + /// re-warm or `/restart`), `load_session` rotates to a fresh process and + /// performs a real ACP load so history is replayed to the new helper. + /// `reap_agent` drops only the exiting generation's records. + orphaned_sessions: Mutex>>, /// #266 born-bound sessions (WTA-launched delegate/resume — copilot/claude/ /// gemini). **Binding-only**: unlike `hook_owned`, the file watcher may /// still supply STATUS for these when no real hook is installed @@ -359,6 +359,12 @@ struct AgentCli { /// crashed-and-respawned CLI under the same command line never inherits /// another instance's stale orphan sessions. cmd_key: AgentCmdKey, + /// Distinguishes successive pooled processes for the same command line. + generation: u64, + /// Helpers currently bound to this exact process generation. + helper_bindings: AtomicU64, + /// Wakes the child task when a superseded process loses its last helper. + shutdown: Arc, } /// Per-helper recovery metadata stashed in @@ -388,6 +394,7 @@ pub(crate) struct HelperRecoveryMeta { #[derive(Clone)] struct MasterClient { state: Arc, + agent_generation: u64, } impl MasterClient { @@ -410,23 +417,33 @@ impl MasterClient { match entry { Some(HelperRoute { helper_id, - forwarder: Some(forwarder), - .. - }) => Ok((helper_id, forwarder)), - Some(HelperRoute { - forwarder: None, - helper_id, + agent_generation, + forwarder, .. }) => { - tracing::error!( - target: "master", - op = op, - session_id = ?sid, - helper_id = ?helper_id, - "routing entry has no forwarder — bug; routing entry should always carry the helper's AgentSideConnection", - ); - Err(acp::Error::internal_error() - .data(serde_json::json!("master routing entry missing forwarder"))) + if agent_generation != self.agent_generation { + tracing::warn!( + target: "master", + op = op, + session_id = ?sid, + route_generation = agent_generation, + caller_generation = self.agent_generation, + "stale agent CLI sent request for a session owned by another generation" + ); + return Err(acp::Error::internal_error() + .data(serde_json::json!("stale agent generation for session_id"))); + } + forwarder.map(|forwarder| (helper_id, forwarder)).ok_or_else(|| { + tracing::error!( + target: "master", + op = op, + session_id = ?sid, + helper_id = ?helper_id, + "routing entry has no forwarder — bug; routing entry should always carry the helper's AgentSideConnection", + ); + acp::Error::internal_error() + .data(serde_json::json!("master routing entry missing forwarder")) + }) } None => { tracing::warn!( @@ -533,20 +550,65 @@ impl MasterClient { map.get(&sid).map(|r| { ( r.helper_id, - r.notif_tx.clone(), + r.agent_generation, Arc::clone(&r.consecutive_drops), ) }) }; match route { - Some((snap_helper_id, tx, drops)) => { + Some((snap_helper_id, agent_generation, drops)) => { + if agent_generation != self.agent_generation { + tracing::debug!( + target: "master", + session_id = ?sid, + route_generation = agent_generation, + caller_generation = self.agent_generation, + "dropping notification from stale agent generation" + ); + return Ok(()); + } + let ordered_replay = self + .state + .ordered_replay_sessions + .lock() + .await + .contains(&sid); + let map = self.state.session_to_helper.lock().await; + let current = match map.get(&sid) { + Some(current) + if current.helper_id == snap_helper_id + && current.agent_generation == self.agent_generation => + { + current + } + _ => { + tracing::debug!( + target: "master", + session_id = ?sid, + caller_generation = self.agent_generation, + "route changed while dispatching notification; dropping stale update" + ); + return Ok(()); + } + }; + if ordered_replay { + let forwarder = current.forwarder.as_ref().ok_or_else(|| { + acp::Error::internal_error() + .data(serde_json::json!("master replay route missing forwarder")) + })?; + forwarder.session_notification(args).await?; + drop(map); + return Ok(()); + } + let send_result = current.notif_tx.try_send(args); + drop(map); use std::sync::atomic::Ordering; // `try_send` rather than `send().await`: a slow helper // pipe must not back-pressure this trait method, which // is driven by the agent CLI's I/O loop and is shared // across every helper. Blocking here would freeze // notification delivery for everyone. - match tx.try_send(args) { + match send_result { Ok(()) => { // First successful send after one or more drops // is the recovery point — summarize and reset. @@ -820,6 +882,11 @@ struct HelperHandler { /// guarantees `initialize` precedes `new_session`/`prompt`/…, so /// `resolved_agent()` always finds it populated for those. agent: Arc>>, + /// Fresh ACP process selected when the shared process reports that a + /// durable-resume target is already loaded. + replacement_agent: Arc>>, + /// Serializes replacement binding and route quarantine for this helper. + rotation_lock: Arc>, state: Arc, /// Notification fan-in for this helper. `new_session` / /// `load_session` writes `(SessionId → this sender)` into @@ -882,16 +949,85 @@ impl HelperHandler { /// binding — a protocol violation by the helper, never expected in /// the normal handshake order. fn resolved_agent(&self, op: &'static str) -> acp::Result> { - self.agent.get().cloned().ok_or_else(|| { - tracing::error!( - target: "master", - op = op, - helper_id = ?self.helper_id, - "helper request arrived before initialize bound an agent — protocol violation" - ); - acp::Error::internal_error() - .data(serde_json::json!("no agent bound; initialize must come first")) - }) + self.replacement_agent + .get() + .or_else(|| self.agent.get()) + .cloned() + .ok_or_else(|| { + tracing::error!( + target: "master", + op = op, + helper_id = ?self.helper_id, + "helper request arrived before initialize bound an agent — protocol violation" + ); + acp::Error::internal_error().data(serde_json::json!( + "no agent bound; initialize must come first" + )) + }) + } + + async fn rotate_agent_for_load( + &self, + previous: &Arc, + session_id: &acp::schema::v1::SessionId, + ) -> acp::Result> { + let _rotation_guard = self.rotation_lock.lock().await; + { + let mut routes = self.state.session_to_helper.lock().await; + let route = routes.get_mut(session_id).ok_or_else(|| { + acp::Error::internal_error().data(serde_json::json!( + "session route disappeared before rotation" + )) + })?; + if route.helper_id != self.helper_id || route.agent_generation != previous.generation { + return Err(acp::Error::internal_error() + .data(serde_json::json!("session route changed before rotation"))); + } + // Quarantine traffic from the old process while the replacement + // initializes. Generation zero is never assigned to an AgentCli. + route.agent_generation = 0; + } + let replacement = rotate_agent(&self.state, previous).await.map_err(|err| { + acp::Error::internal_error().data(serde_json::json!(format!( + "failed to rotate agent CLI for session load: {err}" + ))) + })?; + let newly_bound = { + let mut routes = self.state.session_to_helper.lock().await; + let route = routes.get_mut(session_id).ok_or_else(|| { + acp::Error::internal_error().data(serde_json::json!( + "session route disappeared during rotation" + )) + })?; + if route.helper_id != self.helper_id || route.agent_generation != 0 { + return Err(acp::Error::internal_error().data(serde_json::json!( + "session route was rebound during rotation" + ))); + } + let newly_bound = match self.replacement_agent.set(Arc::clone(&replacement)) { + Ok(()) => true, + Err(_) + if self + .replacement_agent + .get() + .is_some_and(|bound| bound.generation == replacement.generation) => + { + false + } + Err(_) => { + return Err(acp::Error::internal_error().data(serde_json::json!( + "helper already has a different replacement agent binding" + ))); + } + }; + route.agent_generation = replacement.generation; + newly_bound + }; + if newly_bound { + replacement.helper_bindings.fetch_add(1, Ordering::Relaxed); + release_agent_binding(&self.state, previous).await; + } + Ok(replacement) } /// Forward `session/new` to this helper's bound agent CLI with a @@ -1000,7 +1136,9 @@ impl HelperHandler { })?; // `set` is idempotent-by-error; a helper that (incorrectly) sent // initialize twice keeps its first binding, which is fine. - let _ = self.agent.set(Arc::clone(&agent)); + if self.agent.set(Arc::clone(&agent)).is_ok() { + agent.helper_bindings.fetch_add(1, Ordering::Relaxed); + } // Replay the CLI's own initialize response (re-forwarding returns // empty `agent_info` on most backends, blanking the agent bar). @@ -1063,6 +1201,7 @@ impl HelperHandler { resp.session_id.clone(), HelperRoute { helper_id: self.helper_id, + agent_generation: agent.generation, notif_tx: self.notif_tx.clone(), forwarder: Some(forwarder), consecutive_drops: Arc::new(std::sync::atomic::AtomicU64::new(0)), @@ -1189,82 +1328,94 @@ impl HelperHandler { session_id.clone(), HelperRoute { helper_id: self.helper_id, + agent_generation: agent.generation, notif_tx: self.notif_tx.clone(), forwarder: Some(forwarder), consecutive_drops: Arc::new(std::sync::atomic::AtomicU64::new(0)), }, ); } - // Orphan re-bind fast path: this session's previous helper - // disconnected but the shared CLI still has it loaded (tracked in - // `orphaned_sessions` under this agent's key). Re-attach onto the - // routing pre-registered above WITHOUT a `session/load` round-trip — - // the CLI already has the session, and forwarding a load would be - // rejected "already loaded", or (if the orphan turn is still running) - // wedge behind it and hang the pane on "Resuming…". Any in-flight - // turn now streams its `session/update`s to this new helper. Scoped - // to `agent.cmd_key` so we only re-bind sessions this exact CLI still - // holds (a crashed+respawned CLI's set was dropped by `reap_agent`). - let is_orphan_rebind = { + // Remove the stale owner marker, but still ask the agent to load the + // session. A replacement helper needs the ACP history replay to rebuild + // its conversation UI; routing alone only reconnects future updates. + let is_orphan = { let mut orphans = self.state.orphaned_sessions.lock().await; orphans - .get_mut(&agent.cmd_key) + .get_mut(&(agent.cmd_key.clone(), agent.generation)) .is_some_and(|set| set.remove(&session_id)) }; - // Both a re-bind and a real `session/load` resume the session; only a - // genuine load failure rolls back. Resolve the response, then register - // the resumed row once for either success path. - let resp = if is_orphan_rebind { + self.state + .ordered_replay_sessions + .lock() + .await + .insert(session_id.clone()); + let mut active_agent = Arc::clone(&agent); + let mut load_result = if is_orphan { + match self.rotate_agent_for_load(&agent, &session_id).await { + Ok(replacement) => { + active_agent = replacement; + active_agent.conn.load_session(args.clone()).await + } + Err(err) => Err(err), + } + } else { + agent.conn.load_session(args.clone()).await + }; + if active_agent.generation == agent.generation + && load_result.as_ref().is_err_and(is_already_loaded_error) + { tracing::info!( target: "master", step = "helper→agent", op = "load_session", helper_id = ?self.helper_id, session_id = ?session_id, - "re-binding orphan session without a session/load round-trip" + generation = agent.generation, + "session is already loaded; rotating ACP process for a real history replay" ); - acp::schema::v1::LoadSessionResponse::new() - } else { - match agent.conn.load_session(args).await { - Ok(resp) => resp, - // Fallback for an orphan we didn't track (e.g. it predates - // this master): the CLI reports "already loaded", so re-bind - // onto the pre-registered routing just like the fast path. - Err(err) if is_already_loaded_error(&err) => { - tracing::info!( - target: "master", - step = "helper→agent", - op = "load_session", - helper_id = ?self.helper_id, - session_id = ?session_id, - "re-binding session already loaded in the shared CLI" - ); - acp::schema::v1::LoadSessionResponse::new() + load_result = match self.rotate_agent_for_load(&agent, &session_id).await { + Ok(replacement) => { + active_agent = replacement; + active_agent.conn.load_session(args).await } - Err(err) => { - // Roll back the pre-registration. Only `session_to_helper` - // needs touching — we never wrote to `registry` and we - // never broadcast `session_added`, so peers never saw - // this row. + Err(err) => Err(err), + }; + } + self.state + .ordered_replay_sessions + .lock() + .await + .remove(&session_id); + + let resp = match load_result { + Ok(resp) => resp, + Err(err) => { + // Roll back the pre-registration. Only `session_to_helper` + // needs touching — we never wrote to `registry` and we + // never broadcast `session_added`, so peers never saw + // this row. + { + let mut map = self.state.session_to_helper.lock().await; + if map + .get(&session_id) + .is_some_and(|route| route.helper_id == self.helper_id) { - let mut map = self.state.session_to_helper.lock().await; map.remove(&session_id); } - tracing::warn!( - target: "master", - helper_id = ?self.helper_id, - session_id = ?session_id, - error = %err, - "load_session failed; rolled back routing entry" - ); - return Err(err); } + tracing::warn!( + target: "master", + helper_id = ?self.helper_id, + session_id = ?session_id, + error = %err, + "load_session failed; rolled back routing entry" + ); + return Err(err); } }; - // Register the resumed row (Live + tagged) — shared by the real-load - // and orphan-re-bind paths. + // Register the resumed row (Live + tagged). let mut info = crate::session_registry::SessionInfo::new(session_id.clone(), cwd_for_registry); info.pane_session_id = wta_meta.pane_session_id; @@ -1855,6 +2006,7 @@ async fn run_master_loop(cli: Cli, pipe_name: String) -> Result<()> { let inner = Arc::new(MasterStateInner { session_to_helper: Mutex::new(HashMap::new()), + ordered_replay_sessions: Mutex::new(HashSet::new()), registry: crate::session_registry::InMemoryRegistry::shared(), helper_ext_subscribers: Mutex::new(HashMap::new()), wt, @@ -2185,12 +2337,79 @@ async fn get_or_spawn_agent( // cleanly (no lingering dead slot, no leaked subprocess). let agent = cell .get_or_try_init(|| async { - spawn_one_agent(state, &key, agent_cmd, agent_id, source).await + spawn_one_agent( + state, + &key, + agent_cmd, + agent_id, + source, + Arc::clone(&cell), + ) + .await }) .await?; Ok(Arc::clone(agent)) } +/// Replace the pooled process for an agent command while existing helpers keep +/// their `Arc` bindings to the previous process. This is required +/// when an ACP implementation refuses to load a session that is still resident +/// in its current process: the replacement process can perform a real +/// `session/load` and replay the conversation to the restoring helper. +async fn rotate_agent( + state: &Arc, + previous: &Arc, +) -> Result> { + let key = previous.cmd_key.clone(); + let cell = { + let mut agents = state.agents.lock().await; + if let Some(cell) = agents.get(&key) { + match cell.get() { + Some(current) if current.generation != previous.generation => { + return Ok(Arc::clone(current)); + } + None => Arc::clone(cell), + Some(_) => { + let replacement = Arc::new(tokio::sync::OnceCell::new()); + agents.insert(key.clone(), Arc::clone(&replacement)); + replacement + } + } + } else { + let replacement = Arc::new(tokio::sync::OnceCell::new()); + agents.insert(key.clone(), Arc::clone(&replacement)); + replacement + } + }; + let agent_cmd = key + .split_once('\0') + .map(|(_, command)| command) + .unwrap_or(key.as_str()) + .to_string(); + let replacement = cell + .get_or_try_init(|| async { + spawn_one_agent( + state, + &key, + &agent_cmd, + None, + &previous.source, + Arc::clone(&cell), + ) + .await + }) + .await + .map(Arc::clone)?; + tracing::info!( + target: "master", + agent = %key, + old_generation = previous.generation, + new_generation = replacement.generation, + "rotated pooled agent CLI for ACP session reload" + ); + Ok(replacement) +} + /// Spawn one agent CLI subprocess, wire master as its ACP client, run /// the startup `initialize` round trip, and install per-CLI reapers. /// Unlike the old single-agent master, an agent CLI death here only @@ -2202,7 +2421,9 @@ async fn spawn_one_agent( agent_cmd: &str, agent_id: Option<&str>, source: &crate::agent_source::AgentSource, + pool_cell: Arc>>, ) -> Result> { + let generation = NEXT_AGENT_GENERATION.fetch_add(1, Ordering::Relaxed); let mut spawn_result = spawn_agent_process_for_source(agent_cmd, None, source) .with_context(|| format!("failed to spawn agent CLI: {agent_cmd}"))?; tracing::info!( @@ -2237,6 +2458,7 @@ async fn spawn_one_agent( let client = MasterClient { state: Arc::clone(state), + agent_generation: generation, }; let builder = acp::Client .builder() @@ -2344,6 +2566,7 @@ async fn spawn_one_agent( { let state = Arc::clone(state); let key = key.clone(); + let pool_cell = Arc::clone(&pool_cell); tokio::task::spawn_local(async move { match handle_io.await { Ok(()) => tracing::info!( @@ -2358,7 +2581,7 @@ async fn spawn_one_agent( "agent CLI ACP I/O loop ended with error — removing from pool" ), } - reap_agent(&state, &key).await; + reap_agent(&state, &key, generation, &pool_cell).await; }); } @@ -2415,21 +2638,37 @@ async fn spawn_one_agent( } }; + let shutdown = Arc::new(tokio::sync::Notify::new()); + // Init succeeded — install the child reaper now (takes ownership of // `child`). A later CLI exit drops just this agent from the pool so // the next helper respawns it; the master stays up for other agents. { let state = Arc::clone(state); let key = key.clone(); + let pool_cell = Arc::clone(&pool_cell); + let shutdown_waiter = Arc::clone(&shutdown); tokio::task::spawn_local(async move { - let status = child.wait().await; + let status = tokio::select! { + status = child.wait() => status, + () = shutdown_waiter.notified() => { + tracing::info!( + target: "master", + agent = %key, + generation, + "stopping superseded agent CLI with no bound helpers" + ); + let _ = child.start_kill(); + child.wait().await + } + }; tracing::error!( target: "master", agent = %key, ?status, "agent CLI exited — removing from pool (master stays up for other agents)" ); - reap_agent(&state, &key).await; + reap_agent(&state, &key, generation, &pool_cell).await; }); } @@ -2472,6 +2711,9 @@ async fn spawn_one_agent( cli_source, source: source.clone(), cmd_key: key.clone(), + generation, + helper_bindings: AtomicU64::new(0), + shutdown, })) } @@ -2480,14 +2722,25 @@ async fn spawn_one_agent( /// pane gets rebuilt); a fresh helper requesting the same `agent_cmd` /// re-runs `spawn_one_agent`. Sessions owned by the dead agent are left /// for the owning helper's disconnect cleanup (`drop_sessions_for_helper`). -async fn reap_agent(state: &Arc, key: &AgentCmdKey) { - let removed = { state.agents.lock().await.remove(key).is_some() }; +async fn reap_agent( + state: &Arc, + key: &AgentCmdKey, + expected_generation: u64, + expected_cell: &Arc>>, +) { + let removed = { + let mut agents = state.agents.lock().await; + let matches_process = agents + .get(key) + .is_some_and(|cell| Arc::ptr_eq(cell, expected_cell)); + matches_process && agents.remove(key).is_some() + }; + state + .orphaned_sessions + .lock() + .await + .remove(&(key.clone(), expected_generation)); if removed { - // Every session THIS CLI held died with it, so drop only this - // agent's orphan set — a post-respawn resume then forwards a real - // `session/load` (reloading from disk) instead of re-binding to a - // session the new CLI never had. Other agents' orphans are untouched. - state.orphaned_sessions.lock().await.remove(key); tracing::info!( target: "master", agent = %key, @@ -2496,6 +2749,29 @@ async fn reap_agent(state: &Arc, key: &AgentCmdKey) { } } +async fn release_agent_binding(state: &Arc, agent: &Arc) { + let was_last = agent + .helper_bindings + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |count| { + count.checked_sub(1) + }) + .is_ok_and(|previous| previous == 1); + if !was_last { + return; + } + + let is_pooled = { + let agents = state.agents.lock().await; + agents + .get(&agent.cmd_key) + .and_then(|cell| cell.get()) + .is_some_and(|current| Arc::ptr_eq(current, agent)) + }; + if !is_pooled { + agent.shutdown.notify_one(); + } +} + /// Per-helper-connection task. Wraps the named pipe in an /// `AgentSideConnection`, runs both its I/O loop and a notification /// forwarder until the helper disconnects. @@ -2538,6 +2814,8 @@ async fn serve_helper( // Resolved lazily during this helper's `initialize` (see // HelperHandler::initialize → get_or_spawn_agent). agent: Arc::new(OnceLock::new()), + replacement_agent: Arc::new(OnceLock::new()), + rotation_lock: Arc::new(Mutex::new(())), state: Arc::clone(&state), notif_tx, agent_side_slot: Arc::clone(&agent_side_slot), @@ -2652,16 +2930,17 @@ async fn serve_helper( let victims = drop_sessions_for_helper(&state, helper_id).await; // The dropped sessions are still loaded on the shared CLI — they're now - // orphans. Record them under the owning agent's key so a later resume - // re-binds directly instead of forwarding a `session/load` that the CLI - // rejects "already loaded" (or, mid-turn, wedges behind the running - // turn). Guard on `Arc::ptr_eq`: only record if the helper's bound CLI - // is STILL the live pool instance for its key. If that CLI already died - // (reaped, possibly respawned under the same command line), these - // sessions are gone — recording them would make a later resume skip the - // `session/load` the new CLI needs, binding to a session it never had. + // Orphans. Record them under the exact owning process generation so a + // later resume knows it must rotate before performing a real ACP load. + // Guard on `Arc::ptr_eq`: only the current pooled process can create + // actionable orphan metadata. + let active_agent = handler + .replacement_agent + .get() + .or_else(|| handler.agent.get()) + .cloned(); if !victims.is_empty() { - if let Some(agent) = handler.agent.get() { + if let Some(agent) = active_agent.as_ref() { let key = agent.cmd_key.clone(); let still_live = { let agents = state.agents.lock().await; @@ -2672,12 +2951,15 @@ async fn serve_helper( }; if still_live { let mut orphans = state.orphaned_sessions.lock().await; - let set = orphans.entry(key).or_default(); + let set = orphans.entry((key, agent.generation)).or_default(); for sid in &victims { set.insert(sid.clone()); } } } + if let Some(agent) = active_agent { + release_agent_binding(&state, &agent).await; + } } tracing::info!( @@ -4163,6 +4445,7 @@ mod tests { fn make_state() -> Arc { Arc::new(MasterStateInner { session_to_helper: Mutex::new(HashMap::new()), + ordered_replay_sessions: Mutex::new(HashSet::new()), registry: crate::session_registry::InMemoryRegistry::shared(), helper_ext_subscribers: Mutex::new(HashMap::new()), wt: None, @@ -4233,10 +4516,15 @@ mod tests { cli_source: None, source: crate::agent_source::AgentSource::Host, cmd_key: "copilot --acp --stdio".to_string(), + generation: 1, + helper_bindings: AtomicU64::new(0), + shutdown: Arc::new(tokio::sync::Notify::new()), })); let handler = HelperHandler { helper_id: HelperId(1), agent, + replacement_agent: Arc::new(OnceLock::new()), + rotation_lock: Arc::new(Mutex::new(())), state: make_state(), notif_tx, agent_side_slot: Arc::new(OnceLock::new()), @@ -4265,6 +4553,8 @@ mod tests { let handler = HelperHandler { helper_id: HelperId(1), agent: Arc::new(OnceLock::new()), + replacement_agent: Arc::new(OnceLock::new()), + rotation_lock: Arc::new(Mutex::new(())), state: make_state(), notif_tx, agent_side_slot: Arc::new(OnceLock::new()), @@ -4290,6 +4580,7 @@ mod tests { let state = make_state(); let client = MasterClient { state: Arc::clone(&state), + agent_generation: 1, }; // No routing entry for this session — it's orphaned. let req = RequestPermissionRequest::new( @@ -4328,6 +4619,26 @@ mod tests { assert!(!is_already_loaded_error(&unrelated)); } + #[tokio::test] + async fn agent_reaper_does_not_remove_a_replacement_cell() { + let state = make_state(); + let key = "copilot --acp --stdio".to_string(); + let old_cell = Arc::new(tokio::sync::OnceCell::new()); + let replacement_cell = Arc::new(tokio::sync::OnceCell::new()); + state + .agents + .lock() + .await + .insert(key.clone(), Arc::clone(&replacement_cell)); + + reap_agent(&state, &key, 1, &old_cell).await; + + let agents = state.agents.lock().await; + assert!(agents + .get(&key) + .is_some_and(|cell| Arc::ptr_eq(cell, &replacement_cell))); + } + /// `reap_agent` must drop only the dead agent's orphan sessions, leaving /// a co-resident agent's (e.g. Gemini next to Copilot) orphans intact. #[tokio::test] @@ -4338,28 +4649,30 @@ mod tests { { let mut orphans = state.orphaned_sessions.lock().await; orphans - .entry(key_a.clone()) + .entry((key_a.clone(), 1)) .or_default() .insert(SessionId::new("a-sess")); orphans - .entry(key_b.clone()) + .entry((key_b.clone(), 1)) .or_default() .insert(SessionId::new("b-sess")); } // reap only acts when the key is a live pool entry. - { + let cell = { let mut agents = state.agents.lock().await; - agents.insert(key_a.clone(), Arc::new(tokio::sync::OnceCell::new())); - } - reap_agent(&state, &key_a).await; + let cell = Arc::new(tokio::sync::OnceCell::new()); + agents.insert(key_a.clone(), Arc::clone(&cell)); + cell + }; + reap_agent(&state, &key_a, 1, &cell).await; let orphans = state.orphaned_sessions.lock().await; assert!( - !orphans.contains_key(&key_a), + !orphans.contains_key(&(key_a.clone(), 1)), "reaped agent's orphan set must be dropped" ); assert!( orphans - .get(&key_b) + .get(&(key_b, 1)) .is_some_and(|s| s.contains(&SessionId::new("b-sess"))), "a co-resident agent's orphans must be untouched" ); @@ -4457,6 +4770,7 @@ mod tests { // reentrant request_permission back out to the owning helper. let master_client = MasterClient { state: Arc::clone(&state), + agent_generation: 1, }; let agent_conn = { let (cr, cw) = tokio::io::split(master_agent_pipe); @@ -4508,10 +4822,15 @@ mod tests { cli_source: Some(crate::agent_sessions::CliSource::Copilot), source: crate::agent_source::AgentSource::Host, cmd_key: "copilot --acp --stdio".to_string(), + generation: 1, + helper_bindings: AtomicU64::new(0), + shutdown: Arc::new(tokio::sync::Notify::new()), })); let handler = HelperHandler { helper_id: HelperId(1), agent, + replacement_agent: Arc::new(OnceLock::new()), + rotation_lock: Arc::new(Mutex::new(())), state: Arc::clone(&state), notif_tx: notif_tx.clone(), agent_side_slot: Arc::new(OnceLock::new()), @@ -4557,6 +4876,7 @@ mod tests { sid.clone(), HelperRoute { helper_id: HelperId(1), + agent_generation: 1, notif_tx, forwarder: Some(master_to_helper), consecutive_drops: Arc::new(std::sync::atomic::AtomicU64::new(0)), @@ -4649,10 +4969,39 @@ mod tests { async fn route(state: &Arc, notif: SessionNotification) { let client = MasterClient { state: Arc::clone(state), + agent_generation: 1, }; client.session_notification(notif).await.unwrap(); } + #[tokio::test] + async fn stale_agent_generation_cannot_notify_replacement_helper() { + let state = make_state(); + let sid = SessionId::new("rotated-session"); + let (tx, mut rx) = mpsc::channel(NOTIF_CHANNEL_CAPACITY); + state.session_to_helper.lock().await.insert( + sid.clone(), + HelperRoute { + helper_id: HelperId(1), + agent_generation: 2, + notif_tx: tx, + forwarder: None, + consecutive_drops: Arc::new(std::sync::atomic::AtomicU64::new(0)), + }, + ); + let stale_client = MasterClient { + state, + agent_generation: 1, + }; + + stale_client + .session_notification(make_notif(&sid)) + .await + .unwrap(); + + assert!(rx.try_recv().is_err()); + } + /// New `session_notification`s for a registered SessionId reach /// the owning helper's channel, and a second helper's channel /// stays untouched. @@ -4670,6 +5019,7 @@ mod tests { sid1.clone(), HelperRoute { helper_id: HelperId(1), + agent_generation: 1, notif_tx: tx1, forwarder: None, consecutive_drops: Arc::new(std::sync::atomic::AtomicU64::new(0)), @@ -4679,6 +5029,7 @@ mod tests { sid2.clone(), HelperRoute { helper_id: HelperId(2), + agent_generation: 1, notif_tx: tx2, forwarder: None, consecutive_drops: Arc::new(std::sync::atomic::AtomicU64::new(0)), @@ -4708,6 +5059,7 @@ mod tests { sid.clone(), HelperRoute { helper_id: HelperId(7), + agent_generation: 1, notif_tx: tx, forwarder: None, consecutive_drops: Arc::new(std::sync::atomic::AtomicU64::new(0)), @@ -4758,6 +5110,7 @@ mod tests { sid.clone(), HelperRoute { helper_id: HelperId(1), + agent_generation: 1, notif_tx: tx_a.clone(), forwarder: None, consecutive_drops: Arc::new(std::sync::atomic::AtomicU64::new(0)), @@ -4783,6 +5136,7 @@ mod tests { sid.clone(), HelperRoute { helper_id: HelperId(2), + agent_generation: 1, notif_tx: tx_b, forwarder: None, consecutive_drops: Arc::new(std::sync::atomic::AtomicU64::new(0)), @@ -4834,6 +5188,7 @@ mod tests { sid.clone(), HelperRoute { helper_id: HelperId(9), + agent_generation: 1, notif_tx: tx.clone(), forwarder: None, consecutive_drops: Arc::new(std::sync::atomic::AtomicU64::new(0)), @@ -4881,6 +5236,7 @@ mod tests { SessionId::new("a1"), HelperRoute { helper_id: HelperId(1), + agent_generation: 1, notif_tx: tx_a.clone(), forwarder: None, consecutive_drops: Arc::new(std::sync::atomic::AtomicU64::new(0)), @@ -4890,6 +5246,7 @@ mod tests { SessionId::new("a2"), HelperRoute { helper_id: HelperId(1), + agent_generation: 1, notif_tx: tx_a, forwarder: None, consecutive_drops: Arc::new(std::sync::atomic::AtomicU64::new(0)), @@ -4899,6 +5256,7 @@ mod tests { SessionId::new("b1"), HelperRoute { helper_id: HelperId(2), + agent_generation: 1, notif_tx: tx_b, forwarder: None, consecutive_drops: Arc::new(std::sync::atomic::AtomicU64::new(0)), @@ -4908,6 +5266,7 @@ mod tests { SessionId::new("c1"), HelperRoute { helper_id: HelperId(3), + agent_generation: 1, notif_tx: tx_c, forwarder: None, consecutive_drops: Arc::new(std::sync::atomic::AtomicU64::new(0)), @@ -4951,6 +5310,7 @@ mod tests { sid_a.clone(), HelperRoute { helper_id: HelperId(1), + agent_generation: 1, notif_tx: tx_a, forwarder: None, consecutive_drops: Arc::new(std::sync::atomic::AtomicU64::new(0)), @@ -4960,6 +5320,7 @@ mod tests { sid_b.clone(), HelperRoute { helper_id: HelperId(2), + agent_generation: 1, notif_tx: tx_b, forwarder: None, consecutive_drops: Arc::new(std::sync::atomic::AtomicU64::new(0)), @@ -5075,6 +5436,7 @@ mod tests { sid_a.clone(), HelperRoute { helper_id: HelperId(1), + agent_generation: 1, notif_tx: notif_tx1.clone(), forwarder: None, consecutive_drops: Arc::new(std::sync::atomic::AtomicU64::new(0)), @@ -5084,6 +5446,7 @@ mod tests { sid_b.clone(), HelperRoute { helper_id: HelperId(1), + agent_generation: 1, notif_tx: notif_tx1, forwarder: None, consecutive_drops: Arc::new(std::sync::atomic::AtomicU64::new(0)), @@ -5131,6 +5494,7 @@ mod tests { let state = make_state(); let client = MasterClient { state: Arc::clone(&state), + agent_generation: 1, }; let err = client .route_for(&SessionId::new("ghost"), "request_permission") @@ -5155,6 +5519,7 @@ mod tests { SessionId::new("orphan"), HelperRoute { helper_id: HelperId(42), + agent_generation: 1, notif_tx: tx, forwarder: None, consecutive_drops: Arc::new(std::sync::atomic::AtomicU64::new(0)), @@ -5163,6 +5528,7 @@ mod tests { } let client = MasterClient { state: Arc::clone(&state), + agent_generation: 1, }; let err = client .route_for(&SessionId::new("orphan"), "create_terminal") @@ -5181,6 +5547,7 @@ mod tests { let state = make_state(); let client = MasterClient { state: Arc::clone(&state), + agent_generation: 1, }; let req = acp::schema::v1::CreateTerminalRequest::new(SessionId::new("nobody-home"), "echo".to_string()); @@ -5225,12 +5592,16 @@ mod tests { let sid = SessionId::new("removed-a"); { let mut map = state.session_to_helper.lock().await; - map.insert(sid.clone(), HelperRoute { - helper_id: HelperId(1), - notif_tx, - forwarder: None, - consecutive_drops: Arc::new(std::sync::atomic::AtomicU64::new(0)), - }); + map.insert( + sid.clone(), + HelperRoute { + helper_id: HelperId(1), + agent_generation: 1, + notif_tx, + forwarder: None, + consecutive_drops: Arc::new(std::sync::atomic::AtomicU64::new(0)), + }, + ); } state.registry.upsert(SessionInfo::new(sid, PathBuf::from("C:\\repo"))).await; { @@ -5409,6 +5780,7 @@ mod tests { ) -> Arc { Arc::new(MasterStateInner { session_to_helper: Mutex::new(HashMap::new()), + ordered_replay_sessions: Mutex::new(HashSet::new()), registry: crate::session_registry::InMemoryRegistry::shared(), helper_ext_subscribers: Mutex::new(HashMap::new()), wt: Some(wt), From f6037e45a71b07cb85c687c774587c7aedce4691 Mon Sep 17 00:00:00 2001 From: "Yuandi.Zhang" Date: Tue, 21 Jul 2026 11:49:26 +0800 Subject: [PATCH 3/6] Polish restored agent pane history Render ACP replay through the existing TUI turn format, suppress the new-pane disclaimer during resume, and show a localized resuming indicator while history loads. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 849b07ad-c08a-4108-89fa-94723b91021c --- tools/wta/src/app.rs | 56 +++++++++++++++++++++++++++++++++++++++- tools/wta/src/ui/chat.rs | 19 +++++++++++++- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/tools/wta/src/app.rs b/tools/wta/src/app.rs index 3c4ac0583d..637889b2c3 100644 --- a/tools/wta/src/app.rs +++ b/tools/wta/src/app.rs @@ -5225,6 +5225,7 @@ impl App { .iter() .any(|m| !matches!(m, ChatMessage::Disclaimer)); if !has_real_content + && !tab.loading_session && !tab .messages .iter() @@ -5260,6 +5261,8 @@ impl App { .map(|t| t == session_id.as_str()) .unwrap_or(false); if tab.loading_session && is_load_target { + tab.messages + .retain(|message| !matches!(message, ChatMessage::Disclaimer)); tab.flush_load_replay_pending(); tab.pack_replayed_messages_into_turns(); tab.loading_session = false; @@ -11943,6 +11946,40 @@ mod tests { ); } + #[test] + fn agent_connected_does_not_add_disclaimer_while_resuming() { + let (mut app, _load_session_rx) = make_app_with_load_session_channel(); + app.tab_id = Some("OWNER-TAB".to_string()); + app.tab_sessions + .insert("OWNER-TAB".to_string(), TabSession::default()); + app.handle_event(AppEvent::WtEvent { + method: "load_session".to_string(), + pane_id: String::new(), + tab_id: None, + params: json!({ + "tab_id": "OWNER-TAB", + "session_id": "sess-target", + "cwd": "", + }), + }); + + app.handle_event(AppEvent::AgentConnected { + name: "Copilot".to_string(), + model: None, + version: None, + session_id: "sess-target".to_string(), + available_models: Vec::new(), + current_model_id: None, + load_session_supported: true, + image_supported: true, + }); + + assert!(!app.tab_sessions["OWNER-TAB"] + .messages + .iter() + .any(|message| matches!(message, ChatMessage::Disclaimer))); + } + /// TabError must clear both flags so a subsequent load can re-open /// the window cleanly. #[test] @@ -12131,6 +12168,7 @@ mod tests { }); // Simulate replay chunks landing in messages. let tab = app.tab_sessions.get_mut("OWNER-TAB").unwrap(); + tab.messages.push(ChatMessage::Disclaimer); tab.messages.push(ChatMessage::User("first prompt".to_string())); tab.messages.push(ChatMessage::Agent("first reply".to_string())); tab.messages.push(ChatMessage::User("second prompt".to_string())); @@ -12158,7 +12196,7 @@ mod tests { // `messages`. assert!( tab.messages.is_empty(), - "resume must not leave any loose chat messages, got {:?}", + "resume must not leave a disclaimer or loose chat messages, got {:?}", tab.messages ); } @@ -16584,6 +16622,22 @@ mod tests { ); } + #[test] + fn render_chat_resuming_activity_line() { + let mut app = test_app(); + app.state = ConnectionState::Connected; + let tab = app.current_tab_mut(); + tab.loading_session = true; + tab.loading_target_session_id = Some("12345678-rest".to_string()); + + let text = render_to_text(&mut app, 80, 24); + let label = t!("system.resuming_session", session_id = "12345678").into_owned(); + assert!( + text.contains(&label), + "chat must paint the resuming activity line ({label:?}); rendered:\n{text}" + ); + } + /// Render: the first-run welcome hint must paint its title when connected /// and `show_welcome_hint` is set. Lifts the welcome branch of /// `ui/chat.rs` + `ui/layout.rs`. diff --git a/tools/wta/src/ui/chat.rs b/tools/wta/src/ui/chat.rs index 62ee8cbe4f..abf566f486 100644 --- a/tools/wta/src/ui/chat.rs +++ b/tools/wta/src/ui/chat.rs @@ -32,7 +32,10 @@ pub fn estimated_block_height(app: &App, area_width: u16) -> u16 { let reveal_catching_up = pending_text .as_deref() .is_some_and(|text| tab.reveal_chars < text.chars().count()); - let activity = if tab.turn.spinner_label().is_some() && !reveal_catching_up { + let has_activity = matches!(app.state, crate::app::ConnectionState::Connecting(_)) + || tab.loading_session + || tab.turn.spinner_label().is_some(); + let activity = if has_activity && !reveal_catching_up { 1usize } else { 0 @@ -307,6 +310,20 @@ fn build_activity_line(app: &App) -> Option> { return Some(Line::from(shimmer::shimmer_spans(&label, app.activity_frame as usize))); } let tab = app.current_tab(); + if tab.loading_session { + let short_id: String = tab + .loading_target_session_id + .as_deref() + .unwrap_or_default() + .chars() + .take(8) + .collect(); + let label = t!("system.resuming_session", session_id = short_id).into_owned(); + return Some(Line::from(shimmer::shimmer_spans( + &label, + tab.activity_frame, + ))); + } if tab.turn.spinner_label().is_none() { return None; } From 1834326165905a15365c0cda07fa1b695a68382c Mon Sep 17 00:00:00 2001 From: "Yuandi.Zhang" Date: Sat, 25 Jul 2026 07:33:15 +0800 Subject: [PATCH 4/6] Complete restored conversation rendering Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/cascadia/TerminalApp/TerminalPage.cpp | 4 +--- tools/wta/src/app.rs | 2 ++ tools/wta/src/ui/chat.rs | 16 +++++++++++++++- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/cascadia/TerminalApp/TerminalPage.cpp b/src/cascadia/TerminalApp/TerminalPage.cpp index d8116d9e88..4c5efcb1f2 100644 --- a/src/cascadia/TerminalApp/TerminalPage.cpp +++ b/src/cascadia/TerminalApp/TerminalPage.cpp @@ -8439,9 +8439,7 @@ namespace winrt::TerminalApp::implementation const auto control = _CreateNewControlAndContent(controlSettings, connection); - // Agent CLIs replay their own transcript. Replaying the terminal buffer - // as well would duplicate the conversation on every restore cycle. - if (hasSessionId && !resumingAgentSession) + if (hasSessionId) { using namespace std::string_view_literals; diff --git a/tools/wta/src/app.rs b/tools/wta/src/app.rs index 637889b2c3..07e09a59e5 100644 --- a/tools/wta/src/app.rs +++ b/tools/wta/src/app.rs @@ -406,6 +406,8 @@ pub enum ConnectionState { pub enum ChatMessage { User(String), Agent(String), + /// App-generated agent-style text that should stay literal. + AgentLiteral(String), System(String), ToolCall { id: String, diff --git a/tools/wta/src/ui/chat.rs b/tools/wta/src/ui/chat.rs index abf566f486..ee54eaea88 100644 --- a/tools/wta/src/ui/chat.rs +++ b/tools/wta/src/ui/chat.rs @@ -89,7 +89,9 @@ fn message_height(msg: &ChatMessage, wrap_width: usize) -> usize { // "> " for user) and a trailing blank line. let body_width = wrap_width.saturating_sub(2).max(1); match msg { - ChatMessage::Agent(t) | ChatMessage::Error(t) => dot_wrap_count(t, body_width) + 1, + ChatMessage::Agent(t) | ChatMessage::AgentLiteral(t) | ChatMessage::Error(t) => { + dot_wrap_count(t, body_width) + 1 + } ChatMessage::User(t) => wrap_count(t, body_width) + 1, ChatMessage::System(t) | ChatMessage::AgentEvent(t) => wrap_count(t, wrap_width) + 1, ChatMessage::ToolCall { .. } => 1, @@ -541,6 +543,18 @@ fn build_message_lines<'a>( lines.push(Line::default()); } } + ChatMessage::AgentLiteral(text) => { + push_dot_prefixed_lines( + &mut lines, + text, + wrap_width, + theme::DOT_AGENT, + theme::AGENT_TEXT, + ); + if !agent_streaming || !is_last_message { + lines.push(Line::default()); + } + } ChatMessage::System(text) => { for line_text in text.lines() { lines.push(Line::from(Span::styled( From 0a5297573fc154dbf0507915ffe086a5a1695dfb Mon Sep 17 00:00:00 2001 From: "Yuandi.Zhang" Date: Sat, 25 Jul 2026 13:02:39 +0800 Subject: [PATCH 5/6] Avoid duplicate resumed CLI scrollback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/cascadia/TerminalApp/TerminalPage.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/cascadia/TerminalApp/TerminalPage.cpp b/src/cascadia/TerminalApp/TerminalPage.cpp index 4c5efcb1f2..d9be47188e 100644 --- a/src/cascadia/TerminalApp/TerminalPage.cpp +++ b/src/cascadia/TerminalApp/TerminalPage.cpp @@ -8439,7 +8439,9 @@ namespace winrt::TerminalApp::implementation const auto control = _CreateNewControlAndContent(controlSettings, connection); - if (hasSessionId) + // The resumed CLI replays its own transcript. Restoring the terminal + // buffer as well would duplicate that conversation on every restart. + if (hasSessionId && !resumingAgentSession) { using namespace std::string_view_literals; From 80c1a13f74232a6dc275e5b2dc2c8c9eabc0a30c Mon Sep 17 00:00:00 2001 From: "Yuandi.Zhang" Date: Mon, 27 Jul 2026 18:35:24 +0800 Subject: [PATCH 6/6] Defer persisted layouts until UI activation Keep COM-only Terminal activations headless, then restore saved windows on the first command-line, named-window, or ConPTY UI request without letting headless persistence overwrite pending layouts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../WindowsTerminal/WindowEmperor.cpp | 83 +++++++++++++------ src/cascadia/WindowsTerminal/WindowEmperor.h | 5 ++ 2 files changed, 64 insertions(+), 24 deletions(-) diff --git a/src/cascadia/WindowsTerminal/WindowEmperor.cpp b/src/cascadia/WindowsTerminal/WindowEmperor.cpp index fdda5b3bff..7686183172 100644 --- a/src/cascadia/WindowsTerminal/WindowEmperor.cpp +++ b/src/cascadia/WindowsTerminal/WindowEmperor.cpp @@ -281,6 +281,7 @@ void WindowEmperor::CreateNewWindow(winrt::TerminalApp::WindowRequestedArgs args auto host = std::make_shared(this, _app.Logic(), std::move(args)); host->Initialize(); + _hasCreatedWindow = true; _windowCount += 1; _windows.emplace_back(std::move(host)); @@ -324,6 +325,8 @@ void WindowEmperor::OpenWindow(const winrt::hstring& name) { _assertIsMainThread(); + _restorePersistedWindows(_startupCurrentDirectory, _startupEnvironment, _startupShowWindowCommand); + if (name.empty()) { return; @@ -598,25 +601,9 @@ void WindowEmperor::HandleCommandlineArgs(int nCmdShow) { const wil::unique_environstrings_ptr envMem{ GetEnvironmentStringsW() }; - const auto env = stringFromDoubleNullTerminated(envMem.get()); - const auto cwd = wil::GetCurrentDirectoryW(); - const auto showCmd = gsl::narrow_cast(nCmdShow); - - // Restore persisted windows. - const auto state = ApplicationState::SharedInstance(); - const auto layouts = state.PersistedWindowLayouts(); - if (layouts && layouts.Size() > 0) - { - _needsPersistenceCleanup = true; - - uint32_t startIdx = 0; - for (const auto layout : layouts) - { - hstring args[] = { L"wt", L"-w", L"new", L"-s", winrt::to_hstring(startIdx) }; - _dispatchCommandlineCommon(args, cwd, env, showCmd); - startIdx += 1; - } - } + _startupEnvironment = stringFromDoubleNullTerminated(envMem.get()); + _startupCurrentDirectory = wil::GetCurrentDirectoryW(); + _startupShowWindowCommand = gsl::narrow_cast(nCmdShow); const auto args = commandlineToArgArray(GetCommandLineW()); @@ -629,11 +616,7 @@ void WindowEmperor::HandleCommandlineArgs(int nCmdShow) } else { - // Create another window if needed: There aren't any yet, OR we got an explicit command line. - if (_windows.empty() || args.size() != 1) - { - _dispatchCommandlineCommon(args, cwd, env, showCmd); - } + _dispatchCommandlineCommon(args, _startupCurrentDirectory, _startupEnvironment, _startupShowWindowCommand); // If we created no windows, e.g. because the args are "/?" we can just exit now. _postQuitMessageIfNeeded(); @@ -649,6 +632,8 @@ void WindowEmperor::HandleCommandlineArgs(int nCmdShow) { TerminalConnection::ConptyConnection::NewConnection([this](TerminalConnection::ConptyConnection conn) { + _restorePersistedWindows(_startupCurrentDirectory, _startupEnvironment, _startupShowWindowCommand); + TerminalApp::CommandlineArgs args; args.ShowWindowCommand(conn.ShowWindow()); args.Connection(std::move(conn)); @@ -893,6 +878,12 @@ void WindowEmperor::_dispatchCommandline(winrt::TerminalApp::CommandlineArgs arg void WindowEmperor::_dispatchCommandlineCommon(winrt::array_view args, wil::zwstring_view currentDirectory, wil::zwstring_view envString, uint32_t showWindowCommand) { + const auto restoredPersistedWindows = _restorePersistedWindows(currentDirectory, envString, showWindowCommand); + if (restoredPersistedWindows && args.size() == 1) + { + return; + } + winrt::TerminalApp::CommandlineArgs c; c.Commandline(args); c.CurrentDirectory(currentDirectory); @@ -901,6 +892,38 @@ void WindowEmperor::_dispatchCommandlineCommon(winrt::array_view previousWindowCount; +} + // This is an implementation-detail of _dispatchCommandline(). safe_void_coroutine WindowEmperor::_dispatchCommandlineCurrentDesktop(winrt::TerminalApp::CommandlineArgs args) { @@ -1352,6 +1375,13 @@ void WindowEmperor::_setupSessionPersistence(bool enabled) void WindowEmperor::_persistState(const ApplicationState& state) const { + // A protocol-only COM server must not consume or overwrite the layout that + // a later user-visible activation still needs to restore. + if (!_hasCreatedWindow) + { + return; + } + // Calling an `ApplicationState` setter triggers a write to state.json. // With this if condition we avoid an unnecessary write when persistence is disabled. if (state.PersistedWindowLayouts()) @@ -1375,6 +1405,11 @@ void WindowEmperor::_finalizeSessionPersistence() const { using namespace std::string_view_literals; + if (!_hasCreatedWindow) + { + return; + } + if (_skipPersistence) { // We received WM_ENDSESSION and persisted the state. diff --git a/src/cascadia/WindowsTerminal/WindowEmperor.h b/src/cascadia/WindowsTerminal/WindowEmperor.h index 1caaea7f7c..6b7c8bca95 100644 --- a/src/cascadia/WindowsTerminal/WindowEmperor.h +++ b/src/cascadia/WindowsTerminal/WindowEmperor.h @@ -72,6 +72,7 @@ class WindowEmperor [[nodiscard]] static LRESULT __stdcall _wndProc(HWND window, UINT message, WPARAM wparam, LPARAM lparam) noexcept; AppHost* _mostRecentWindow() const noexcept; + bool _restorePersistedWindows(wil::zwstring_view currentDirectory, wil::zwstring_view envString, uint32_t showWindowCommand); void _createWindowMaybeRestoringWorkspace(uint64_t windowId, const winrt::hstring& windowName, winrt::TerminalApp::CommandlineArgs args); bool _summonWindow(const SummonWindowSelectionArgs& args) const; void _summonAllWindows() const; @@ -109,7 +110,11 @@ class WindowEmperor bool _notificationIconShown = false; bool _skipPersistence = false; bool _needsPersistenceCleanup = false; + bool _hasCreatedWindow = false; SafeDispatcherTimer _persistStateTimer; + std::wstring _startupCurrentDirectory; + std::wstring _startupEnvironment; + uint32_t _startupShowWindowCommand = SW_SHOWDEFAULT; std::optional _currentSystemThemeIsDark; int32_t _windowCount = 0; int32_t _messageBoxCount = 0;