diff --git a/doc/release-check-list.md b/doc/release-check-list.md index 5d9159e878..670e7a9d1b 100644 --- a/doc/release-check-list.md +++ b/doc/release-check-list.md @@ -234,7 +234,14 @@ Net effect: UT shrinks the manual matrix to "did the wiring and UI connect", not ### Shell integration and detection -- [ ] `C295` `[new]` `[E2E]` **Detected Autofix clicks remain isolated between tabs:** With errors detected in two tabs, clicking one tab's diagnostics button submits exactly one prompt for that tab and preserves the other tab's pending opt-in. _(E2E: `Feature.AutofixRouting`.)_ +- [ ] `C299` `[new]` `[E2E]` **Detected Autofix clicks remain isolated between tabs:** With errors detected in two tabs, clicking one tab's diagnostics button submits exactly one prompt for that tab and preserves the other tab's pending opt-in. _(E2E: `Feature.AutofixRouting`.)_ +- [ ] `C300` `[new]` `[E2E]` **Pane context captures the completed marked command:** A single context request returns the source pane metadata together with the completed command and its error output. _(#838; E2E: `Feature.PaneContext`.)_ +- [ ] `C301` `[new]` `[E2E]` **Pane context falls back to the newest unmarked output:** Shells without command marks return a bounded recent buffer tail with an explicit fallback reason. _(#838; E2E: `Feature.PaneContext`.)_ +- [ ] `C302` `[new]` `[E2E]` **Explicit pane context stays isolated from the focused tab and split:** Explicit context requests read the requested pane even while a different tab or split is focused. _(#838; E2E: `Feature.PaneContext`.)_ +- [ ] `C303` `[new]` `[E2E]` **Missing and closed pane context fails without active-pane fallback:** Stale or unknown pane IDs fail instead of leaking another pane's context. _(#838; E2E: `Feature.PaneContext`.)_ +- [ ] `C304` `[new]` `[E2E]` **Pane context metadata-only requests omit terminal content:** A zero line or character budget returns only pane metadata. _(#838; E2E: `Feature.PaneContext`.)_ +- [ ] `C305` `[new]` `[E2E]` **Pane context bounds preserve Unicode and truthful truncation:** Marked-command and buffer-tail captures honor their limits without splitting Unicode characters or hiding truncation. _(#838; E2E: `Feature.PaneContext`.)_ +- [ ] `C306` `[new]` `[E2E]` **Focused agent pane context resolves to its source terminal:** Default context requests use the agent pane's source shell, while explicit agent-pane requests fail. _(#838; E2E: `Feature.PaneContext`.)_ - [ ] `C087` `[E2E]` **PowerShell shell integration installed:** Supported PowerShell profiles emit command-finished events, including non-zero marks for PowerShell-level failures on Windows PowerShell 5.1. - [ ] `C219` `[new]` `[E2E]` **Bash / WSL shell integration installed:** Supported bash and WSL-bash profiles emit command-finished events, and the injected `PROMPT_COMMAND` is safe under `set -u` (no errors in strict-mode shells). _(#340.)_ diff --git a/src/cascadia/TerminalApp/TerminalPage.Protocol.cpp b/src/cascadia/TerminalApp/TerminalPage.Protocol.cpp index 794f38eaae..00f60fef39 100644 --- a/src/cascadia/TerminalApp/TerminalPage.Protocol.cpp +++ b/src/cascadia/TerminalApp/TerminalPage.Protocol.cpp @@ -164,6 +164,174 @@ namespace winrt::TerminalApp::implementation co_return result; } + // Keep UI-owned references in the caller's apartment; only immutable text + // and limits cross into the background operation. + static IAsyncOperation _buildBoundedPaneContext( + hstring text, + int32_t maxLines, + int32_t maxCharacters, + bool lastCommand) + { + co_await winrt::resume_background(); + + const auto utf8 = winrt::to_string(text); + const auto bounded = lastCommand + ? ProtocolParsing::BuildBoundedCommand(utf8, maxLines, maxCharacters) + : ProtocolParsing::BuildBoundedBufferTail(utf8, maxLines, maxCharacters); + Protocol::PaneContext result{}; + result.Content = winrt::to_hstring(bounded.content); + result.LineCount = bounded.lineCount; + result.Truncated = bounded.truncated; + co_return result; + } + + IAsyncOperation TerminalPage::GetProtocolPaneContext( + winrt::guid sourceSessionId, + bool hasExplicitSource, + int32_t maxLines, + int32_t maxCharacters) + { + auto strong = get_strong(); + co_await wil::resume_foreground(Dispatcher()); + + Protocol::PaneContext result{}; + std::shared_ptr targetPane; + uint32_t targetTabIndex = 0; + + if (hasExplicitSource) + { + for (uint32_t tabIndex = 0; tabIndex < _tabs.Size() && !targetPane; ++tabIndex) + { + const auto tabImpl = _GetTabImpl(_tabs.GetAt(tabIndex)); + const auto rootPane = tabImpl ? tabImpl->GetRootPane() : nullptr; + if (rootPane) + { + targetPane = rootPane->FindPaneBySessionId(sourceSessionId); + if (targetPane) + { + targetTabIndex = tabIndex; + } + } + } + } + else if (const auto focusedTabIndex = _GetFocusedTabIndex()) + { + targetTabIndex = focusedTabIndex.value(); + if (const auto tabImpl = _GetTabImpl(_tabs.GetAt(targetTabIndex))) + { + targetPane = tabImpl->GetActivePane(); + if (targetPane && targetPane->IsAgentPane()) + { + if (const auto rootPane = tabImpl->GetRootPane()) + { + rootPane->WalkTree([&](const auto& pane) { + if (pane->IsSourceOfAgentPane()) + { + targetPane = pane; + } + }); + } + } + } + } + + const auto sessionId = targetPane ? _getSessionIdFromPane(targetPane) : winrt::guid{}; + if (!targetPane || sessionId == winrt::guid{} || targetPane->IsAgentPane()) + { + co_return result; + } + + Protocol::PaneInfo paneInfo{}; + paneInfo.SessionId = sessionId; + paneInfo.TabId = targetTabIndex; + paneInfo.IsAgentPane = false; + paneInfo.Pid = _getPidFromPane(targetPane); + + if (const auto tabImpl = _GetTabImpl(_tabs.GetAt(targetTabIndex))) + { + const auto activePane = tabImpl->GetActivePane(); + paneInfo.IsActive = activePane && activePane->IsAgentPane() + ? targetPane->IsSourceOfAgentPane() + : activePane == targetPane; + } + + if (const auto termContent = targetPane->GetContent().try_as()) + { + paneInfo.Title = termContent.Title(); + const auto profile = termContent.GetProfile(); + paneInfo.Profile = profile ? profile.Name() : L""; + } + + const auto termControl = targetPane->GetTerminalControl(); + if (!termControl) + { + co_return result; + } + + paneInfo.Rows = termControl.ViewHeight(); + paneInfo.Columns = termControl.ViewWidth(); + paneInfo.Cwd = termControl.WorkingDirectory(); + paneInfo.Shell = termControl.ShellName(); + paneInfo.ShellVersion = termControl.ShellVersion(); + result.Pane = paneInfo; + + if (maxLines == 0 || maxCharacters == 0) + { + result.OutputSource = L"metadata_only"; + co_return result; + } + + hstring bufferTail; + try + { + const auto lastCommand = termControl.ReadLastPromptBounded(maxLines + 1, maxCharacters + 1); + if (!lastCommand.empty()) + { + const auto bounded = co_await _buildBoundedPaneContext( + lastCommand, + maxLines, + maxCharacters, + true); + result.Content = bounded.Content; + result.OutputSource = L"last_command"; + result.LineCount = bounded.LineCount; + result.Truncated = bounded.Truncated; + result.HasMarks = true; + co_return result; + } + + result.OutputSource = L"buffer_tail"; + result.FallbackReason = L"marks_unavailable"; + bufferTail = termControl.ReadBufferTail(maxLines + 1, maxCharacters + maxLines + 2); + } + catch (...) + { + LOG_CAUGHT_EXCEPTION(); + result.OutputSource = L"buffer_tail"; + result.FallbackReason = L"last_command_error"; + try + { + bufferTail = termControl.ReadBufferTail(maxLines + 1, maxCharacters + maxLines + 2); + } + catch (...) + { + LOG_CAUGHT_EXCEPTION(); + result.Pane = {}; + co_return result; + } + } + + const auto bounded = co_await _buildBoundedPaneContext( + bufferTail, + maxLines, + maxCharacters, + false); + result.Content = bounded.Content; + result.LineCount = bounded.LineCount; + result.Truncated = bounded.Truncated; + co_return result; + } + IAsyncOperation> TerminalPage::GetProtocolTabs() { auto strong = get_strong(); diff --git a/src/cascadia/TerminalApp/TerminalPage.h b/src/cascadia/TerminalApp/TerminalPage.h index 450a2d41e0..96993e986b 100644 --- a/src/cascadia/TerminalApp/TerminalPage.h +++ b/src/cascadia/TerminalApp/TerminalPage.h @@ -248,6 +248,7 @@ namespace winrt::TerminalApp::implementation Windows::Foundation::IAsyncOperation> GetProtocolTabs(); Windows::Foundation::IAsyncOperation> GetProtocolPanes(uint32_t tabIdFilter); Windows::Foundation::IAsyncOperation ReadProtocolPaneOutput(winrt::guid sessionId, hstring source, int32_t maxLines); + Windows::Foundation::IAsyncOperation GetProtocolPaneContext(winrt::guid sourceSessionId, bool hasExplicitSource, int32_t maxLines, int32_t maxCharacters); Windows::Foundation::IAsyncOperation GetProtocolProcessStatus(winrt::guid sessionId); Windows::Foundation::IAsyncOperation GetProtocolSessionVariable(winrt::guid sessionId, hstring name); Windows::Foundation::IAsyncOperation SetProtocolSessionVariable(winrt::guid sessionId, hstring name, hstring value); diff --git a/src/cascadia/TerminalApp/TerminalPage.idl b/src/cascadia/TerminalApp/TerminalPage.idl index 850345e733..e6878814c4 100644 --- a/src/cascadia/TerminalApp/TerminalPage.idl +++ b/src/cascadia/TerminalApp/TerminalPage.idl @@ -144,6 +144,7 @@ namespace TerminalApp Windows.Foundation.IAsyncOperation > GetProtocolTabs(); Windows.Foundation.IAsyncOperation > GetProtocolPanes(UInt32 tabIdFilter); Windows.Foundation.IAsyncOperation ReadProtocolPaneOutput(Guid sessionId, String source, Int32 maxLines); + Windows.Foundation.IAsyncOperation GetProtocolPaneContext(Guid sourceSessionId, Boolean hasExplicitSource, Int32 maxLines, Int32 maxCharacters); Windows.Foundation.IAsyncOperation GetProtocolProcessStatus(Guid sessionId); Windows.Foundation.IAsyncOperation GetProtocolSessionVariable(Guid sessionId, String name); Windows.Foundation.IAsyncOperation SetProtocolSessionVariable(Guid sessionId, String name, String value); diff --git a/src/cascadia/TerminalControl/ControlCore.cpp b/src/cascadia/TerminalControl/ControlCore.cpp index 84e30cfbeb..73ccd1e516 100644 --- a/src/cascadia/TerminalControl/ControlCore.cpp +++ b/src/cascadia/TerminalControl/ControlCore.cpp @@ -9,6 +9,7 @@ #include #include +#include #include #include "EventArgs.h" @@ -1632,6 +1633,12 @@ namespace winrt::Microsoft::Terminal::Control::implementation return _terminal->GetViewport().Height(); } + int ControlCore::ViewWidth() const + { + const auto lock = _terminal->LockForReading(); + return _terminal->GetViewport().Width(); + } + // Function Description: // - Gets the height of the terminal in lines of text. This includes the // history AND the viewport. @@ -2453,6 +2460,68 @@ namespace winrt::Microsoft::Terminal::Control::implementation return hstring{ str }; } + hstring ControlCore::ReadBufferTail(const int32_t maxLogicalLines, const int32_t maxCharacters) const + { + THROW_HR_IF(E_INVALIDARG, maxLogicalLines <= 0 || maxCharacters <= 0); + + const auto lock = _terminal->LockForReading(); + const auto& textBuffer = _terminal->GetTextBuffer(); + const auto lastRow = textBuffer.GetLastNonSpaceCharacter().y; + + std::vector chunks; + auto remainingCharacters = static_cast(maxCharacters); + int32_t logicalLines = 1; + + for (auto rowIndex = lastRow;; --rowIndex) + { + const auto& row = textBuffer.GetRowByOffset(rowIndex); + const auto rowText = row.GetText(); + const auto strEnd = rowText.find_last_not_of(UNICODE_SPACE); + + std::wstring chunk; + if (strEnd != decltype(rowText)::npos) + { + chunk.assign(rowText.substr(0, strEnd + 1)); + } + if (!row.WasWrapForced()) + { + chunk.append(L"\r\n"); + } + + auto start = chunk.size(); + size_t selectedCharacters = 0; + while (start > 0 && selectedCharacters < remainingCharacters) + { + start = til::utf16_iterate_prev(chunk, start); + ++selectedCharacters; + } + chunks.emplace_back(chunk.substr(start)); + remainingCharacters -= selectedCharacters; + + if (remainingCharacters == 0 || rowIndex == 0) + { + break; + } + + const auto previousRow = rowIndex - 1; + if (!textBuffer.GetRowByOffset(previousRow).WasWrapForced()) + { + if (logicalLines >= maxLogicalLines) + { + break; + } + ++logicalLines; + } + } + + std::wstring result; + for (auto it = chunks.rbegin(); it != chunks.rend(); ++it) + { + result.append(*it); + } + return hstring{ result }; + } + // Returns the most recent *finished* shell prompt — the command typed // at an OSC 133;B mark plus its output, sliced exactly between the // command-start and command-end markers. Used by external agents @@ -2517,6 +2586,69 @@ namespace winrt::Microsoft::Terminal::Control::implementation return {}; } + hstring ControlCore::ReadLastPromptBounded(const int32_t maxLogicalLines, const int32_t maxCharacters) const + { + THROW_HR_IF(E_INVALIDARG, maxLogicalLines <= 0 || maxCharacters <= 0); + + const auto lock = _terminal->LockForReading(); + const auto& marks = _terminal->GetMarkExtents(); + const auto& textBuffer = _terminal->GetTextBuffer(); + + for (auto it = marks.rbegin(); it != marks.rend(); ++it) + { + if (!it->HasCommand() || !it->data.exitCode.has_value()) + { + continue; + } + + const auto startPoint = it->end; + const auto endPoint = it->outputEnd.value_or(*it->commandEnd); + std::wstring result; + auto remainingCharacters = static_cast(maxCharacters); + int32_t logicalLines = 1; + + for (auto rowIndex = startPoint.y; rowIndex <= endPoint.y && remainingCharacters > 0; ++rowIndex) + { + const auto& row = textBuffer.GetRowByOffset(rowIndex); + auto rowBegin = rowIndex == startPoint.y ? startPoint.x : 0; + auto rowEnd = rowIndex == endPoint.y ? endPoint.x : row.GetReadableColumnCount(); + rowBegin = row.AdjustToGlyphStart(rowBegin); + rowEnd = row.AdjustToGlyphEnd(rowEnd); + const auto rowText = row.GetText(rowBegin, rowEnd); + + size_t textEnd = 0; + size_t selectedCharacters = 0; + while (textEnd < rowText.size() && selectedCharacters < remainingCharacters) + { + textEnd = til::utf16_iterate_next(rowText, textEnd); + ++selectedCharacters; + } + result.append(rowText.substr(0, textEnd)); + remainingCharacters -= selectedCharacters; + + if (textEnd < rowText.size() || rowIndex == endPoint.y) + { + break; + } + + if (!row.WasWrapForced()) + { + if (logicalLines >= maxLogicalLines || remainingCharacters == 0) + { + break; + } + result.push_back(L'\n'); + --remainingCharacters; + ++logicalLines; + } + } + + return hstring{ result }; + } + + return {}; + } + // Get all of our recent commands. This will only really work if the user has enabled shell integration. Control::CommandHistoryContext ControlCore::CommandHistory() const { diff --git a/src/cascadia/TerminalControl/ControlCore.h b/src/cascadia/TerminalControl/ControlCore.h index 4a85fb75c2..80c32bebd1 100644 --- a/src/cascadia/TerminalControl/ControlCore.h +++ b/src/cascadia/TerminalControl/ControlCore.h @@ -195,6 +195,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation int ScrollOffset(); int ViewHeight() const; + int ViewWidth() const; int BufferHeight() const; bool HasSelection() const; @@ -265,7 +266,9 @@ namespace winrt::Microsoft::Terminal::Control::implementation void SetReadOnlyMode(const bool readOnlyState); hstring ReadEntireBuffer() const; + hstring ReadBufferTail(int32_t maxLogicalLines, int32_t maxCharacters) const; hstring ReadLastPrompt() const; + hstring ReadLastPromptBounded(int32_t maxLogicalLines, int32_t maxCharacters) const; Control::CommandHistoryContext CommandHistory() const; bool QuickFixesAvailable() const noexcept; void UpdateQuickFixes(const Windows::Foundation::Collections::IVector& quickFixes); diff --git a/src/cascadia/TerminalControl/ControlCore.idl b/src/cascadia/TerminalControl/ControlCore.idl index 23c747b8e5..de9d3fcfc3 100644 --- a/src/cascadia/TerminalControl/ControlCore.idl +++ b/src/cascadia/TerminalControl/ControlCore.idl @@ -171,7 +171,9 @@ namespace Microsoft.Terminal.Control void EnablePainting(); String ReadEntireBuffer(); + String ReadBufferTail(Int32 maxLogicalLines, Int32 maxCharacters); String ReadLastPrompt(); + String ReadLastPromptBounded(Int32 maxLogicalLines, Int32 maxCharacters); CommandHistoryContext CommandHistory(); Boolean QuickFixesAvailable { get; }; diff --git a/src/cascadia/TerminalControl/ICoreState.idl b/src/cascadia/TerminalControl/ICoreState.idl index c20bfc6678..90baf056da 100644 --- a/src/cascadia/TerminalControl/ICoreState.idl +++ b/src/cascadia/TerminalControl/ICoreState.idl @@ -66,5 +66,6 @@ namespace Microsoft.Terminal.Control void SelectOutput(Boolean goUp); IVector ScrollMarks { get; }; + Int32 ViewWidth { get; }; }; } diff --git a/src/cascadia/TerminalControl/TermControl.cpp b/src/cascadia/TerminalControl/TermControl.cpp index fbaa985667..4d4a775702 100644 --- a/src/cascadia/TerminalControl/TermControl.cpp +++ b/src/cascadia/TerminalControl/TermControl.cpp @@ -2747,6 +2747,11 @@ namespace winrt::Microsoft::Terminal::Control::implementation return _core.ViewHeight(); } + int TermControl::ViewWidth() const + { + return _core.ViewWidth(); + } + int TermControl::BufferHeight() const { return _core.BufferHeight(); @@ -3828,10 +3833,18 @@ namespace winrt::Microsoft::Terminal::Control::implementation { return _core.ReadEntireBuffer(); } + hstring TermControl::ReadBufferTail(const int32_t maxLogicalLines, const int32_t maxCharacters) const + { + return _core.ReadBufferTail(maxLogicalLines, maxCharacters); + } hstring TermControl::ReadLastPrompt() const { return _core.ReadLastPrompt(); } + hstring TermControl::ReadLastPromptBounded(const int32_t maxLogicalLines, const int32_t maxCharacters) const + { + return _core.ReadLastPromptBounded(maxLogicalLines, maxCharacters); + } Control::CommandHistoryContext TermControl::CommandHistory() const { return _core.CommandHistory(); diff --git a/src/cascadia/TerminalControl/TermControl.h b/src/cascadia/TerminalControl/TermControl.h index b7ccf55e08..d5a366d381 100644 --- a/src/cascadia/TerminalControl/TermControl.h +++ b/src/cascadia/TerminalControl/TermControl.h @@ -101,6 +101,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation int ScrollOffset() const; int ViewHeight() const; + int ViewWidth() const; int BufferHeight() const; bool HasSelection() const; @@ -176,7 +177,9 @@ namespace winrt::Microsoft::Terminal::Control::implementation static Windows::UI::Xaml::Thickness ParseThicknessFromPadding(const hstring padding); hstring ReadEntireBuffer() const; + hstring ReadBufferTail(int32_t maxLogicalLines, int32_t maxCharacters) const; hstring ReadLastPrompt() const; + hstring ReadLastPromptBounded(int32_t maxLogicalLines, int32_t maxCharacters) const; Control::CommandHistoryContext CommandHistory() const; void UpdateWinGetSuggestions(Windows::Foundation::Collections::IVector suggestions); diff --git a/src/cascadia/TerminalControl/TermControl.idl b/src/cascadia/TerminalControl/TermControl.idl index 0c0c27270b..dfaf9c0d77 100644 --- a/src/cascadia/TerminalControl/TermControl.idl +++ b/src/cascadia/TerminalControl/TermControl.idl @@ -141,7 +141,9 @@ namespace Microsoft.Terminal.Control void SetReadOnly(Boolean readOnlyState); String ReadEntireBuffer(); + String ReadBufferTail(Int32 maxLogicalLines, Int32 maxCharacters); String ReadLastPrompt(); + String ReadLastPromptBounded(Int32 maxLogicalLines, Int32 maxCharacters); CommandHistoryContext CommandHistory(); void UpdateWinGetSuggestions(Windows.Foundation.Collections.IVector suggestions); diff --git a/src/cascadia/TerminalProtocol/ProtocolParsing.h b/src/cascadia/TerminalProtocol/ProtocolParsing.h index 211b07fe83..dbc01d25b1 100644 --- a/src/cascadia/TerminalProtocol/ProtocolParsing.h +++ b/src/cascadia/TerminalProtocol/ProtocolParsing.h @@ -7,8 +7,11 @@ #pragma once +#include #include #include +#include +#include #include @@ -225,4 +228,139 @@ namespace Microsoft::Terminal::Protocol::Parsing } return PaneOutputSource::Scrollback; } + + struct BoundedContextText + { + std::string content; + int32_t lineCount = 0; + bool truncated = false; + }; + + inline size_t Utf8PrefixBytes(const std::string_view text, const size_t maxCharacters) + { + size_t offset = 0; + size_t characters = 0; + while (offset < text.size() && characters < maxCharacters) + { + ++offset; + while (offset < text.size() && (static_cast(text[offset]) & 0xC0) == 0x80) + { + ++offset; + } + ++characters; + } + return offset; + } + + inline size_t Utf8TailOffset(const std::string_view text, const size_t maxCharacters) + { + size_t offset = text.size(); + size_t characters = 0; + while (offset > 0 && characters < maxCharacters) + { + --offset; + while (offset > 0 && (static_cast(text[offset]) & 0xC0) == 0x80) + { + --offset; + } + ++characters; + } + return offset; + } + + inline int32_t CountContextLines(const std::string_view text) + { + return text.empty() ? 0 : 1 + static_cast(std::count(text.begin(), text.end(), '\n')); + } + + inline BoundedContextText BuildBoundedCommand(const std::string_view text, const int32_t maxLines, const int32_t maxCharacters) + { + if (maxLines <= 0 || maxCharacters <= 0 || text.empty()) + { + return {}; + } + + std::vector lines; + std::istringstream stream{ std::string{ text } }; + std::string line; + while (std::getline(stream, line)) + { + if (!line.empty() && line.back() == '\r') + { + line.pop_back(); + } + lines.emplace_back(std::move(line)); + } + // The bounded reader may stop immediately after a newline. Preserve + // that lookahead character so truncation is not mistaken for EOF. + if (text.back() == '\n') + { + lines.emplace_back(); + } + + const auto lineLimit = std::min(lines.size(), static_cast(maxLines)); + std::string content; + for (size_t index = 0; index < lineLimit; ++index) + { + if (index > 0) + { + content.push_back('\n'); + } + content.append(lines[index]); + } + + const auto end = Utf8PrefixBytes(content, static_cast(maxCharacters)); + const auto truncated = lineLimit < lines.size() || end < content.size(); + content.resize(end); + return { + content, + CountContextLines(content), + truncated, + }; + } + + inline BoundedContextText BuildBoundedBufferTail(const std::string_view text, const int32_t maxLines, const int32_t maxCharacters) + { + if (maxLines <= 0 || maxCharacters <= 0 || text.empty()) + { + return {}; + } + + std::vector lines; + std::istringstream stream{ std::string{ text } }; + std::string line; + while (std::getline(stream, line)) + { + if (!line.empty() && line.back() == '\r') + { + line.pop_back(); + } + lines.emplace_back(std::move(line)); + } + + const auto lineLimit = static_cast(maxLines); + const auto startLine = lines.size() > lineLimit ? lines.size() - lineLimit : 0; + std::string content; + for (auto index = startLine; index < lines.size(); ++index) + { + if (!content.empty()) + { + content.push_back('\n'); + } + content.append(lines[index]); + } + + const auto characterStart = Utf8TailOffset(content, static_cast(maxCharacters)); + const auto truncated = startLine > 0 || characterStart > 0; + if (characterStart > 0) + { + content.erase(0, characterStart); + } + + return { + content, + CountContextLines(content), + truncated, + }; + } } diff --git a/src/cascadia/TerminalProtocol/TerminalProtocol.idl b/src/cascadia/TerminalProtocol/TerminalProtocol.idl index 8b4163252d..f1b039cfee 100644 --- a/src/cascadia/TerminalProtocol/TerminalProtocol.idl +++ b/src/cascadia/TerminalProtocol/TerminalProtocol.idl @@ -58,6 +58,17 @@ namespace Microsoft.Terminal.Protocol Boolean HasMarks; }; + struct PaneContext + { + PaneInfo Pane; + String Content; + String OutputSource; + String FallbackReason; + Int32 LineCount; + Boolean Truncated; + Boolean HasMarks; + }; + struct ProcessStatus { Guid SessionId; @@ -131,5 +142,9 @@ namespace Microsoft.Terminal.Protocol // Client-originated event publishing (agent → WT → listeners) void SendEvent(String eventJson); + + // Appended in protocol 2.3. Resolves the explicit or effective source + // pane and captures bounded context in one operation. + PaneContext GetPaneContext(Guid sourceSessionId, Boolean hasExplicitSource, Int32 maxLines, Int32 maxCharacters); } } diff --git a/src/cascadia/UnitTests_Control/ControlCoreTests.cpp b/src/cascadia/UnitTests_Control/ControlCoreTests.cpp index 7a0ef5134f..66efa2a2d1 100644 --- a/src/cascadia/UnitTests_Control/ControlCoreTests.cpp +++ b/src/cascadia/UnitTests_Control/ControlCoreTests.cpp @@ -37,6 +37,7 @@ namespace ControlUnitTests TEST_METHOD(TestClearScreen); TEST_METHOD(TestClearAll); TEST_METHOD(TestReadEntireBuffer); + TEST_METHOD(TestReadBufferTail); TEST_METHOD(TestSelectCommandSimple); TEST_METHOD(TestSelectOutputSimple); @@ -129,6 +130,14 @@ namespace ControlUnitTests #endif VERIFY_IS_TRUE(core->_initializedTerminal); VERIFY_ARE_EQUAL(30, core->_terminal->GetViewport().Width()); + const auto state = core.as(); + VERIFY_ARE_EQUAL(30, state.ViewWidth()); + VERIFY_ARE_EQUAL(20, state.ViewHeight()); + + core->SizeChanged(450, 380); + VERIFY_ARE_EQUAL(50, core->_terminal->GetViewport().Width()); + VERIFY_ARE_EQUAL(50, state.ViewWidth()); + VERIFY_ARE_EQUAL(20, state.ViewHeight()); } void ControlCoreTests::TestAdjustAcrylic() @@ -369,6 +378,41 @@ namespace ControlUnitTests core->ReadEntireBuffer()); } + void ControlCoreTests::TestReadBufferTail() + { + auto [settings, conn] = _createSettingsAndConnection(); + auto core = createCore(*settings, *conn); + VERIFY_IS_NOT_NULL(core); + _standardInit(core); + + for (auto i = 0; i < 200; ++i) + { + conn->WriteInput(winrt_wstring_to_array_view(fmt::format(L"line-{:03}\r\n", i))); + } + + VERIFY_ARE_EQUAL(L"line-199\r\n", core->ReadBufferTail(1, 100)); + VERIFY_ARE_EQUAL( + L"line-197\r\nline-198\r\nline-199\r\n", + core->ReadBufferTail(3, 100)); + VERIFY_ARE_EQUAL(L"-199\r\n", core->ReadBufferTail(10, 6)); + + VERIFY_THROWS_SPECIFIC( + core->ReadBufferTail(0, 100), + wil::ResultException, + [](const wil::ResultException& e) { return e.GetErrorCode() == E_INVALIDARG; }); + VERIFY_THROWS_SPECIFIC( + core->ReadBufferTail(1, 0), + wil::ResultException, + [](const wil::ResultException& e) { return e.GetErrorCode() == E_INVALIDARG; }); + + auto [unicodeSettings, unicodeConn] = _createSettingsAndConnection(); + auto unicodeCore = createCore(*unicodeSettings, *unicodeConn); + VERIFY_IS_NOT_NULL(unicodeCore); + _standardInit(unicodeCore); + unicodeConn->WriteInput(winrt_wstring_to_array_view(L"A\U0001F366B\r\n")); + VERIFY_ARE_EQUAL(L"\U0001F366B\r\n", unicodeCore->ReadBufferTail(1, 4)); + } + static void _writePrompt(const winrt::com_ptr& conn, const std::wstring_view& path) { conn->WriteInput(winrt_wstring_to_array_view(L"\x1b]133;D\x7")); diff --git a/src/cascadia/WindowsTerminal/TerminalProtocolComServer.cpp b/src/cascadia/WindowsTerminal/TerminalProtocolComServer.cpp index 15b5852760..ae615c35e8 100644 --- a/src/cascadia/WindowsTerminal/TerminalProtocolComServer.cpp +++ b/src/cascadia/WindowsTerminal/TerminalProtocolComServer.cpp @@ -280,6 +280,19 @@ static Json::Value _toJson(const Protocol::PaneOutput& o) return v; } +static Json::Value _toJson(const Protocol::PaneContext& context) +{ + Json::Value v; + v["pane"] = _toJson(context.Pane); + v["content"] = winrt::to_string(context.Content); + v["output_source"] = winrt::to_string(context.OutputSource); + v["fallback_reason"] = winrt::to_string(context.FallbackReason); + v["line_count"] = context.LineCount; + v["truncated"] = static_cast(context.Truncated); + v["has_marks"] = static_cast(context.HasMarks); + return v; +} + static Json::Value _toJson(const Protocol::ProcessStatus& s) { Json::Value v; @@ -505,8 +518,8 @@ try // ITerminalProtocol method is gated on this call. Json::Value v; v["authenticated"] = true; - // 2.2 — SendInput restored on the COM surface; pane identifiers remain GUIDs. - v["protocol_version"] = "2.2"; + // 2.3 — GetPaneContext resolves and captures bounded pane context in one call. + v["protocol_version"] = "2.3"; *resultJson = _bstrFromJson(v); return S_OK; } @@ -538,6 +551,7 @@ try "subscribe", "unsubscribe", "send_event", + "get_pane_context", }; Json::Value methods(Json::arrayValue); @@ -714,6 +728,67 @@ try } CATCH_RETURN() +STDMETHODIMP TerminalProtocolComServer::GetPaneContext( + GUID sourceSessionId, + boolean hasExplicitSource, + long maxLines, + long maxCharacters, + BSTR* json) +try +{ + RETURN_HR_IF_NULL(E_POINTER, json); + *json = nullptr; + RETURN_HR_IF(E_NOT_VALID_STATE, !s_emperor); + + constexpr long MaxContextLines = 1000; + constexpr long MaxContextCharacters = 100000; + RETURN_HR_IF(E_INVALIDARG, maxLines < 0 || maxLines > MaxContextLines); + RETURN_HR_IF(E_INVALIDARG, maxCharacters < 0 || maxCharacters > MaxContextCharacters); + + const auto windows = s_emperor->GetWindows(); + if (hasExplicitSource) + { + RETURN_HR_IF(E_INVALIDARG, InlineIsEqualGUID(sourceSessionId, GUID{})); + + for (const auto& host : windows) + { + const auto page = _getPage(host.get()); + if (!page) + { + continue; + } + + auto context = page.GetProtocolPaneContext( + winrt::guid{ sourceSessionId }, + true, + maxLines, + maxCharacters) + .get(); + if (context.Pane.SessionId != winrt::guid{}) + { + context.Pane.WindowId = host->Logic().WindowProperties().WindowId(); + *json = _bstrFromJson(_toJson(context)); + return S_OK; + } + } + return HRESULT_FROM_WIN32(ERROR_NOT_FOUND); + } + + const auto host = _getMostRecentHost(windows); + RETURN_HR_IF(E_FAIL, !host); + + const auto page = _getPage(host.get()); + RETURN_HR_IF(E_FAIL, !page); + + auto context = page.GetProtocolPaneContext({}, false, maxLines, maxCharacters).get(); + RETURN_HR_IF(E_FAIL, context.Pane.SessionId == winrt::guid{}); + + context.Pane.WindowId = host->Logic().WindowProperties().WindowId(); + *json = _bstrFromJson(_toJson(context)); + return S_OK; +} +CATCH_RETURN() + STDMETHODIMP TerminalProtocolComServer::GetProcessStatus(GUID sessionId, BSTR* json) try { diff --git a/src/cascadia/WindowsTerminal/TerminalProtocolComServer.h b/src/cascadia/WindowsTerminal/TerminalProtocolComServer.h index 1dc49bc821..2ff89c8fbb 100644 --- a/src/cascadia/WindowsTerminal/TerminalProtocolComServer.h +++ b/src/cascadia/WindowsTerminal/TerminalProtocolComServer.h @@ -61,6 +61,7 @@ TerminalProtocolComServer : public Microsoft::WRL::RuntimeClass< STDMETHODIMP Subscribe(ITerminalProtocolEventSink* sink) override; STDMETHODIMP Unsubscribe() override; STDMETHODIMP SendEvent(BSTR eventJson) override; + STDMETHODIMP GetPaneContext(GUID sourceSessionId, boolean hasExplicitSource, long maxLines, long maxCharacters, BSTR* json) override; // Static setup — must be called before s_StartListening(). static void s_setEmperor(WindowEmperor* emperor) noexcept; diff --git a/src/cascadia/ut_app/ProtocolParsingTests.cpp b/src/cascadia/ut_app/ProtocolParsingTests.cpp index ce8aa6473d..850e5e9946 100644 --- a/src/cascadia/ut_app/ProtocolParsingTests.cpp +++ b/src/cascadia/ut_app/ProtocolParsingTests.cpp @@ -17,6 +17,8 @@ namespace TerminalAppUnitTests TEST_METHOD(DefaultPasteRequestUsesDirectRoute); TEST_METHOD(AgentSessionsRetiredUsesDirectRoute); TEST_METHOD(RestartRequestIdentityIsStampedOnce); + TEST_METHOD(BoundedCommandPreservesUtf8Characters); + TEST_METHOD(BoundedBufferTailAppliesLineAndCharacterLimits); }; void ProtocolParsingTests::DefaultPasteRequestUsesDirectRoute() @@ -53,4 +55,82 @@ namespace TerminalAppUnitTests VERIFY_ARE_EQUAL("request-1", event["params"]["request_id"].asString()); } + + void ProtocolParsingTests::BoundedCommandPreservesUtf8Characters() + { + const auto result = BuildBoundedCommand("a\xF0\x9F\x8D\xA6" + "bc", + 1, + 3); + VERIFY_ARE_EQUAL(std::string{ "a\xF0\x9F\x8D\xA6" + "b" }, + result.content); + VERIFY_ARE_EQUAL(1, result.lineCount); + VERIFY_IS_TRUE(result.truncated); + + const auto lines = BuildBoundedCommand("command\r\n" + "first\r\n" + "second\r\n", + 2, + 100); + VERIFY_ARE_EQUAL("command\n" + "first", + lines.content); + VERIFY_ARE_EQUAL(2, lines.lineCount); + VERIFY_IS_TRUE(lines.truncated); + + const auto newlineLookahead = BuildBoundedCommand("command\n", 2, 7); + VERIFY_ARE_EQUAL("command", newlineLookahead.content); + VERIFY_IS_TRUE(newlineLookahead.truncated); + + const auto blankLineLookahead = BuildBoundedCommand("command\n\n", 2, 100); + VERIFY_ARE_EQUAL("command\n", blankLineLookahead.content); + VERIFY_ARE_EQUAL(2, blankLineLookahead.lineCount); + VERIFY_IS_TRUE(blankLineLookahead.truncated); + + const auto leadingBlankLines = BuildBoundedCommand("\n\n" + "command\n", + 10, + 100); + VERIFY_ARE_EQUAL("\n\n" + "command\n", + leadingBlankLines.content); + VERIFY_ARE_EQUAL(4, leadingBlankLines.lineCount); + VERIFY_IS_FALSE(leadingBlankLines.truncated); + } + + void ProtocolParsingTests::BoundedBufferTailAppliesLineAndCharacterLimits() + { + const auto byLines = BuildBoundedBufferTail("first\r\n" + "second\r\n" + "third\r\n", + 2, + 100); + VERIFY_ARE_EQUAL("second\n" + "third", + byLines.content); + VERIFY_ARE_EQUAL(2, byLines.lineCount); + VERIFY_IS_TRUE(byLines.truncated); + + const auto byCharacters = BuildBoundedBufferTail("one\r\n" + "two\r\n" + "three\r\n", + 3, + 6); + VERIFY_ARE_EQUAL("\n" + "three", + byCharacters.content); + VERIFY_ARE_EQUAL(2, byCharacters.lineCount); + VERIFY_IS_TRUE(byCharacters.truncated); + + const auto exact = BuildBoundedBufferTail("one\r\n" + "two\r\n", + 2, + 7); + VERIFY_ARE_EQUAL("one\n" + "two", + exact.content); + VERIFY_ARE_EQUAL(2, exact.lineCount); + VERIFY_IS_FALSE(exact.truncated); + } } diff --git a/src/host/proxy/ITerminalProtocol.idl b/src/host/proxy/ITerminalProtocol.idl index 07cec24013..2b71a9fc9e 100644 --- a/src/host/proxy/ITerminalProtocol.idl +++ b/src/host/proxy/ITerminalProtocol.idl @@ -78,4 +78,11 @@ import "unknwn.idl"; HRESULT Subscribe([in] ITerminalProtocolEventSink* sink); HRESULT Unsubscribe(); HRESULT SendEvent([in] BSTR eventJson); + + // Appended in protocol 2.3. Existing methods must never be reordered. + HRESULT GetPaneContext([in] GUID sourceSessionId, + [in] boolean hasExplicitSource, + [in] long maxLines, + [in] long maxCharacters, + [out, retval] BSTR* json); }; diff --git a/src/tools/wtcli/main.cpp b/src/tools/wtcli/main.cpp index 580dfdba68..adf113a8a2 100644 --- a/src/tools/wtcli/main.cpp +++ b/src/tools/wtcli/main.cpp @@ -18,6 +18,7 @@ #include +#include #include #include #include @@ -28,6 +29,7 @@ #include #include #include +#include #include #include @@ -309,6 +311,38 @@ static bool TryParseU64(const std::string& s, uint64_t& out) return true; } +static bool ProtocolAtLeast(const std::string& version, const unsigned requiredMajor, const unsigned requiredMinor) +{ + const auto dot = version.find('.'); + if (dot == std::string::npos) + { + return false; + } + + uint64_t major = 0; + uint64_t minor = 0; + if (!TryParseU64(version.substr(0, dot), major) || + !TryParseU64(version.substr(dot + 1), minor)) + { + return false; + } + return major > requiredMajor || (major == requiredMajor && minor >= requiredMinor); +} + +static bool SupportsCapability(ITerminalProtocol* server, const std::string_view capability) +{ + Json::Value capabilities; + if (FAILED(CallJson([&](BSTR* json) { return server->GetCapabilities(json); }, capabilities)) || + !capabilities.isArray()) + { + return false; + } + + return std::any_of(capabilities.begin(), capabilities.end(), [&](const auto& item) { + return item.isString() && item.asString() == capability; + }); +} + // ── Main ── // `wmain` — deliberately NOT `main`. Almost every string this tool forwards to @@ -524,6 +558,87 @@ int wmain(int argc, wchar_t** argv) printf("%s\n", output["content"].asString().c_str()); }); + // ── get-pane-context ── + std::string paneContextTarget; + int paneContextMaxLines = 30; + int paneContextMaxCharacters = 4000; + auto* paneContextCmd = app.add_subcommand("get-pane-context", "Resolve a pane and capture bounded context"); + auto* paneContextTargetOption = paneContextCmd->add_option("-t,--target", paneContextTarget, "Explicit source pane session ID (GUID)"); + paneContextCmd->add_option("-l,--max-lines", paneContextMaxLines, "Buffer-tail lines when command marks are unavailable"); + paneContextCmd->add_option("--max-chars", paneContextMaxCharacters, "Maximum returned content characters"); + paneContextCmd->callback([&]() { + constexpr int MaxContextLines = 1000; + constexpr int MaxContextCharacters = 100000; + if (paneContextMaxLines < 0 || paneContextMaxLines > MaxContextLines) + { + fprintf(stderr, "[wtcli] --max-lines must be between 0 and %d\n", MaxContextLines); + exitCode = 1; + return; + } + if (paneContextMaxCharacters < 0 || paneContextMaxCharacters > MaxContextCharacters) + { + fprintf(stderr, "[wtcli] --max-chars must be between 0 and %d\n", MaxContextCharacters); + exitCode = 1; + return; + } + + GUID source{}; + const auto hasExplicitSource = paneContextTargetOption->count() != 0; + if (hasExplicitSource) + { + source = GuidFromString(paneContextTarget, true); + if (InlineIsEqualGUID(source, GUID{})) + { + fprintf(stderr, "[wtcli] Invalid session ID: %s\n", paneContextTarget.empty() ? "(empty)" : paneContextTarget.c_str()); + exitCode = 1; + return; + } + } + + std::string version; + auto server = ConnectToTerminal(nullptr, &version, skipAuthenticate); + if (!server) + { + exitCode = 1; + return; + } + + if (!ProtocolAtLeast(version, 2, 3) || + !SupportsCapability(server.get(), "get_pane_context")) + { + fprintf(stderr, + "[wtcli] WT_PROTOCOL_UNSUPPORTED_PANE_CONTEXT server=%s required=2.3\n", + version.empty() ? "unknown" : version.c_str()); + exitCode = 2; + return; + } + + Json::Value context; + const auto hr = CallJson([&](BSTR* json) { + return server->GetPaneContext( + source, + hasExplicitSource, + paneContextMaxLines, + paneContextMaxCharacters, + json); + }, context); + if (FAILED(hr)) + { + fprintf(stderr, "GetPaneContext failed: 0x%08X\n", static_cast(hr)); + exitCode = 1; + return; + } + + if (jsonMode) + { + PrintJson(context); + } + else + { + printf("%s\n", context["content"].asString().c_str()); + } + }); + // ── pane-status ── std::string paneStatusTarget; auto* paneStatusCmd = app.add_subcommand("pane-status", "Show pane process status"); diff --git a/test/e2e/Measure-PaneContext.ps1 b/test/e2e/Measure-PaneContext.ps1 new file mode 100644 index 0000000000..b3d72e68ec --- /dev/null +++ b/test/e2e/Measure-PaneContext.ps1 @@ -0,0 +1,222 @@ +#Requires -Version 7.2 +<# +.SYNOPSIS + Compare legacy and consolidated context collection against existing, stable panes. +.DESCRIPTION + Read-only attachment: never starts/stops Terminal, changes focus/settings, or sends input. + Both collectors use the SAME deployed server and wtcli, not before/after app builds. + See README.md "Pane-context performance benchmark" for semantics and interpretation. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][ValidateScript({ $_ -ne 'Auto' -and -not [string]::IsNullOrWhiteSpace($_) })][string]$Package, + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Configuration, + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string[]]$TargetPaneId, + [Parameter(Mandatory)][ValidateSet('Planner', 'ManualFix', 'ExplicitAutofix')][string]$Mode, + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Scenario, + [Parameter(Mandatory)][string]$OutDir, + [ValidateSet('Any', 'Marked', 'Unmarked')][string]$ExpectedMarks = 'Any', + [string]$ExpectedMarker, + [ValidateRange(5, 1000)][int]$Warmup = 5, + [ValidateRange(40, 10000)][int]$Samples = 40, + [ValidateRange(1, 120)][int]$TimeoutSec = 20 +) + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'tools\PaneContextBenchmark.ps1') +Import-Module (Join-Path $PSScriptRoot 'ItE2E\ItE2E.psd1') -Force +$repo = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..')) +$outPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutDir) +$artifactRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot 'artifacts')) + '\' +if (-not $outPath.StartsWith($artifactRoot, [StringComparison]::OrdinalIgnoreCase)) { + throw 'OutDir must be a subdirectory of the ignored test\e2e\artifacts directory.' +} +foreach ($name in @('samples.csv', 'requests.csv', 'summary.json', 'metadata.json')) { + if (Test-Path (Join-Path $outPath $name)) { throw "Output already exists: $name. Choose a fresh OutDir." } +} +if ($Mode -ne 'ExplicitAutofix' -and $TargetPaneId.Count -ne 1) { + throw 'Planner/ManualFix require one expected active pane. This script never changes focus.' +} +$TargetPaneId = @($TargetPaneId | ForEach-Object { ([guid]$_).ToString() }) +if (@($TargetPaneId | Select-Object -Unique).Count -ne $TargetPaneId.Count -or $TargetPaneId -contains [guid]::Empty.ToString()) { + throw 'TargetPaneId must contain distinct nonempty GUIDs.' +} + +function Invoke-BenchmarkGit { + param([string[]]$Arguments) + (Invoke-PcbProcess -FilePath 'git' -Arguments (@('-C', $repo, '--no-pager') + $Arguments) -TimeoutSec $TimeoutSec).StdOut.TrimEnd() +} + +$app = Resolve-ItApp -Package $Package +if (-not $app.WindowsTerminal -or -not (Test-Path $app.WindowsTerminal)) { throw 'Package executable is not readable.' } +$expectedWtcli = Join-Path $app.InstallLocation 'wtcli.exe' +if ($app.WtcliPath -ne $expectedWtcli) { throw 'Refusing wtcli alias/fallback: package-local wtcli.exe is required.' } +$processes = @(Get-WtProcessesForApp -App $app | Where-Object Path -eq $app.WindowsTerminal) +if (-not $processes.Count) { throw 'The selected package must already be running. No app will be launched.' } + +# Reuse package discovery and brand constants, but NOT Resolve-WtComClsid's probing: +# probing arbitrary brands for a custom PFN could activate another package. +$knownClsids = & (Get-Module ItE2E) { @($script:ItBrandClsids.Values) } +[xml]$manifest = Get-Content -LiteralPath (Join-Path $app.InstallLocation 'AppxManifest.xml') -Raw +$classes = @($manifest.SelectNodes("//*[local-name()='ExeServer' and @Executable='WindowsTerminal.exe']/*[local-name()='Class']")) +$clsids = @($classes | ForEach-Object { ([guid]$_.Id).ToString('B').ToUpperInvariant() } | Where-Object { $_ -in $knownClsids }) +if ($clsids.Count -ne 1) { throw 'Cannot unambiguously resolve the package protocol CLSID from its manifest.' } +$app.ComClsid = $clsids[0] + +# Pinned to HEAD when the restored issue was benchmarked; later commits cannot redefine "old". +$baselineRevision = 'db609f8061f81c2eb9a4bdaf3e0666392596bce4' +$baselinePath = 'tools/wta/src/protocol/acp/prompt_context.rs' +$baselineSource = Invoke-BenchmarkGit @('show', "${baselineRevision}:$baselinePath") +$binaryFiles = @(Get-ChildItem -LiteralPath $app.InstallLocation -File | Where-Object { + $_.Name -in @('wtcli.exe', 'wta.exe', 'WindowsTerminal.exe') -or + ($_.Extension -eq '.dll' -and $_.Name -match 'Terminal|Control') +}) +$binaries = @($binaryFiles | ForEach-Object { + [pscustomobject]@{ + Path = $_.FullName + Sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash + FileVersion = $_.VersionInfo.FileVersion + ProductVersion = $_.VersionInfo.ProductVersion + } +}) +$sourceStatus = Invoke-BenchmarkGit @('status', '--porcelain=v1', '--untracked-files=all') +$metadata = [ordered]@{ + SchemaVersion = 1 + StartedUtc = [DateTime]::UtcNow.ToString('o') + PackageSelector = $Package + PackageFamilyName = $app.Package + PackageFullName = $app.PackageFullName + PackageVersion = $app.Version + ConfigurationLabel = $Configuration + ComClsid = $app.ComClsid + TerminalProcesses = @($processes | ForEach-Object { @{ Pid = $_.Id; Path = $_.Path; StartedUtc = $_.StartTime.ToUniversalTime().ToString('o') } }) + Binaries = $binaries + SourceRevision = Invoke-BenchmarkGit @('rev-parse', 'HEAD') + SourceBranch = Invoke-BenchmarkGit @('branch', '--show-current') + SourceDirty = -not [string]::IsNullOrEmpty($sourceStatus) + SourceStatus = $sourceStatus + SourceDiffSha256 = Get-PcbTextHash (Invoke-BenchmarkGit @('diff', 'HEAD', '--binary')) + BaselineRevision = $baselineRevision + BaselineSourcePath = $baselinePath + BaselineSourceBlob = Invoke-BenchmarkGit @('rev-parse', "${baselineRevision}:$baselinePath") + BaselineSourceTextSha256 = Get-PcbTextHash $baselineSource + BenchmarkScriptSha256 = (Get-FileHash -LiteralPath $PSCommandPath -Algorithm SHA256).Hash + BenchmarkHelpersSha256 = (Get-FileHash -LiteralPath (Join-Path $PSScriptRoot 'tools\PaneContextBenchmark.ps1') -Algorithm SHA256).Hash + PowerShellVersion = $PSVersionTable.PSVersion.ToString() + OSVersion = [Environment]::OSVersion.VersionString + ProcessorCount = [Environment]::ProcessorCount + Mode = $Mode + Scenario = $Scenario + TargetPaneIds = $TargetPaneId + ExpectedMarks = $ExpectedMarks + ExpectedMarker = $ExpectedMarker + WarmupPairsPerPane = $Warmup + RecordedPairsPerPane = $Samples + CommandTimeoutSeconds = $TimeoutSec + MaxLines = $(if ($Mode -eq 'Planner') { 24 } else { 30 }) + MaxContentScalars = 4000 + TimingBoundary = 'Sum of each wtcli process Start through exit and asynchronous stdout/stderr EOF; includes authentication/COM/capture, excludes PowerShell JSON parsing, validation, prompt assembly and LLM.' + SecondaryTiming = 'CollectorMs includes PowerShell emulation, JSON parsing and inter-request overhead; not native Rust collector latency.' + Scope = 'Both paths on the same new server isolate context collection, not before/after app binaries or full model/prompt latency. Request counts mean wtcli subprocesses, not raw COM calls; consolidated negotiates capabilities internally.' + Semantics = 'Legacy planner/manual fix: active-pane + last-prompt + optional full-buffer capture/line-tail fallback. Explicit autofix also queries active-pane, then walks windows/tabs/panes to exact source. No unsupported-capability probe. Legacy marked output has only a 4000-scalar prefix cap, no line cap. New marks also obey 24/30 lines; unmarked scalar truncation retains the tail, whereas legacy keeps the prefix of its line tail. Prompt truncation suffix is outside the 4000-scalar content budget. Exact cross-path text equality is reported, not required.' +} +New-Item -ItemType Directory -Path $outPath -Force | Out-Null +$metadata | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath (Join-Path $outPath 'metadata.json') -Encoding utf8 +$context = @{ + App = $app + Processes = $processes + TimeoutSec = $TimeoutSec + Requests = [Collections.Generic.List[object]]::new() +} +$recorded = [Collections.Generic.List[object]]::new() +$comparisons = [Collections.Generic.List[object]]::new() +foreach ($target in $TargetPaneId) { + $fingerprints = @{} + for ($pair = 0; $pair -lt ($Warmup + $Samples); $pair++) { + $phase = if ($pair -lt $Warmup) { 'Warmup' } else { 'Recorded' } + $index = if ($phase -eq 'Warmup') { $pair + 1 } else { $pair - $Warmup + 1 } + $order = if ($pair % 2 -eq 0) { @('Legacy', 'Consolidated') } else { @('Consolidated', 'Legacy') } + $pairResults = @{} + for ($position = 0; $position -lt 2; $position++) { + $path = $order[$position] + $result = Invoke-PcbCollector $context $path $Mode $target + if ($ExpectedMarker -and -not $result.Content.Contains($ExpectedMarker, [StringComparison]::Ordinal)) { + throw "$path/$target omitted expected marker from bounded content." + } + if (($ExpectedMarks -eq 'Marked' -and -not $result.HasMarks) -or + ($ExpectedMarks -eq 'Unmarked' -and $result.HasMarks)) { throw "$path/$target has unexpected shell marks." } + $fingerprint = @($result.CaptureContentSha256, $result.PromptSha256, $result.OutputSource, $result.HasMarks, + $result.Pane.session_id, $result.Pane.tab_id, $result.Pane.window_id, + $result.Pane.pid, $result.Pane.cwd, $result.Pane.shell, $result.RequestCount) -join '|' + if ($fingerprints.ContainsKey($path) -and $fingerprints[$path] -cne $fingerprint) { + throw "$path/$target content, metadata or topology changed; discard this run and use stable panes." + } + $fingerprints[$path] = $fingerprint + $pairResults[$path] = $result + $row = [pscustomobject][ordered]@{ + Scenario = $Scenario; Mode = $Mode; TargetPaneId = $target; Phase = $phase + Pair = $index; Position = $position + 1; Path = $path + BoundaryMs = $result.BoundaryMs; CollectorMs = $result.CollectorMs + RequestCount = $result.RequestCount; StdoutBytes = $result.StdoutBytes; StderrBytes = $result.StderrBytes + CaptureContentBytes = $result.CaptureContentBytes; ContentBytes = $result.ContentBytes + ContentScalars = $result.ContentScalars; ContentLines = $result.ContentLines + PromptBytes = $result.PromptBytes; HasMarks = $result.HasMarks; OutputSource = $result.OutputSource + Truncated = $result.Truncated; CaptureContentSha256 = $result.CaptureContentSha256 + ContentSha256 = $result.ContentSha256; PromptSha256 = $result.PromptSha256 + } + $row | Export-Csv -LiteralPath (Join-Path $outPath 'samples.csv') -NoTypeInformation -Append -Encoding utf8 + $requestIndex = 0 + foreach ($request in $result.Requests) { + [pscustomobject]@{ + TargetPaneId = $target; Phase = $phase; Pair = $index; Path = $path; Position = $position + 1 + Request = ++$requestIndex; Command = $request.Command; BoundaryMs = $request.BoundaryMs + StdoutBytes = $request.StdoutBytes; StderrBytes = $request.StderrBytes; StdoutSha256 = $request.StdoutSha256 + } | Export-Csv -LiteralPath (Join-Path $outPath 'requests.csv') -NoTypeInformation -Append -Encoding utf8 + } + if ($phase -eq 'Recorded') { $recorded.Add($row) } + } + foreach ($field in @('session_id', 'tab_id', 'window_id', 'pid', 'cwd', 'shell')) { + if ([string]$pairResults.Legacy.Pane[$field] -cne [string]$pairResults.Consolidated.Pane[$field]) { + throw "Collectors disagree on pane metadata '$field'." + } + } + if ($pairResults.Legacy.HasMarks -ne $pairResults.Consolidated.HasMarks -or + $pairResults.Legacy.OutputSource -ne $pairResults.Consolidated.OutputSource) { + throw 'Collectors disagree on marks/output source; unstable or incompatible capture.' + } + } + $old = @($recorded | Where-Object { $_.TargetPaneId -eq $target -and $_.Path -eq 'Legacy' }) + $new = @($recorded | Where-Object { $_.TargetPaneId -eq $target -and $_.Path -eq 'Consolidated' }) + $comparison = Get-PcbComparison -Legacy $old.BoundaryMs -Consolidated $new.BoundaryMs + $comparisons.Add([pscustomobject]@{ + TargetPaneId = $target + Latency = $comparison + LegacyRequestsPerSample = @($old.RequestCount | Select-Object -Unique) + ConsolidatedRequestsPerSample = @($new.RequestCount | Select-Object -Unique) + LegacyMeanStdoutBytes = ($old | Measure-Object StdoutBytes -Average).Average + ConsolidatedMeanStdoutBytes = ($new | Measure-Object StdoutBytes -Average).Average + LegacyContentScalars = @($old.ContentScalars | Select-Object -Unique) + ConsolidatedContentScalars = @($new.ContentScalars | Select-Object -Unique) + ExactBoundedContentEqual = $old[0].ContentSha256 -ceq $new[0].ContentSha256 + ExactPromptPayloadEqual = $old[0].PromptSha256 -ceq $new[0].PromptSha256 + }) +} +foreach ($binary in $binaries) { + if ((Get-FileHash -LiteralPath $binary.Path -Algorithm SHA256).Hash -ne $binary.Sha256) { + throw "Deployed binary changed during the run: $($binary.Path)" + } +} +$summary = [ordered]@{ + Status = 'Passed' + CompletedUtc = [DateTime]::UtcNow.ToString('o') + Metadata = $metadata + PercentileMethod = 'Nearest rank: sorted[ceil(p*N)-1]; warmups excluded; ratio of path percentiles, not percentile of pair ratios.' + Comparisons = $comparisons.ToArray() +} +$summary | ConvertTo-Json -Depth 15 | Set-Content -LiteralPath (Join-Path $outPath 'summary.json') -Encoding utf8 +$comparisons | ForEach-Object { + Write-Host ('{0}: p50 {1:F2} -> {2:F2} ms ({3:F2}x, {4:F1}% reduction); p95 {5:F2} -> {6:F2} ms' -f + $_.TargetPaneId, $_.Latency.LegacyP50Ms, $_.Latency.ConsolidatedP50Ms, $_.Latency.P50Speedup, + $_.Latency.P50ReductionPercent, $_.Latency.LegacyP95Ms, $_.Latency.ConsolidatedP95Ms) +} +Write-Host "Verified benchmark artifacts: $outPath" diff --git a/test/e2e/README.md b/test/e2e/README.md index 5b5ec116b1..25b1c9b740 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -33,6 +33,7 @@ authenticated ACP agents. Current status (run on the Store package): | `Feature.AutofixPane.Tests.ps1` | Direct Helper Autofix proposal card render/insert/run/reject/target/stashed + across layout + WSL shell identity and Linux fixes | 12 (2 WSL-gated) | | `Feature.AutofixParser.Tests.ps1` | issue #474: PowerShell ParserError-to-Autofix pipeline + success/handled-error/blank-input negative controls | 4 | | `Feature.AutofixRouting.Tests.ps1` | Two Detected tabs: real diagnostics clicks submit only to the selected tab's ACP session and preserve the other tab's opt-in | 1 | +| `Feature.PaneContext.Tests.ps1` | issue #838: packaged pane-context capture, marked/unmarked output, explicit routing, missing panes, metadata-only mode, Unicode bounds, and agent-focus source resolution | 7 | | `Feature.CommandResolution.Tests.ps1` | PR #418: packaged WTA resolves PowerShell profile-only aliases to their real targets | 1 | | `Feature.SessionList.Tests.ps1` | session view (button + `/sessions` slash), session states, view switching (incl. draft-preservation), focus/restore | 13 (+1 skip) | | `Feature.NonAsciiCwd.Tests.ps1` | issue #641: a non-ASCII starting directory survives `wtcli` argv → COM → `CreateProcessW`, so the resume launch path connects and starts in that directory | 2 | @@ -155,6 +156,120 @@ Invoke-Pester test/e2e/selftests # everything (30 tests) The self-tests are the framework's own proof: every primitive is exercised against a running terminal (`selftests/ItE2E.Live.Tests.ps1`) and the core helpers are unit-tested in `selftests/ItE2E.Unit.Tests.ps1` (hermetic, no terminal needed). + +## Pane-context performance benchmark + +`Measure-PaneContext.ps1` measures issue #838's **wtcli subprocess → COM → +capture** boundary, without sending an agent prompt or consuming model tokens. +It attaches to **already running**, explicitly selected Dev/Store/PFN packages and +existing pane GUIDs. It never launches/closes Terminal, changes settings/focus, +creates fixtures, or types into panes. Package-local binaries and a readable +package manifest are required; it does not use an ambiguous `wtcli` PATH alias +or probe other brands' COM servers. Dependencies: Windows, PowerShell **7.2+**, +Git, and a deployed package supporting `get-pane-context`. Neither WinApp CLI, +agent authentication, nor Pester is needed for the benchmark itself. + +First prepare stable terminal output yourself, and obtain the existing pane's +`session_id` through the harness/package-specific `wtcli`. Run the benchmark +from a **separate process/pane**, not the pane being measured. Leave its content, +focus, window/tab layout, and package binaries unchanged until completion: + +```powershell +$env:ITE2E_PACKAGE = 'Dev' +pwsh -NoProfile -File test\e2e\bootstrap.ps1 -Check + +# Replace the GUID and marker with those of an existing, settled marked pane. +# Planner/ManualFix require this to remain the resolved active working pane. +pwsh -NoProfile -File test\e2e\Measure-PaneContext.ps1 ` + -Package Dev -Configuration Debug -Mode Planner ` + -TargetPaneId '11111111-2222-3333-4444-555555555555' ` + -Scenario 'marked-short' -ExpectedMarks Marked -ExpectedMarker 'BENCH-DONE' ` + -Warmup 5 -Samples 40 -OutDir test\e2e\artifacts\pane-context-benchmark\debug-marked-planner + +# ExplicitAutofix can target an unfocused pane; no focus change is performed. +pwsh -NoProfile -File test\e2e\Measure-PaneContext.ps1 ` + -Package Dev -Configuration Debug -Mode ExplicitAutofix ` + -TargetPaneId '11111111-2222-3333-4444-555555555555' ` + -Scenario 'unmarked-long-scrollback' -ExpectedMarks Unmarked ` + -OutDir test\e2e\artifacts\pane-context-benchmark\debug-unmarked-autofix +``` + +`-Package`, `-Configuration` (a label, not a build action), `-Mode`, +`-TargetPaneId`, `-Scenario`, and `-OutDir` are mandatory; `Auto` is rejected. +`ExplicitAutofix` also accepts an array of existing pane IDs when called from +PowerShell with `& .\test\e2e\Measure-PaneContext.ps1 ... -TargetPaneId @($id1, $id2)`. +Each pane gets its own paired measurements. Use distinct output directories +under ignored `test\e2e\artifacts`; existing result files are never overwritten. +Run marked, unmarked, and long-scrollback scenarios separately and label them +honestly. For optional `-ExpectedMarker`, choose text that survives **both** +paths' intentional bounds. + +### Baseline and interpretation + +- The baseline is a source-faithful PowerShell reproduction of the collector at + `db609f8061f81c2eb9a4bdaf3e0666392596bce4` (HEAD when #838 was restored), + pinned in the script. Its `prompt_context.rs` is retrieved with `git show` for + provenance. **That planner already used marks**, not a buffer-only read. +- `Planner`: `active-pane` → `capture-pane --last-prompt` → optional + `capture-pane -l 24`. `ManualFix` uses the same sequence with 30 fallback lines. + `ExplicitAutofix` preserves the old **unconditional active-pane query**, then + walks windows → tabs → panes until the exact source GUID is found, followed by + marked capture / 30-line fallback. Enumeration cost depends on target position + and topology. Errors abort instead of being silently turned into samples. +- No unsupported-capability request is added to the legacy baseline. The new + path uses **one** `get-pane-context --max-lines 24|30 --max-chars 4000` + subprocess (with `--target` only for explicit autofix). Its normal + authentication/capability negotiation remains inside that subprocess. +- Legacy read methods still capture first and trim locally; the benchmark does + not retrofit bounded capture into them. Both paths use a 4000-Unicode-scalar + content budget, but **legacy marked output has no line cap**, while new marked + output also observes 24/30 lines. For oversized unmarked output, new capture + keeps a scalar **tail** versus legacy's scalar **prefix** of its line tail. + Legacy also ignores the read result's truncation flag. WTA's + `\n...` prompt suffix is outside the content budget. Therefore exact + cross-path payload equality is **reported, not asserted**. +- The same transport runs both paths: `.NET ProcessStartInfo.ArgumentList`, + no shell, UTF-8, concurrent asynchronous stdout/stderr reads, closed stdin, + normal authentication and a shared per-command timeout (default 20 seconds). + The existing harness also uses asynchronous process waits, not polling; the + benchmark-specific transport adds precise timing and one deadline covering + both process exit and pipe EOF, and fails loudly on parse/read/exit errors. +- Each pane runs at least five warmup pairs, then at least 40 recorded pairs, + alternating legacy-first/new-first order. Primary `BoundaryMs` is the **sum + of process-start-to-exit-and-EOF durations**, excluding PowerShell parsing, + assertions and report writes. Secondary `CollectorMs` includes PowerShell + emulation overhead and is **not** a native Rust collector measurement. + Warmups are exported but excluded from statistics. p50/p95 use nearest rank; + speedup is `legacy/new`, reduction is `100*(1-new/legacy)`, including regressions. +- Output must resolve to the requested pane with matching shell/cwd/process + metadata, valid Unicode/bounds, consistent mark/source metadata, and an optional + literal marker. Each path's payload/metadata fingerprint must stay stable. + Different payload hashes across paths may be expected from the bounds above. + +Artifacts are `samples.csv` (raw paired samples, bytes, hashes, marks, bounds), +`requests.csv` (individual subprocess commands/timings/response bytes), +`metadata.json` (written before measurement), and `summary.json` (written **only +after complete validation**). Summaries include per-path request counts, +nearest-rank p50/p95, speedups/reductions, byte metrics, payload equality, +package version/CLSID, deployed binary hashes/versions, process identity, source +revision/dirty status/diff hash, and benchmark source hashes. Raw terminal text +is not saved, but pane IDs/paths and the optional marker are; keep artifacts local. +A failed run may retain partial CSVs but has no success summary. + +**Scope:** this isolates old versus new **context collection on the same new +server**, not old/new application binaries and not full prompt/LLM latency. +Subprocess counts are not COM-call counts. Configuration labels and source +hashes alone cannot prove a deployment came from that revision: the operator +must build/deploy the intended code and compare packaged binary hashes. +Debug and Release results are not interchangeable. Busy UI threads, antivirus, +background output and changing topology can affect measurements; repeat runs. + +Hermetic benchmark tests (no app launches or installs): + +```powershell +Invoke-Pester test\e2e\selftests\PaneContextBenchmark.Unit.Tests.ps1 -Output Detailed +``` + ## Reports (HTML + precise per-failure diagnostics) `Invoke-ItE2EReport.ps1` wraps Pester and, by default, writes the report to the **fixed diff --git a/test/e2e/selftests/PaneContextBenchmark.Unit.Tests.ps1 b/test/e2e/selftests/PaneContextBenchmark.Unit.Tests.ps1 new file mode 100644 index 0000000000..fae7e07234 --- /dev/null +++ b/test/e2e/selftests/PaneContextBenchmark.Unit.Tests.ps1 @@ -0,0 +1,165 @@ +#Requires -Modules @{ ModuleName='Pester'; ModuleVersion='5.0.0' } + +BeforeAll { + . (Join-Path $PSScriptRoot '..\tools\PaneContextBenchmark.ps1') +} + +Describe 'Pane-context benchmark statistics and Unicode' -Tag 'Unit' { + It 'Uses nearest-rank p50 and p95 without interpolation' { + Get-PcbPercentile -Values (1..40) -Quantile 0.5 | Should -Be 20 + Get-PcbPercentile -Values (1..40) -Quantile 0.95 | Should -Be 38 + Get-PcbPercentile -Values @(8) -Quantile 0.95 | Should -Be 8 + } + + It 'Computes speedup and reduction from path percentiles' { + $result = Get-PcbComparison -Legacy @(40, 10, 30, 20) -Consolidated @(20, 5, 15, 10) + $result.SamplesPerPath | Should -Be 4 + $result.LegacyP50Ms | Should -Be 20 + $result.ConsolidatedP95Ms | Should -Be 20 + $result.P50Speedup | Should -Be 2 + $result.P95ReductionPercent | Should -Be 50 + } + + It 'Reports regressions rather than clamping improvement to zero' { + $result = Get-PcbComparison -Legacy @(10) -Consolidated @(20) + $result.P50Speedup | Should -Be 0.5 + $result.P50ReductionPercent | Should -Be -100 + } + + It 'Rejects unpaired or invalid timings' { + { Get-PcbComparison @(1, 2) @(1) } | Should -Throw '*paired*' + { Get-PcbComparison @(0) @(1) } | Should -Throw '*positive*' + { Get-PcbComparison @([double]::NaN) @(1) } | Should -Throw '*finite*' + } + + It 'Preserves supplementary characters at the 4000-scalar prefix boundary' { + $emoji = [char]::ConvertFromUtf32(0x1F366) + $bounded = Limit-PcbPrompt (('a' * 3999) + $emoji + 'z') + $bounded.Content | Should -Be (('a' * 3999) + $emoji) + Get-PcbScalarCount $bounded.Content | Should -Be 4000 + $bounded.Prompt | Should -Be ($bounded.Content + "`n...") + $bounded.Truncated | Should -BeTrue + } + + It 'Keeps protocol truncation visible without duplicating its suffix' { + (Limit-PcbPrompt 'short' 4000 $true).Prompt | Should -Be "short`n..." + (Limit-PcbPrompt 'short...' 4000 $true).Prompt | Should -Be 'short...' + (Limit-PcbPrompt 'short').Prompt | Should -Be 'short' + (Limit-PcbPrompt '').Content | Should -Be '' + } + + It 'Rejects unpaired surrogates instead of measuring damaged payloads' { + { Get-PcbScalarCount ([string][char]0xD800) } | Should -Throw '*surrogate*' + } +} + +Describe 'Pane-context benchmark collector request fidelity' -Tag 'Unit' { + BeforeEach { + $script:paneId = '21a19cef-d793-405b-b6c7-094162071111' + $script:pane = @{ + session_id = $script:paneId; tab_id = 3; window_id = 5; pid = 42 + cwd = 'C:\workspace'; shell = 'pwsh'; is_agent_pane = $false + } + $script:ctx = @{ Requests = [Collections.Generic.List[object]]::new() } + $script:marked = $true + $script:text = 'marker' + $script:newTruncated = $false + $script:newLineCount = 1 + $script:newReason = '' + Mock Invoke-PcbRequest { + param($Context, $Arguments) + $Context.Requests.Add([pscustomobject]@{ + Command = $Arguments -join ' '; BoundaryMs = 5; StdoutBytes = 100; StderrBytes = 0 + }) + switch ($Arguments[0]) { + 'active-pane' { return $script:pane.Clone() } + 'list-windows' { return @{ windows = @(@{ window_id = 5 }) } } + 'list-tabs' { return @{ tabs = @(@{ tab_id = 3 }) } } + 'list-panes' { return @{ panes = @($script:pane.Clone()) } } + 'capture-pane' { + $content = if (-not $script:marked -and $Arguments -contains '--last-prompt') { '' } else { $script:text } + return @{ session_id = $script:paneId; has_marks = $script:marked; content = $content; truncated = $true } + } + 'get-pane-context' { + return @{ + pane = $script:pane.Clone(); content = $script:text; has_marks = $script:marked + output_source = $(if ($script:marked) { 'last_command' } else { 'buffer_tail' }) + fallback_reason = $script:newReason; line_count = $script:newLineCount; truncated = $script:newTruncated + } + } + default { throw "Unexpected request: $($Arguments -join ' ')" } + } + } + } + + It 'Uses active then marks for the actual HEAD planner, without a capability probe' { + $result = Invoke-PcbCollector $script:ctx Legacy Planner $script:paneId + $result.RequestCount | Should -Be 2 + $result.Requests[0].Command | Should -Be 'active-pane' + $result.Requests[1].Command | Should -Be "capture-pane -t $script:paneId --last-prompt" + $result.Truncated | Should -BeFalse -Because 'legacy ignores the server truncation flag' + $result.BoundaryMs | Should -Be 10 + } + + It 'Preserves planner and manual-fix fallback line budgets' { + $script:marked = $false + foreach ($case in @(@('Planner', 24), @('ManualFix', 30))) { + $result = Invoke-PcbCollector $script:ctx Legacy $case[0] $script:paneId + $result.RequestCount | Should -Be 3 + $result.Requests[2].Command | Should -Be "capture-pane -t $script:paneId -l $($case[1])" + $result.OutputSource | Should -Be 'buffer_tail' + } + } + + It 'Retains the pre-change explicit autofix active query before exact-source enumeration' { + $result = Invoke-PcbCollector $script:ctx Legacy ExplicitAutofix $script:paneId + $result.RequestCount | Should -Be 5 + $result.Requests.Command | Should -Be @( + 'active-pane', 'list-windows', 'list-tabs -w 5', 'list-panes -w 5 -t 3', + "capture-pane -t $script:paneId --last-prompt" + ) + } + + It 'Adds exactly one unmarked buffer fallback for explicit autofix' { + $script:marked = $false + $result = Invoke-PcbCollector $script:ctx Legacy ExplicitAutofix $script:paneId + $result.RequestCount | Should -Be 6 + $result.Requests[-1].Command | Should -Be "capture-pane -t $script:paneId -l 30" + } + + It 'Sends one consolidated subprocess with default resolution or explicit source as appropriate' { + foreach ($mode in @('Planner', 'ManualFix', 'ExplicitAutofix')) { + $result = Invoke-PcbCollector $script:ctx Consolidated $mode $script:paneId + $lines = if ($mode -eq 'Planner') { 24 } else { 30 } + $suffix = if ($mode -eq 'ExplicitAutofix') { " --target $script:paneId" } else { '' } + $result.RequestCount | Should -Be 1 + $result.Requests[0].Command | Should -Be "get-pane-context --max-lines $lines --max-chars 4000$suffix" + } + } + + It 'Does not retrofit the new marked line cap into the baseline' { + $script:text = (1..35) -join "`n" + (Invoke-PcbCollector $script:ctx Legacy Planner $script:paneId).ContentLines | Should -Be 35 + $script:newLineCount = 35 + { Invoke-PcbCollector $script:ctx Consolidated Planner $script:paneId } | Should -Throw '*budget*' + } + + It 'Rejects a different active pane instead of silently benchmarking another target' { + { Invoke-PcbCollector $script:ctx Legacy Planner 'wrong-pane' } | Should -Throw '*wrong*' + { Invoke-PcbCollector $script:ctx Consolidated Planner 'wrong-pane' } | Should -Throw '*wrong*' + } + + It 'Rejects protocol errors and inconsistent payload metadata' { + $script:newReason = 'last_command_error' + $script:marked = $false + { Invoke-PcbCollector $script:ctx Consolidated ManualFix $script:paneId } | Should -Throw '*fallback*' + $script:newReason = 'marks_unavailable' + $script:newLineCount = 2 + { Invoke-PcbCollector $script:ctx Consolidated ManualFix $script:paneId } | Should -Throw '*line count*' + } + + It 'Does not turn a failed legacy RPC into a successful fallback sample' { + Mock Invoke-PcbRequest { throw 'RPC failed' } + { Invoke-PcbCollector $script:ctx Legacy Planner $script:paneId } | Should -Throw '*RPC failed*' + } +} diff --git a/test/e2e/tests/Feature.PaneContext.Tests.ps1 b/test/e2e/tests/Feature.PaneContext.Tests.ps1 new file mode 100644 index 0000000000..22a5883ec3 --- /dev/null +++ b/test/e2e/tests/Feature.PaneContext.Tests.ps1 @@ -0,0 +1,180 @@ +#Requires -Modules @{ ModuleName='Pester'; ModuleVersion='5.0.0' } +# Issue #838: exercise packaged wtcli -> COM -> TerminalPage -> ControlCore. +# Exact checklist titles below cover routing and capture, not model-generated answers. + +BeforeDiscovery { + $script:Ready = [bool](Get-AppxPackage | Where-Object { $_.Name -like '*IntelligentTerminal*' }) +} + +Describe 'Feature: consolidated pane context' -Tag 'Feature' -Skip:(-not $script:Ready) { + BeforeAll { + Import-Module (Join-Path $PSScriptRoot '..\ItE2E\ItE2E.psd1') -Force + $script:app = Start-Terminal -Package (Get-ItTestPackage) -PassFre $true -Settings @{ + autoErrorDetectionEnabled = $true + autoFixEnabled = $false + } + + function Read-TestPaneContext { + param([string]$SessionId, [int]$Lines = 1000, [int]$Characters = 100000) + $arguments = @('get-pane-context', '--max-lines', "$Lines", '--max-chars', "$Characters") + if ($SessionId) { $arguments += @('--target', $SessionId) } + Invoke-WtCli -App $script:app -Arguments $arguments + } + + $script:shell = New-WtTab -App $script:app -Command 'pwsh.exe -NoLogo -NoExit' -Title 'pane-context-shell' + Wait-Until -TimeoutSec 30 -Because 'integrated PowerShell prompt' -Condition { + (Read-TestPaneContext -SessionId $script:shell.session_id).pane.shell -eq 'pwsh' + } | Out-Null + + # No profile means no OSC marks. Encoded startup output avoids input echo + # satisfying the completion oracle and avoids argv code-page ambiguity. + $script:tailMarker = "pc-tail-$([guid]::NewGuid().ToString('N'))" + $script:unicode = [char]::ConvertFromUtf32(0x1F366) + $outputScript = "[Console]::OutputEncoding = [Text.UTF8Encoding]::new(); 0..79 | ForEach-Object { 'pc-line-{0:D3}' -f `$_ }; [Console]::WriteLine('$script:tailMarker' + [char]::ConvertFromUtf32(0x1F366))" + $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($outputScript)) + $script:plain = New-WtTab -App $script:app -Command "pwsh.exe -NoLogo -NoProfile -NoExit -EncodedCommand $encoded" -Title 'pane-context-unmarked' + Wait-Until -TimeoutSec 30 -Because 'unmarked startup output completed' -Condition { + (Read-TestPaneContext -SessionId $script:plain.session_id).content.Contains($script:tailMarker + $script:unicode) + } | Out-Null + } + + AfterAll { + if ($script:app) { Stop-Terminal -App $script:app } + } + + It 'Pane context captures the completed marked command' { + $marker = "pc-error-$([guid]::NewGuid().ToString('N'))" + $listener = Start-WtEventListener -App $script:app + try { + Invoke-RunCommand -App $script:app -SessionId $script:shell.session_id -Command "throw '$marker'" | Out-Null + $failure = Wait-WtCommandFailure -Listener $listener -PaneId $script:shell.session_id -TimeoutSec 20 + $failure.params.tab_id | Should -Not -BeNullOrEmpty + $context = Read-TestPaneContext -SessionId $script:shell.session_id + $context.output_source | Should -Be 'last_command' + $context.has_marks | Should -BeTrue + $context.fallback_reason | Should -Be '' + $context.content | Should -Match ([regex]::Escape("throw '$marker'")) + ([regex]::Matches($context.content, [regex]::Escape($marker))).Count | + Should -BeGreaterOrEqual 2 -Because 'both the command and its error output must be captured' + $context.truncated | Should -BeFalse + $context.pane.session_id | Should -Be $script:shell.session_id + $context.pane.pid | Should -Be $script:shell.pid + $context.pane.shell | Should -Be 'pwsh' + $context.pane.cwd | Should -Not -BeNullOrEmpty + $context.pane.size.rows | Should -BeGreaterThan 0 + $context.pane.size.columns | Should -BeGreaterThan 0 + } + finally { Stop-WtEventListener -Listener $listener } + } + + It 'Pane context falls back to the newest unmarked output' { + $context = Read-TestPaneContext -SessionId $script:plain.session_id -Lines 10 + $context.output_source | Should -Be 'buffer_tail' + $context.has_marks | Should -BeFalse + $context.fallback_reason | Should -Be 'marks_unavailable' + $context.content | Should -Match 'pc-line-079' + $context.content | Should -Match ([regex]::Escape($script:tailMarker + $script:unicode)) + $context.content | Should -Not -Match 'pc-line-000' + $context.line_count | Should -BeLessOrEqual 10 + $context.truncated | Should -BeTrue + } + + It 'Explicit pane context stays isolated from the focused tab and split' { + $split = $null + try { + Set-WtPaneFocus -App $script:app -SessionId $script:plain.session_id + $split = Split-WtPane -App $script:app -SessionId $script:plain.session_id -Direction right -Command 'pwsh.exe -NoLogo -NoProfile -NoExit' + Set-WtPaneFocus -App $script:app -SessionId $split.session_id + $context = Read-TestPaneContext -SessionId $script:plain.session_id + $context.pane.session_id | Should -Be $script:plain.session_id + $context.content | Should -Match ([regex]::Escape($script:tailMarker)) + (Read-TestPaneContext -SessionId $script:shell.session_id).pane.tab_id | Should -Be $script:shell.tab_id + (Read-TestPaneContext -Lines 0).pane.session_id | Should -Be $split.session_id + } + finally { if ($split) { Close-WtPane -App $script:app -SessionId $split.session_id } } + } + + It 'Missing and closed pane context fails without active-pane fallback' { + foreach ($id in @('not-a-guid', [guid]::Empty.ToString(), '')) { + $failure = & (Get-Module ItE2E) { + param($App, $Target) + Invoke-Native -FilePath $App.WtcliPath -Arguments @('get-pane-context', '--target', $Target) ` + -Environment @{ WT_COM_CLSID = $App.ComClsid } + } $script:app $id + $failure.TimedOut | Should -BeFalse + $failure.ExitCode | Should -Be 1 + $failure.StdOut | Should -BeNullOrEmpty + ([regex]::Matches($failure.StdErr, '\[wtcli\] Invalid session ID:')).Count | Should -Be 1 + } + $gone = New-WtTab -App $script:app -Command 'pwsh.exe -NoLogo -NoProfile -NoExit' + Close-WtPane -App $script:app -SessionId $gone.session_id + foreach ($id in @($gone.session_id, [guid]::NewGuid().ToString())) { + { Read-TestPaneContext -SessionId $id } | Should -Throw + } + (Read-TestPaneContext -SessionId $script:plain.session_id).pane.session_id | Should -Be $script:plain.session_id + } + + It 'Pane context metadata-only requests omit terminal content' { + foreach ($limits in @(@(0, 100), @(10, 0))) { + $context = Read-TestPaneContext -SessionId $script:plain.session_id -Lines $limits[0] -Characters $limits[1] + $context.output_source | Should -Be 'metadata_only' + $context.content | Should -Be '' + $context.line_count | Should -Be 0 + $context.truncated | Should -BeFalse + $context.pane.session_id | Should -Be $script:plain.session_id + $context.pane.pid | Should -Be $script:plain.pid + $context.pane.size.rows | Should -BeGreaterThan 0 + $context.pane.size.columns | Should -BeGreaterThan 0 + } + } + + It 'Pane context bounds preserve Unicode and truthful truncation' { + $full = Read-TestPaneContext -SessionId $script:plain.session_id + $full.truncated | Should -BeFalse + $full.content | Should -Match ([regex]::Escape($script:unicode)) + # Vary the boundary across a supplementary character, accounting for + # UTF-16 surrogate pairs when comparing the protocol's character budget. + $tailLength = $full.content.Length - $full.content.LastIndexOf($script:unicode) + foreach ($limit in ($tailLength - 2)..($tailLength + 2)) { + $context = Read-TestPaneContext -SessionId $script:plain.session_id -Characters $limit + $context.truncated | Should -BeTrue + $context.content | Should -Not -Match "\uFFFD|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?'))) { + $prompt += "`n..." + } + [pscustomobject]@{ Content = $content; Prompt = $prompt; Truncated = ($count -gt $MaxChars -or $ProtocolTruncated) } +} + +function Get-PcbTextHash { + param([AllowEmptyString()][string]$Text) + [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($Text))) +} + +function Invoke-PcbProcess { + param( + [Parameter(Mandatory)][string]$FilePath, + [Parameter(Mandatory)][string[]]$Arguments, + [ValidateRange(1, 120)][int]$TimeoutSec = 20, + [hashtable]$Environment = @{} + ) + $psi = [Diagnostics.ProcessStartInfo]::new() + $psi.FileName = $FilePath + $psi.UseShellExecute = $false + $psi.CreateNoWindow = $true + $psi.RedirectStandardInput = $true + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.StandardOutputEncoding = [Text.UTF8Encoding]::new($false, $true) + $psi.StandardErrorEncoding = [Text.UTF8Encoding]::new($false, $true) + foreach ($argument in $Arguments) { $psi.ArgumentList.Add($argument) } + foreach ($key in $Environment.Keys) { $psi.Environment[$key] = $Environment[$key] } + $process = [Diagnostics.Process]::new() + $process.StartInfo = $psi + $clock = [Diagnostics.Stopwatch]::new() + try { + $clock.Start() + if (-not $process.Start()) { throw "Could not start $FilePath." } + $process.StandardInput.Close() + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + $completion = [Threading.Tasks.Task]::WhenAll([Threading.Tasks.Task[]]@( + $process.WaitForExitAsync(), $stdout, $stderr + )) + if (-not $completion.Wait($TimeoutSec * 1000)) { + # Only the child owned by this invocation; never kill the terminal or process names. + if (-not $process.HasExited) { $process.Kill() } + if (-not $process.WaitForExit(2000)) { throw "Timed out and could not reap child PID $($process.Id)." } + throw "Timed out after ${TimeoutSec}s: $FilePath $($Arguments -join ' ')" + } + $out = $stdout.GetAwaiter().GetResult() + $err = $stderr.GetAwaiter().GetResult() + $clock.Stop() + if ($process.ExitCode -ne 0) { + throw "$FilePath $($Arguments -join ' ') exited $($process.ExitCode): $err" + } + [pscustomobject]@{ + StdOut = $out + StdErr = $err + BoundaryMs = $clock.Elapsed.TotalMilliseconds + StdoutBytes = [Text.Encoding]::UTF8.GetByteCount($out) + StderrBytes = [Text.Encoding]::UTF8.GetByteCount($err) + } + } + finally { $process.Dispose() } +} + +function Invoke-PcbRequest { + param($Context, [string[]]$Arguments) + # Avoid COM activation when the attached package has exited. This is outside the timer. + foreach ($process in $Context.Processes) { + $process.Refresh() + if ($process.HasExited) { throw "Attached Terminal PID $($process.Id) exited; refusing COM activation." } + } + $result = Invoke-PcbProcess -FilePath $Context.App.WtcliPath -Arguments (@('--json') + $Arguments) ` + -TimeoutSec $Context.TimeoutSec -Environment @{ WT_COM_CLSID = $Context.App.ComClsid } + $json = ConvertFrom-Json -InputObject $result.StdOut -AsHashtable -Depth 64 -ErrorAction Stop + if ($null -eq $json) { throw "wtcli $($Arguments -join ' ') returned no JSON." } + $Context.Requests.Add([pscustomobject]@{ + Command = $Arguments -join ' ' + BoundaryMs = $result.BoundaryMs + StdoutBytes = $result.StdoutBytes + StderrBytes = $result.StderrBytes + StdoutSha256 = Get-PcbTextHash $result.StdOut + }) + $json +} + +function Assert-PcbPane { + param($Pane, [string]$TargetPaneId) + if (-not $Pane -or [string]$Pane.session_id -ne $TargetPaneId -or $Pane.is_agent_pane) { + throw "Context resolved to a missing/wrong/agent pane; expected $TargetPaneId." + } + foreach ($field in @('session_id', 'tab_id', 'window_id', 'pid', 'cwd', 'shell', 'is_agent_pane')) { + if (-not $Pane.ContainsKey($field)) { throw "Pane metadata omitted $field." } + } +} + +function Invoke-PcbCollector { + param( + $Context, + [ValidateSet('Legacy', 'Consolidated')][string]$Path, + [ValidateSet('Planner', 'ManualFix', 'ExplicitAutofix')][string]$Mode, + [string]$TargetPaneId + ) + $Context.Requests.Clear() + $maxLines = if ($Mode -eq 'Planner') { 24 } else { 30 } + $clock = [Diagnostics.Stopwatch]::StartNew() + if ($Path -eq 'Consolidated') { + $arguments = @('get-pane-context', '--max-lines', "$maxLines", '--max-chars', '4000') + if ($Mode -eq 'ExplicitAutofix') { $arguments += @('--target', $TargetPaneId) } + $capture = Invoke-PcbRequest $Context $arguments + $pane = $capture.pane + Assert-PcbPane $pane $TargetPaneId + foreach ($field in @('content', 'has_marks', 'output_source', 'fallback_reason', 'line_count', 'truncated')) { + if (-not $capture.ContainsKey($field)) { throw "get-pane-context omitted $field." } + } + if ($capture.content -isnot [string] -or $capture.has_marks -isnot [bool] -or $capture.truncated -isnot [bool]) { + throw 'Invalid pane-context content/marks/truncation types.' + } + if ((Get-PcbScalarCount $capture.content) -gt 4000 -or $capture.line_count -gt $maxLines) { + throw 'get-pane-context exceeded its line/scalar budget.' + } + $source = $capture.output_source + if ($source -eq 'last_command') { + if (-not $capture.has_marks -or $capture.fallback_reason -ne '') { throw 'Inconsistent last-command metadata.' } + } + elseif ($source -eq 'buffer_tail') { + if ($capture.has_marks -or $capture.fallback_reason -ne 'marks_unavailable') { + throw "Unexpected buffer fallback reason: $($capture.fallback_reason)" + } + } + else { throw "Unexpected capture source '$source'." } + $bounded = Limit-PcbPrompt $capture.content 4000 $capture.truncated + } + else { + # HEAD db609f8 resolves active even for explicit autofix; do not "optimize" the baseline. + $pane = Invoke-PcbRequest $Context @('active-pane') + if ($Mode -eq 'ExplicitAutofix') { + $pane = $null + $windows = Invoke-PcbRequest $Context @('list-windows') + :findPane foreach ($window in $windows.windows) { + $tabs = Invoke-PcbRequest $Context @('list-tabs', '-w', [string]$window.window_id) + foreach ($tab in $tabs.tabs) { + $panes = Invoke-PcbRequest $Context @('list-panes', '-w', [string]$window.window_id, '-t', [string]$tab.tab_id) + foreach ($candidate in $panes.panes) { + if ([string]$candidate.session_id -eq $TargetPaneId) { + $pane = $candidate + break findPane + } + } + } + } + } + Assert-PcbPane $pane $TargetPaneId + $capture = Invoke-PcbRequest $Context @('capture-pane', '-t', $TargetPaneId, '--last-prompt') + if ([string]$capture.session_id -ne $TargetPaneId -or $capture.content -isnot [string] -or $capture.has_marks -isnot [bool]) { + throw 'Invalid legacy marked capture or mismatched target.' + } + $hasMarks = $capture.has_marks + $source = 'last_command' + if (-not $hasMarks -or [string]::IsNullOrEmpty($capture.content)) { + $source = 'buffer_tail' + $capture = Invoke-PcbRequest $Context @('capture-pane', '-t', $TargetPaneId, '-l', "$maxLines") + if ([string]$capture.session_id -ne $TargetPaneId -or $capture.content -isnot [string]) { + throw 'Invalid legacy buffer capture or mismatched target.' + } + } + # ReadPaneOutput captures first, then trims lines; chars are truncated in WTA. + # Legacy marks have NO line cap and legacy ignores the server's truncated flag. + $bounded = Limit-PcbPrompt $capture.content + $capture.has_marks = $hasMarks + } + $clock.Stop() + $content = $bounded.Content + $lines = if ($content.Length) { [regex]::Matches($content, "`n").Count + 1 } else { 0 } + if ($Path -eq 'Consolidated' -and ($lines -gt $maxLines -or $capture.line_count -ne $lines)) { + throw "Inconsistent bounded line count: reported $($capture.line_count), actual $lines." + } + if ((Get-PcbScalarCount $content) -gt 4000) { throw 'Prompt content exceeded 4000 Unicode scalars.' } + [pscustomobject]@{ + Path = $Path + TargetPaneId = $TargetPaneId + Pane = $pane + Content = $content + CaptureContentSha256 = Get-PcbTextHash $capture.content + ContentSha256 = Get-PcbTextHash $content + PromptSha256 = Get-PcbTextHash $bounded.Prompt + PromptBytes = [Text.Encoding]::UTF8.GetByteCount($bounded.Prompt) + CaptureContentBytes = [Text.Encoding]::UTF8.GetByteCount($capture.content) + ContentBytes = [Text.Encoding]::UTF8.GetByteCount($content) + ContentScalars = Get-PcbScalarCount $content + ContentLines = $lines + HasMarks = $capture.has_marks + OutputSource = $source + Truncated = $bounded.Truncated + BoundaryMs = ($Context.Requests | Measure-Object BoundaryMs -Sum).Sum + CollectorMs = $clock.Elapsed.TotalMilliseconds + Requests = @($Context.Requests.ToArray()) + RequestCount = $Context.Requests.Count + StdoutBytes = ($Context.Requests | Measure-Object StdoutBytes -Sum).Sum + StderrBytes = ($Context.Requests | Measure-Object StderrBytes -Sum).Sum + } +} diff --git a/tools/wta/src/logging.rs b/tools/wta/src/logging.rs index a6472faad2..60c2522ffb 100644 --- a/tools/wta/src/logging.rs +++ b/tools/wta/src/logging.rs @@ -52,6 +52,36 @@ pub(crate) fn default_filter_directive(debug_assertions: bool) -> &'static str { } } +fn explicitly_configures_acp_dependency(directives: &str) -> bool { + directives + .split(',') + .map(str::trim) + .any(|directive| directive.starts_with("agent_client_protocol")) +} + +fn apply_dependency_privacy_cap(mut filter: EnvFilter, directives: Option<&str>) -> EnvFilter { + if !directives.is_some_and(explicitly_configures_acp_dependency) { + filter = filter.add_directive( + "agent_client_protocol=info" + .parse() + .expect("static ACP logging directive must be valid"), + ); + } + filter +} + +fn configured_filter(default_directives: &str) -> EnvFilter { + for variable in ["WTA_LOG", "RUST_LOG"] { + if let Ok(directives) = std::env::var(variable) { + if let Ok(filter) = EnvFilter::try_new(&directives) { + return apply_dependency_privacy_cap(filter, Some(&directives)); + } + } + } + + apply_dependency_privacy_cap(EnvFilter::new(default_directives), None) +} + /// Root of the WTA log tree: `/logs` (or a temp-dir fallback). fn logs_root() -> std::path::PathBuf { crate::runtime_paths::intelligent_terminal_local_root() @@ -120,9 +150,7 @@ pub fn init(process: &str) { let default_level = default_filter_directive(cfg!(debug_assertions)); - let filter = EnvFilter::try_from_env("WTA_LOG") - .or_else(|_| EnvFilter::try_from_default_env()) - .unwrap_or_else(|_| EnvFilter::new(default_level)); + let filter = configured_filter(default_level); tracing_subscriber::registry() .with(filter) @@ -449,8 +477,24 @@ fn prune_stale_helper_logs(log_dir: &Path) { #[cfg(test)] mod tests { use super::*; + use std::io::Write; + use std::sync::Arc; use tracing_subscriber::filter::LevelFilter; + #[derive(Clone)] + struct SharedWriter(Arc>>); + + impl Write for SharedWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + #[test] fn debug_build_default_is_debug() { assert_eq!( @@ -488,6 +532,65 @@ mod tests { assert_eq!(filter.max_level_hint(), Some(LevelFilter::DEBUG)); } + #[test] + fn global_debug_does_not_explicitly_enable_acp_dependency_payloads() { + assert!(!explicitly_configures_acp_dependency("debug")); + assert!(!explicitly_configures_acp_dependency( + "debug,wta=trace,acp.content=trace" + )); + + let filter = apply_dependency_privacy_cap(EnvFilter::new("debug"), Some("debug")); + assert!(filter.to_string().contains("agent_client_protocol=info")); + } + + #[test] + fn acp_dependency_payload_logging_requires_an_explicit_target() { + assert!(explicitly_configures_acp_dependency( + "debug,agent_client_protocol=debug" + )); + assert!(explicitly_configures_acp_dependency( + "info,agent_client_protocol::jsonrpc=trace" + )); + + let filter = apply_dependency_privacy_cap( + EnvFilter::new("debug,agent_client_protocol=debug"), + Some("debug,agent_client_protocol=debug"), + ); + assert!(!filter.to_string().contains("agent_client_protocol=info")); + } + + #[test] + fn global_debug_filter_drops_acp_dependency_payload_bodies() { + let output = Arc::new(Mutex::new(Vec::new())); + let writer = output.clone(); + let subscriber = tracing_subscriber::registry() + .with(apply_dependency_privacy_cap( + EnvFilter::new("debug"), + Some("debug"), + )) + .with( + fmt::layer() + .without_time() + .with_ansi(false) + .with_writer(move || SharedWriter(writer.clone())), + ); + + tracing::subscriber::with_default(subscriber, || { + tracing::debug!( + target: "agent_client_protocol::jsonrpc::outgoing_actor", + prompt = "secret-prompt", + "outgoing request" + ); + tracing::debug!(target: "wta_test", "visible WTA diagnostic"); + }); + + let bytes = output.lock().unwrap().clone(); + let log = String::from_utf8(bytes).unwrap(); + assert!(log.contains("visible WTA diagnostic")); + assert!(!log.contains("secret-prompt")); + assert!(!log.contains("outgoing request")); + } + #[test] fn prune_keeps_only_current_version() { let root = std::env::temp_dir().join(format!("wta-version-prune-{}", std::process::id())); diff --git a/tools/wta/src/protocol/acp/mock_agent_tests.rs b/tools/wta/src/protocol/acp/mock_agent_tests.rs index 99378dca90..8ba602d647 100644 --- a/tools/wta/src/protocol/acp/mock_agent_tests.rs +++ b/tools/wta/src/protocol/acp/mock_agent_tests.rs @@ -124,14 +124,22 @@ impl crate::shell::wt_channel::WtChannel for BlockingPromptContextChannel { method: &str, _params: serde_json::Value, ) -> anyhow::Result { - if method == "get_active_pane" { + if method == "get_pane_context" { self.started.notify_one(); self.release.notified().await; return Ok(serde_json::json!({ - "session_id": "context-pane", - "cwd": "C:\\work", - "pid": std::process::id(), - "is_agent_pane": false, + "pane": { + "session_id": "context-pane", + "cwd": "C:\\work", + "pid": std::process::id(), + "is_agent_pane": false, + }, + "content": null, + "output_source": "metadata_only", + "fallback_reason": "", + "line_count": 0, + "truncated": false, + "has_marks": false, })); } Err(anyhow::anyhow!( diff --git a/tools/wta/src/protocol/acp/prompt_builder.rs b/tools/wta/src/protocol/acp/prompt_builder.rs index 66893bb651..3782a463c2 100644 --- a/tools/wta/src/protocol/acp/prompt_builder.rs +++ b/tools/wta/src/protocol/acp/prompt_builder.rs @@ -109,7 +109,6 @@ pub(crate) async fn build_prompt_text( let context_request = ContextRequest { is_autofix, wt_connected, - shell_mgr, context_pane: resolved_context.context_pane.as_ref(), shell_exe: resolved_context.shell_exe.as_deref(), terminal_output: resolved_context.terminal_output.as_deref(), @@ -342,12 +341,8 @@ mod tests { ); } - /// Minimal [`crate::shell::wt_channel::WtChannel`] that answers - /// `get_active_pane` with a canned pane and the - /// `list_windows`/`list_tabs`/`list_panes` enumeration with canned - /// payloads; every other request errors. `read_pane_last_message` degrades - /// to `None` on those errors, which is all the assembly tests need (no - /// buffer content is asserted). + /// Minimal [`crate::shell::wt_channel::WtChannel`] that returns consolidated + /// pane context from canned active or explicit-source pane metadata. struct MockWtChannel { active_pane: serde_json::Value, /// Optional enumeration topology for `resolve_pane_by_session_id`: @@ -362,13 +357,37 @@ mod tests { async fn request( &self, method: &str, - _params: serde_json::Value, + params: serde_json::Value, ) -> anyhow::Result { let scripted = |v: &Option, what: &str| { v.clone() .ok_or_else(|| anyhow::anyhow!("MockWtChannel: no {what} scripted")) }; match method { + "get_pane_context" => { + let pane = if params.get("session_id").is_some() { + self.panes + .as_ref() + .and_then(|value| value.get("panes")) + .and_then(serde_json::Value::as_array) + .and_then(|panes| panes.first()) + .cloned() + .ok_or_else(|| { + anyhow::anyhow!("MockWtChannel: no source pane scripted") + })? + } else { + self.active_pane.clone() + }; + Ok(serde_json::json!({ + "pane": pane, + "content": serde_json::Value::Null, + "output_source": "metadata_only", + "fallback_reason": "", + "line_count": 0, + "truncated": false, + "has_marks": false, + })) + } "get_active_pane" => Ok(self.active_pane.clone()), "list_windows" => scripted(&self.windows, "list_windows"), "list_tabs" => scripted(&self.tabs, "list_tabs"), diff --git a/tools/wta/src/protocol/acp/prompt_context.rs b/tools/wta/src/protocol/acp/prompt_context.rs index 6c7aaedd61..4122fd04e4 100644 --- a/tools/wta/src/protocol/acp/prompt_context.rs +++ b/tools/wta/src/protocol/acp/prompt_context.rs @@ -37,6 +37,15 @@ fn truncate_for_prompt(text: &str, max_chars: usize) -> String { } } +fn preserve_protocol_truncation(text: &str, max_chars: usize, protocol_truncated: bool) -> String { + let bounded = truncate_for_prompt(text, max_chars); + if protocol_truncated && !bounded.ends_with("...") { + format!("{bounded}\n...") + } else { + bounded + } +} + fn json_str_or_num(value: Option<&serde_json::Value>) -> Option { match value { Some(serde_json::Value::String(s)) => Some(s.clone()), @@ -55,7 +64,7 @@ fn json_str_or_num(value: Option<&serde_json::Value>) -> Option { /// is visible in `wta-{process}.log`: /// * `last_message_request` — start, with pane_id and budgets /// * `last_message_result` — outcome: marks_hit | fallback_used | empty -async fn read_pane_last_message( +async fn read_pane_last_message_legacy( shell_mgr: &ShellManager, pane_id: &str, fallback_lines: u32, @@ -298,6 +307,85 @@ async fn resolve_pane_by_session_id( None } +struct CapturedPaneContext { + pane: serde_json::Value, + output: Option, +} + +async fn capture_pane_context( + shell_mgr: &ShellManager, + explicit_source: Option<&str>, + max_lines: u32, + max_chars: usize, +) -> Option { + let started = std::time::Instant::now(); + match shell_mgr + .wt_get_pane_context(explicit_source, max_lines, max_chars) + .await + { + Ok(value) => { + let pane = value.get("pane")?.clone(); + let protocol_truncated = value + .get("truncated") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let output = value + .get("content") + .and_then(serde_json::Value::as_str) + .filter(|content| !content.is_empty()) + .map(|content| { + preserve_protocol_truncation(content, max_chars, protocol_truncated) + }); + tracing::debug!( + target: "acp.terminal_context", + explicit_source = explicit_source.is_some(), + rpc_ms = started.elapsed().as_millis() as u64, + output_source = value + .get("output_source") + .and_then(serde_json::Value::as_str), + fallback_reason = value + .get("fallback_reason") + .and_then(serde_json::Value::as_str), + truncated = value.get("truncated").and_then(serde_json::Value::as_bool), + "pane_context_request_complete" + ); + Some(CapturedPaneContext { pane, output }) + } + Err(error) if format!("{error:#}").contains("WT_PROTOCOL_UNSUPPORTED_PANE_CONTEXT") => { + tracing::warn!( + target: "acp.terminal_context", + explicit_source = explicit_source.is_some(), + "pane_context_legacy_fallback" + ); + let pane = match explicit_source { + Some(source) => resolve_pane_by_session_id(shell_mgr, source).await?, + None => shell_mgr.wt_get_active_pane().await.ok()?, + }; + let is_agent = pane + .get("is_agent_pane") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + if is_agent { + return None; + } + let pane_id = json_str_or_num(pane.get("session_id"))?; + let output = + read_pane_last_message_legacy(shell_mgr, &pane_id, max_lines, max_chars).await; + Some(CapturedPaneContext { pane, output }) + } + Err(error) => { + tracing::debug!( + target: "acp.terminal_context", + explicit_source = explicit_source.is_some(), + rpc_ms = started.elapsed().as_millis() as u64, + error = %error, + "pane_context_request_failed" + ); + None + } + } +} + struct PlannerTerminalContext { json: String, target_pane_id: String, @@ -308,14 +396,14 @@ async fn build_terminal_context( shell_mgr: &ShellManager, pane_context: Option<&PaneContext>, ) -> Option { - // WT's GetActivePane already resolves the agent pane to the user's working - // pane (the "source"), so a single active-pane query gives us the right - // target. Pane IDs are process-globally unique, so we only need the pane - // id itself — tab/window aren't needed for addressing. - let active = match pane_context.and_then(|context| context.source_pane_id.as_deref()) { - Some(source) => resolve_pane_by_session_id(shell_mgr, source).await?, - None => shell_mgr.wt_get_active_pane().await.ok()?, - }; + let captured = capture_pane_context( + shell_mgr, + pane_context.and_then(|context| context.source_pane_id.as_deref()), + 24, + ACTIVE_PANE_CONTEXT_MAX_CHARS, + ) + .await?; + let active = captured.pane; let is_agent = active .get("is_agent_pane") @@ -351,21 +439,13 @@ async fn build_terminal_context( "terminal_context_target_resolved" ); - let buffer = read_pane_last_message( - shell_mgr, - &target_pane_id, - 24, - ACTIVE_PANE_CONTEXT_MAX_CHARS, - ) - .await; - let json = serde_json::to_string(&serde_json::json!({ "activeTarget": target_pane_id, "window_title": target_window_title, "cwd": target_cwd, "shell": target_shell, "locale": user_locale_tag(), - "buffer": buffer, + "buffer": captured.output, })) .ok()?; @@ -426,67 +506,42 @@ pub(super) async fn resolve_provider_context( return resolved; } - let active = shell_mgr.wt_get_active_pane().await.ok(); + let explicit_source = pane_context.and_then(|ctx| ctx.source_pane_id.as_deref()); + let Some(captured) = capture_pane_context( + shell_mgr, + explicit_source, + 30, + ACTIVE_PANE_CONTEXT_MAX_CHARS, + ) + .await + else { + return resolved; + }; - // Explicit source pane (error-triggered autofix) wins; otherwise fall - // back to the resolved active working pane (`/fix`). An active pane that - // is itself an agent pane is skipped — there's no terminal output there. - let explicit_source = pane_context.and_then(|ctx| ctx.source_pane_id.clone()); - let source_pane_id = explicit_source.clone().or_else(|| { - active.as_ref().and_then(|a| { - let is_agent = a - .get("is_agent_pane") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - if is_agent { - None - } else { - json_str_or_num(a.get("session_id")) - } - }) - }); - // When we resolved the pane ourselves (manual `/fix`, no explicit - // source), remember it so the App can fill `target_pane_id` — that is - // the pane the eventual fix command is sent to. + let is_agent = captured + .pane + .get("is_agent_pane") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + if is_agent { + return resolved; + } + + let source_pane_id = json_str_or_num(captured.pane.get("session_id")); if explicit_source.is_none() { resolved.resolved_fix_pane = source_pane_id.clone(); } + resolved.shell_exe = shell_from_active(&captured.pane); + resolved.context_pane = Some(captured.pane); + resolved.terminal_output = captured.output; - // The pane whose shell/cwd describe the FAILING command — drives the - // `### Shell Context` header and the command-not-found near-match gate. - // For a manual `/fix` the active pane IS the source. But error-triggered - // autofix can fire for a pane in a *non-focused* tab, so deriving the - // shell from `wt_get_active_pane()` would describe the wrong pane (e.g. - // a failing pwsh pane while bash is active) and mis-gate the near-match. - // Resolve the explicit source pane's JSON by *session id* (not by - // `PaneContext.tab_id`, which in autofix is a StableId `list_panes` - // won't accept — see `resolve_pane_by_session_id`). If that lookup fails, - // omit shell context rather than borrowing an unrelated active pane. - resolved.context_pane = match explicit_source.as_deref() { - Some(src) => resolve_pane_by_session_id(shell_mgr, src).await, - None => active, - }; - // Canonical shell exe (pwsh.exe / cmd.exe / wsl.exe …) of the failing - // pane — load-bearing for both the shell-context header and the - // command-not-found near-match gate. - resolved.shell_exe = resolved.context_pane.as_ref().and_then(shell_from_active); - - if let Some(source_pane_id) = source_pane_id { - tracing::debug!( - target: "acp.terminal_context", - source_pane_id = %source_pane_id, - shell = ?resolved.shell_exe, - mode = "autofix", - "terminal_context_target_resolved" - ); - resolved.terminal_output = read_pane_last_message( - shell_mgr, - &source_pane_id, - 30, - ACTIVE_PANE_CONTEXT_MAX_CHARS, - ) - .await; - } + tracing::debug!( + target: "acp.terminal_context", + source_pane_id = ?source_pane_id, + shell = ?resolved.shell_exe, + mode = "autofix", + "terminal_context_target_resolved" + ); resolved } @@ -504,9 +559,6 @@ pub(super) struct ContextRequest<'a> { pub(super) is_autofix: bool, /// Whether the WT protocol channel is live (pane queries are meaningful). pub(super) wt_connected: bool, - /// Shell manager for providers that query WT directly (planner terminal - /// context). - pub(super) shell_mgr: &'a ShellManager, /// Autofix only: the JSON of the pane whose shell/cwd describe the failing /// command (the source pane — for error-triggered autofix this can be a /// pane in a non-focused tab, not the active pane). `None` when WT is not @@ -817,7 +869,10 @@ fn near_match_list(matches: &[String]) -> String { mod tests { use super::*; use crate::shell::ShellManager; - use std::sync::Arc; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, Mutex, + }; /// `shell_from_active` resolves our own pid to a real exe name (the test /// binary). Proves the pid → image-name path works end to end on Windows; @@ -881,7 +936,15 @@ mod tests { _params: serde_json::Value, ) -> anyhow::Result { match method { - "get_active_pane" => Ok(self.active_pane.clone()), + "get_pane_context" => Ok(serde_json::json!({ + "pane": self.active_pane.clone(), + "content": serde_json::Value::Null, + "output_source": "metadata_only", + "fallback_reason": "", + "line_count": 0, + "truncated": false, + "has_marks": false, + })), other => Err(anyhow::anyhow!("MockWtChannel: unhandled method {other}")), } } @@ -895,6 +958,210 @@ mod tests { ShellManager::new().with_wt_channel(Arc::new(MockWtChannel { active_pane })) } + struct RecordingPaneContextChannel { + requests: AtomicUsize, + params: Mutex>, + error: Option<&'static str>, + } + + #[async_trait::async_trait] + impl crate::shell::wt_channel::WtChannel for RecordingPaneContextChannel { + async fn request( + &self, + method: &str, + params: serde_json::Value, + ) -> anyhow::Result { + assert_eq!(method, "get_pane_context"); + self.requests.fetch_add(1, Ordering::Relaxed); + *self.params.lock().unwrap() = Some(params); + if let Some(error) = self.error { + anyhow::bail!("{error}"); + } + Ok(serde_json::json!({ + "pane": { + "session_id": "pane-explicit", + "is_agent_pane": false, + }, + "content": "command output", + "output_source": "last_command", + "fallback_reason": "", + "line_count": 1, + "truncated": false, + "has_marks": true, + })) + } + + fn is_available(&self) -> bool { + true + } + } + + #[tokio::test] + async fn consolidated_context_uses_one_request_with_explicit_source() { + let channel = Arc::new(RecordingPaneContextChannel { + requests: AtomicUsize::new(0), + params: Mutex::new(None), + error: None, + }); + let mgr = ShellManager::new().with_wt_channel(channel.clone()); + + let captured = capture_pane_context(&mgr, Some("pane-explicit"), 30, 4000) + .await + .expect("consolidated pane context should resolve"); + + assert_eq!(captured.pane["session_id"], "pane-explicit"); + assert_eq!(captured.output.as_deref(), Some("command output")); + assert_eq!(channel.requests.load(Ordering::Relaxed), 1); + let params = channel.params.lock().unwrap().clone().unwrap(); + assert_eq!(params["session_id"], "pane-explicit"); + assert_eq!(params["max_lines"], 30); + assert_eq!(params["max_chars"], 4000); + } + + #[tokio::test] + async fn planner_and_autofix_resolve_context_with_one_request() { + for is_autofix in [false, true] { + for explicit_source in [None, Some("pane-explicit")] { + let channel = Arc::new(RecordingPaneContextChannel { + requests: AtomicUsize::new(0), + params: Mutex::new(None), + error: None, + }); + let mgr = ShellManager::new().with_wt_channel(channel.clone()); + let pane_context = PaneContext { + source_pane_id: explicit_source.map(str::to_string), + ..Default::default() + }; + + let resolved = + resolve_provider_context(is_autofix, true, &mgr, Some(&pane_context)).await; + + assert_eq!(channel.requests.load(Ordering::Relaxed), 1); + let params = channel.params.lock().unwrap().clone().unwrap(); + assert_eq!( + params.get("session_id").and_then(|id| id.as_str()), + explicit_source + ); + assert_eq!(params["max_lines"], if is_autofix { 30 } else { 24 }); + assert_eq!(params["max_chars"], 4000); + if is_autofix { + assert_eq!( + resolved.context_pane.unwrap()["session_id"], + "pane-explicit" + ); + assert_eq!(resolved.terminal_output.as_deref(), Some("command output")); + assert_eq!( + resolved.resolved_fix_pane.as_deref(), + explicit_source.is_none().then_some("pane-explicit") + ); + } else { + assert_eq!( + resolved.resolved_planner_pane.as_deref(), + Some("pane-explicit") + ); + let context: serde_json::Value = + serde_json::from_str(&resolved.planner_terminal_context.unwrap()).unwrap(); + assert_eq!(context["activeTarget"], "pane-explicit"); + assert_eq!(context["buffer"], "command output"); + } + } + } + } + + #[tokio::test] + async fn pane_context_failure_does_not_retry_against_another_pane() { + for is_autofix in [false, true] { + for explicit_source in [None, Some("pane-missing")] { + let channel = Arc::new(RecordingPaneContextChannel { + requests: AtomicUsize::new(0), + params: Mutex::new(None), + error: Some("GetPaneContext failed: 0x80070490"), + }); + let mgr = ShellManager::new().with_wt_channel(channel.clone()); + let pane_context = PaneContext { + source_pane_id: explicit_source.map(str::to_string), + ..Default::default() + }; + + let resolved = + resolve_provider_context(is_autofix, true, &mgr, Some(&pane_context)).await; + + assert_eq!(channel.requests.load(Ordering::Relaxed), 1); + assert!(resolved.context_pane.is_none()); + assert!(resolved.terminal_output.is_none()); + assert!(resolved.resolved_fix_pane.is_none()); + assert!(resolved.planner_terminal_context.is_none()); + assert!(resolved.resolved_planner_pane.is_none()); + } + } + } + + struct LegacyPaneContextChannel { + methods: Mutex>, + } + + #[async_trait::async_trait] + impl crate::shell::wt_channel::WtChannel for LegacyPaneContextChannel { + async fn request( + &self, + method: &str, + _params: serde_json::Value, + ) -> anyhow::Result { + self.methods.lock().unwrap().push(method.to_string()); + match method { + "get_pane_context" => Err(anyhow::anyhow!( + "wtcli failed: WT_PROTOCOL_UNSUPPORTED_PANE_CONTEXT" + )), + "list_windows" => Ok(serde_json::json!({ + "windows": [{ "window_id": 1 }] + })), + "list_tabs" => Ok(serde_json::json!({ + "tabs": [{ "tab_id": 2 }] + })), + "list_panes" => Ok(serde_json::json!({ + "panes": [{ + "session_id": "pane-legacy", + "is_agent_pane": false, + }] + })), + "read_pane_output" => Ok(serde_json::json!({ + "content": "legacy output", + "has_marks": true, + })), + other => Err(anyhow::anyhow!("unexpected legacy method {other}")), + } + } + + fn is_available(&self) -> bool { + true + } + } + + #[tokio::test] + async fn unsupported_server_uses_observable_legacy_path() { + let channel = Arc::new(LegacyPaneContextChannel { + methods: Mutex::new(Vec::new()), + }); + let mgr = ShellManager::new().with_wt_channel(channel.clone()); + + let captured = capture_pane_context(&mgr, Some("pane-legacy"), 30, 4000) + .await + .expect("legacy pane context should resolve"); + + assert_eq!(captured.pane["session_id"], "pane-legacy"); + assert_eq!(captured.output.as_deref(), Some("legacy output")); + assert_eq!( + *channel.methods.lock().unwrap(), + vec![ + "get_pane_context".to_string(), + "list_windows".to_string(), + "list_tabs".to_string(), + "list_panes".to_string(), + "read_pane_output".to_string(), + ] + ); + } + #[tokio::test] async fn build_terminal_context_none_without_wt_channel() { let mgr = ShellManager::new(); @@ -930,7 +1197,7 @@ mod tests { assert_eq!(context.target_pane_id, "pane-9"); assert_eq!(v["window_title"], "My Tab"); assert_eq!(v["cwd"], "C:\\workspace"); - // The mock errors the buffer reads, so `buffer` is null. + // The mock returns metadata-only context, so `buffer` is null. assert!(v["buffer"].is_null()); // pid is our own test process → shell resolves to the test binary exe. if cfg!(windows) { @@ -953,6 +1220,18 @@ mod tests { assert_eq!(truncate_for_prompt("hello", 3), "hel\n..."); } + #[test] + fn protocol_truncation_remains_visible_at_the_prompt_boundary() { + assert_eq!( + preserve_protocol_truncation("bounded output", 4000, true), + "bounded output\n..." + ); + assert_eq!( + preserve_protocol_truncation("complete output", 4000, false), + "complete output" + ); + } + #[test] fn truncate_for_prompt_is_char_safe() { let s: String = std::iter::repeat('é').take(10).collect(); @@ -980,11 +1259,10 @@ mod tests { assert_eq!(json_str_or_num(None), None); } - fn req_planner(mgr: &ShellManager, wt_connected: bool) -> ContextRequest<'_> { + fn req_planner(_mgr: &ShellManager, wt_connected: bool) -> ContextRequest<'_> { ContextRequest { is_autofix: false, wt_connected, - shell_mgr: mgr, context_pane: None, shell_exe: None, terminal_output: None, diff --git a/tools/wta/src/shell/shell_manager.rs b/tools/wta/src/shell/shell_manager.rs index 41c459cd0f..4c87a31f60 100644 --- a/tools/wta/src/shell/shell_manager.rs +++ b/tools/wta/src/shell/shell_manager.rs @@ -611,6 +611,24 @@ impl ShellManager { self.wt()?.request("read_pane_output", params).await } + /// Resolve an explicit source pane, or the effective active pane when no + /// source is provided, and capture bounded context in one wtcli request. + pub async fn wt_get_pane_context( + &self, + pane_id: Option<&str>, + max_lines: u32, + max_chars: usize, + ) -> anyhow::Result { + let mut params = serde_json::json!({ + "max_lines": max_lines, + "max_chars": max_chars, + }); + if let Some(pane_id) = pane_id { + params["session_id"] = pane_id.into(); + } + self.wt()?.request("get_pane_context", params).await + } + /// Switch focus to a pane (switching tab if needed). pub async fn wt_focus_pane(&self, pane_id: &str) -> anyhow::Result { self.wt()? diff --git a/tools/wta/src/shell/wt_channel/cli_channel.rs b/tools/wta/src/shell/wt_channel/cli_channel.rs index b9c08c800f..bc2f1c37c9 100644 --- a/tools/wta/src/shell/wt_channel/cli_channel.rs +++ b/tools/wta/src/shell/wt_channel/cli_channel.rs @@ -961,6 +961,54 @@ impl WtChannel for CliChannel { self.run_wtcli(&args).await } "get_active_pane" => self.run_wtcli(&["active-pane"]).await, + "get_pane_context" => { + const MAX_CONTEXT_LINES: u64 = 1000; + const MAX_CONTEXT_CHARS: u64 = 100_000; + + let pane_id = params + .get("session_id") + .map(|value| { + json_id_as_str(value).ok_or_else(|| { + anyhow!("get_pane_context: 'session_id' must be a string") + }) + }) + .transpose()?; + let max_lines = params + .get("max_lines") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| { + anyhow!("get_pane_context: missing or invalid 'max_lines' parameter") + })?; + let max_chars = params + .get("max_chars") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| { + anyhow!("get_pane_context: missing or invalid 'max_chars' parameter") + })?; + if max_lines > MAX_CONTEXT_LINES { + bail!("get_pane_context: 'max_lines' exceeds {MAX_CONTEXT_LINES}"); + } + if max_chars > MAX_CONTEXT_CHARS { + bail!("get_pane_context: 'max_chars' exceeds {MAX_CONTEXT_CHARS}"); + } + + let max_lines_owned = max_lines.to_string(); + let max_chars_owned = max_chars.to_string(); + let mut args = vec![ + "get-pane-context", + "--max-lines", + &max_lines_owned, + "--max-chars", + &max_chars_owned, + ]; + if let Some(pane_id) = pane_id.as_deref() { + if pane_id.is_empty() { + bail!("get_pane_context: 'session_id' must not be empty"); + } + args.extend(["--target", pane_id]); + } + self.run_wtcli(&args).await + } "get_settings" => self.run_wtcli(&["get-settings"]).await, "read_pane_output" => { let pane_id = params