diff --git a/.github/workflows/ci-full.yml b/.github/workflows/ci-full.yml index 7c416d4c..f95f81fc 100644 --- a/.github/workflows/ci-full.yml +++ b/.github/workflows/ci-full.yml @@ -37,6 +37,31 @@ jobs: shell: pwsh run: dotnet build Phantom.Workspaces.slnx -c Release --no-restore + # The SDK-staged copilot.exe is a server-only runtime (it rejects --version). + # BYOK tests need the full standalone CLI, pinned to the SDK's CLI version. + - name: Install Copilot CLI for BYOK tests + shell: pwsh + run: | + $version = '1.0.83' # GitHub.Copilot.SDK 1.0.13's CopilotCliVersion + $expectedSha256 = '0e07221a275fdf7e61619c53566e3a421fd646d74d8e9ca491dbbff221f22945' + $directory = Join-Path $PWD '.ci-tools\copilot' + New-Item -ItemType Directory -Path $directory -Force | Out-Null + $archive = Join-Path $directory 'copilot.zip' + Invoke-WebRequest "https://github.com/github/copilot-cli/releases/download/v$version/copilot-win32-x64.zip" -OutFile $archive + if ((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash -ne $expectedSha256) { + throw "Copilot CLI archive hash mismatch" + } + Expand-Archive -LiteralPath $archive -DestinationPath $directory + $cli = Join-Path $directory 'copilot.exe' + if (-not (Test-Path -LiteralPath $cli -PathType Leaf)) { + throw "Standalone Copilot CLI not found: $cli" + } + & $cli --version + if ($LASTEXITCODE -ne 0) { + throw "Standalone Copilot CLI failed its version check (exit $LASTEXITCODE)" + } + "COPILOT_CLI_PATH=$cli" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + # windows-latest cannot start the Linux MongoDB container, so exclude the # SlowDocker tests here; they run in the ubuntu-latest job below. Also exclude # RequiresLocalConsole: the hosted windows-latest ConPTY renders no cmd.exe/pwsh @@ -47,7 +72,12 @@ jobs: shell: pwsh run: | dotnet test Phantom.Workspaces.slnx --no-restore --nologo -v minimal ` - --filter "Category!=SlowDocker & Category!=RequiresLocalConsole" + -maxcpucount:1 ` + --filter "Category!=SlowDocker & Category!=RequiresLocalConsole" 2>&1 | + Tee-Object -FilePath scripts/test-results.log + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } - name: Upload Windows test logs if: always() @@ -73,11 +103,40 @@ jobs: - name: Set up build uses: ./.github/actions/setup-build + # Pull before the test host starts: Atlas Local's cold image download can exceed the + # test harness's per-test inactivity watchdog on a fresh hosted runner. + - name: Pull MongoDB Atlas Local image + run: docker pull mongodb/mongodb-atlas-local:latest + + # The image runs as a non-root user. New bind directories created by .NET under + # the runner's default TMPDIR are owned by the runner and are not writable by MongoDB. + # Default ACLs also cover the unique directories created by the replica-set tests. + - name: Prepare writable MongoDB test mounts + shell: bash + run: | + if ! command -v setfacl >/dev/null; then + sudo apt-get update -qq + sudo apt-get install -y acl + fi + mongo_uid=$(docker run --rm --entrypoint id mongodb/mongodb-atlas-local:latest -u) + directory="$GITHUB_WORKSPACE/.ci-tools/mongodb-tmp" + mkdir -p "$directory" + setfacl -m "u:$mongo_uid:rwx,d:u:$mongo_uid:rwx" "$directory" + echo "TMPDIR=$directory" >> "$GITHUB_ENV" + # Only the platform-agnostic data-layer Docker/Git integration tests run on # Linux; the Windows-only GUI/scheduled-task/junction tests run above. - name: Run Docker + Git integration tests shell: pwsh - run: .\scripts\run-tests.ps1 -Mode full -TestNames Phantom.Workspaces.Data.MongoDB.Tests + run: .\scripts\run-tests.ps1 -Mode full -TestNames Phantom.Workspaces.Data.MongoDB.Tests -PerTestHangTimeout 10m + + - name: Diagnose MongoDB container on failure + if: failure() + run: | + docker ps -a --filter name=pw-mongodb-test + docker inspect pw-mongodb-test --format '{{json .State}}' || true + docker logs --tail 150 pw-mongodb-test 2>&1 || true + free -h - name: Upload Linux test logs if: always() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c21aeb1a..3da798bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: build-and-test: name: Build and fast tests (windows-latest) runs-on: windows-latest - timeout-minutes: 10 + timeout-minutes: 120 env: AVALONIA_UI_LICENSE_KEY: ${{ secrets.AVALONIA_UI_LICENSE_KEY }} steps: @@ -37,7 +37,7 @@ jobs: shell: pwsh run: | Write-Host 'Fast filter: Category!=SlowDocker & Category!=SlowGit & Category!=Integration & Category!=WebView & Category!=SlowLayout & Category!=RequiresLocalConsole' - .\scripts\run-tests.ps1 -Mode fast + .\scripts\run-tests.ps1 -Mode fast -PerTestHangTimeout 10m - name: Upload test results log if: always() diff --git a/Phantom.Workspaces.Agent.Gui.Tests/AgentChatOutputControlTests.cs b/Phantom.Workspaces.Agent.Gui.Tests/AgentChatOutputControlTests.cs index c3d8db66..4f091f43 100644 --- a/Phantom.Workspaces.Agent.Gui.Tests/AgentChatOutputControlTests.cs +++ b/Phantom.Workspaces.Agent.Gui.Tests/AgentChatOutputControlTests.cs @@ -78,7 +78,9 @@ public void ChatOutput_SuppressedAssistantHeaderWithInspectTargets_RemainsHidden { var html = ReadShellHtml(); Assert.DoesNotContain("header.hidden = false", html, StringComparison.Ordinal); - Assert.Contains("header.hidden ? message.querySelector(\":scope > .chat-contents\") : meta", html, StringComparison.Ordinal); + Assert.Contains("var host = header.hidden", html, StringComparison.Ordinal); + Assert.Contains("message.querySelector(\":scope > .chat-contents > .chat-tool-group-actions\")", html, StringComparison.Ordinal); + Assert.Contains("message.querySelector(\":scope > .chat-contents\")", html, StringComparison.Ordinal); Assert.Contains("postToHost({", html, StringComparison.Ordinal); Assert.Contains("type: \"inspect\"", html, StringComparison.Ordinal); Assert.Contains("CopyGutter.init(document)", html, StringComparison.Ordinal); @@ -98,8 +100,11 @@ public void ChatOutputShellHtml_UsageInspectAffordance_DeduplicatesPerMessage() { var html = ReadShellHtml(); - Assert.Contains("meta.querySelector(\".usage-gutter-btn\")", html, StringComparison.Ordinal); - Assert.DoesNotContain("previousElementSibling", html, StringComparison.Ordinal); + Assert.Contains("var leader = runId && Array.from(document.querySelectorAll(\"details.chat-tool-group[data-assistant-run-id]\"))", html, StringComparison.Ordinal); + Assert.Contains("var host = leader && leader.previousElementSibling;", html, StringComparison.Ordinal); + Assert.Contains("var ids = new Set(targets.map(function (target) { return target.id; }));", html, StringComparison.Ordinal); + Assert.Contains("if (!ids.has(button.getAttribute(\"data-segment-target-id\"))) { button.remove(); }", html, StringComparison.Ordinal); + Assert.Contains("if (button.parentElement !== host) { button.remove(); }", html, StringComparison.Ordinal); } [Fact] diff --git a/Phantom.Workspaces.Agent.Gui.Tests/ChatOutputHtmlModelTests.cs b/Phantom.Workspaces.Agent.Gui.Tests/ChatOutputHtmlModelTests.cs index 380a957c..13c15fee 100644 --- a/Phantom.Workspaces.Agent.Gui.Tests/ChatOutputHtmlModelTests.cs +++ b/Phantom.Workspaces.Agent.Gui.Tests/ChatOutputHtmlModelTests.cs @@ -1617,7 +1617,7 @@ public void GenerateHistoryChunk_DoesNotInvokeOnInsertOrEmitSinkCommands() } [Fact] - public void BuildHistoryRenderPlan_ResultInNewerChunkCallInOlderChunk_InjectedIntoCall() + public void BuildHistoryRenderPlan_ResultInNewerChunkAfterNarration_RemainsInNewerChunk() { // The call sits at index 10 (older chunk) while its result sits at index 300 (newer chunk). var snapshot = Enumerable.Range(0, 10) @@ -1636,20 +1636,17 @@ public void BuildHistoryRenderPlan_ResultInNewerChunkCallInOlderChunk_InjectedIn var plan = BuildPlan(snapshot, sink); - // The result-only message renders no element of its own. - Assert.False(plan.Slots[300].HasDomElement); + Assert.True(plan.Slots[300].HasDomElement); - // The older chunk containing the call renders the injected result nested under it. Assert.True(plan.Chunks.Count >= 2); var olderChunk = plan.Chunks.First(chunk => chunk.Start <= 10 && chunk.End > 10); var html = ChatOutputHtmlModel.GenerateHistoryChunk(plan, olderChunk.Start, olderChunk.End); - Assert.Contains("cross-chunk result data", html); - Assert.Contains("chat-tool-result", html); + Assert.DoesNotContain("cross-chunk result data", html); - // The newer chunk contains no standalone rendering of the result message. var newerChunk = plan.Chunks.First(chunk => chunk.Start <= 300 && chunk.End > 300); var newerHtml = ChatOutputHtmlModel.GenerateHistoryChunk(plan, newerChunk.Start, newerChunk.End); - Assert.DoesNotContain($"id=\"{ChatOutputHtmlRenderer.MessageId(300)}\"", newerHtml); + Assert.Contains($"id=\"{ChatOutputHtmlRenderer.MessageId(300)}\"", newerHtml); + Assert.Contains("cross-chunk result data", newerHtml); } [Fact] @@ -2706,7 +2703,7 @@ public async Task Regression_BugD_OnInsert_UsesContainerPath_NotLoadAfterHardcod } [AvaloniaFact(Timeout = 15_000)] - public async Task Regression_BugE_CrossChunkToolResult_MatchesCall() + public async Task Regression_BugE_CrossChunkToolResult_AfterNarrationRendersAtEncounterPosition() { // The call lands in the older chunk while its result lands in the newer chunk. var history = new ObservableCollection( @@ -2733,14 +2730,11 @@ public async Task Regression_BugE_CrossChunkToolResult_MatchesCall() var allContent = string.Concat(sink.ContentOperations.Select(op => op.Content)); - // The result is injected (nested under its call), never rendered standalone. Assert.Contains("cross-chunk result data", allContent); - Assert.DoesNotContain($"id=\"{ChatOutputHtmlRenderer.MessageId(300)}\"", allContent); + Assert.Contains($"id=\"{ChatOutputHtmlRenderer.MessageId(300)}\"", allContent); - // The chunk containing the call renders it with the injected result. var chunkWithCall = sink.ContentOperations.First(op => op.Content.Contains("my_tool")); - Assert.Contains("cross-chunk result data", chunkWithCall.Content); - Assert.Contains("chat-tool-result", chunkWithCall.Content); + Assert.DoesNotContain("cross-chunk result data", chunkWithCall.Content); } // ── Live insertion anchor regression tests (issue #900) ──────────────────── @@ -2909,8 +2903,7 @@ public async Task ChatOutput_CompletedTurnWithInterleavedAssistantContent_Coales using var model = new ChatOutputHtmlModel(history, new ObservableCollection(), () => true, sink); await model.HistoryLoaded; var html = string.Concat(sink.ContentOperations.Select(op => op.Content)); - Assert.Single(System.Text.RegularExpressions.Regex.Matches(html, "class=\"chat-content chat-tool-group\"").Cast()); - Assert.Contains("2 calls", html); + Assert.Equal(2, System.Text.RegularExpressions.Regex.Matches(html, "class=\"chat-content chat-tool-group\"").Count); Assert.Contains("id=\"tool-group-0-header\" hidden", html); var read = html.IndexOf("read_file(…)", StringComparison.Ordinal); var between = html.IndexOf("between calls", StringComparison.Ordinal); @@ -2918,7 +2911,9 @@ public async Task ChatOutput_CompletedTurnWithInterleavedAssistantContent_Coales var finished = html.IndexOf("finished", StringComparison.Ordinal); Assert.True(read < between && between < write && write < finished, "Every call and assistant text must remain in encounter order."); - Assert.Contains("class=\"chat-content chat-tool-group\" open", html); + Assert.DoesNotContain("between calls", html.Substring(html.IndexOf("id=\"tool-group-0-details\"", StringComparison.Ordinal), + html.IndexOf("", html.IndexOf("id=\"tool-group-0-details\"", StringComparison.Ordinal), StringComparison.Ordinal) + - html.IndexOf("id=\"tool-group-0-details\"", StringComparison.Ordinal))); } [AvaloniaFact(Timeout = 15_000)] @@ -2939,13 +2934,121 @@ public async Task ChatOutput_SameRunMixedContent_GroupsAllCallsWithoutLosingText using var model = new ChatOutputHtmlModel(history, new ObservableCollection(), () => true, sink); await model.HistoryLoaded; var html = string.Concat(sink.ContentOperations.Select(op => op.Content)); - Assert.Single(System.Text.RegularExpressions.Regex.Matches(html, "class=\"chat-content chat-tool-group\"").Cast()); - Assert.Contains("2 calls", html); + Assert.Equal(2, System.Text.RegularExpressions.Regex.Matches(html, "class=\"chat-content chat-tool-group\"").Count); Assert.True(html.IndexOf("before call", StringComparison.Ordinal) < html.IndexOf("read_file(…)", StringComparison.Ordinal)); Assert.True(html.IndexOf("read_file(…)", StringComparison.Ordinal) < html.IndexOf("between calls", StringComparison.Ordinal)); Assert.True(html.IndexOf("between calls", StringComparison.Ordinal) < html.IndexOf("write_file(…)", StringComparison.Ordinal)); Assert.Contains("result-one", html); Assert.Contains("result-two", html); + Assert.True(html.IndexOf("before call", StringComparison.Ordinal) < + html.IndexOf("read_file(…)", StringComparison.Ordinal)); + } + + [AvaloniaFact(Timeout = 15_000)] + public async Task ChatOutput_SameRunMixedContent_SplitsTextAndCallsWithoutLosingOrder() + { + var history = new ObservableCollection + { + new() + { + Role = ChatRole.Assistant, AssistantRunId = "run", + Contents = + [ + new TextContent("before segment"), + new FunctionCallContent("c1", "first_tool"), + new TextContent("between segments"), + new FunctionCallContent("c2", "second_tool"), + new TextContent("after segment"), + ], + }, + }; + var sink = new RecordingSink(); + using var model = new ChatOutputHtmlModel(history, new ObservableCollection(), () => true, sink); + await model.HistoryLoaded; + var html = string.Concat(sink.ContentOperations.Select(op => op.Content)); + Assert.Equal(2, System.Text.RegularExpressions.Regex.Matches(html, "class=\"chat-content chat-tool-group\"").Count); + var markers = new[] { "before segment", "first_tool(…)", "between segments", "second_tool(…)", "after segment" }; + var positions = markers.Select(text => html.IndexOf(text, StringComparison.Ordinal)).ToArray(); + Assert.All(positions, position => Assert.True(position >= 0)); + Assert.True(positions.SequenceEqual(positions.OrderBy(position => position))); + Assert.DoesNotContain("between segments", html.Substring( + html.IndexOf("id=\"history-0-1-details\"", StringComparison.Ordinal), + html.IndexOf("", html.IndexOf("id=\"history-0-1-details\"", StringComparison.Ordinal), StringComparison.Ordinal) + - html.IndexOf("id=\"history-0-1-details\"", StringComparison.Ordinal))); + } + + [AvaloniaFact(Timeout = 15_000)] + public async Task ChatOutput_MixedCallNarrationResult_PreservesBindingOrderInLiveAndReload() + { + var item = new AgentChatHistoryItem + { + Role = ChatRole.Assistant, AssistantRunId = "run", + Contents = + [ + new FunctionCallContent("c1", "first_tool"), + new TextContent("between call and result"), + new FunctionResultContent("c1", "result-after-text"), + ], + }; + var liveHistory = new ObservableCollection(); + var liveSink = new RecordingSink(); + using var live = new ChatOutputHtmlModel(liveHistory, new ObservableCollection(), + () => true, liveSink); + await live.HistoryLoaded; + liveHistory.Add(item); + var reloadSink = new RecordingSink(); + using var reload = new ChatOutputHtmlModel(new ObservableCollection { item }, + new ObservableCollection(), () => true, reloadSink); + await reload.HistoryLoaded; + foreach (var sink in new[] { liveSink, reloadSink }) + { + var html = string.Concat(sink.ContentOperations.Select(op => op.Content)); + var call = html.IndexOf("first_tool(…)", StringComparison.Ordinal); + var narration = html.IndexOf("between call and result", StringComparison.Ordinal); + var result = html.IndexOf("tool result: c1", StringComparison.Ordinal); + Assert.True(call >= 0 && call < narration && narration < result); + Assert.Contains("result-after-text", html); + } + } + + [AvaloniaFact(Timeout = 15_000)] + public async Task ChatOutput_ResultAfterSeparateNarration_DoesNotInjectBeforeProse() + { + var items = new[] + { + ToolCallMessage("first_tool", "c1") with { AssistantRunId = "run" }, + TextMessage(ChatRole.Assistant, "between call and result") with { AssistantRunId = "run" }, + new AgentChatHistoryItem { Role = ChatRole.Tool, AssistantRunId = "run", + Contents = [new FunctionResultContent("c1", "after-narration")] }, + }; + var sink = new RecordingSink(); + using var model = new ChatOutputHtmlModel(new ObservableCollection(items), + new ObservableCollection(), () => true, sink); + await model.HistoryLoaded; + var html = string.Concat(sink.ContentOperations.Select(op => op.Content)); + Assert.True(html.IndexOf("between call and result", StringComparison.Ordinal) < + html.IndexOf("tool result: c1", StringComparison.Ordinal)); + Assert.Contains("after-narration", html); + } + + [AvaloniaFact(Timeout = 15_000)] + public async Task ChatOutput_RunAcrossHistoryChunkBoundary_PreservesOneSummaryAndProseOrder() + { + var items = Enumerable.Range(0, 199).Select(i => TextMessage(ChatRole.User, $"user-{i}")).ToList(); + items.Add(ToolCallMessage("first_tool", "c1") with { AssistantRunId = "chunk-run" }); + items.Add(TextMessage(ChatRole.Assistant, "between calls") with { AssistantRunId = "chunk-run" }); + items.Add(ToolCallMessage("second_tool", "c2") with { AssistantRunId = "chunk-run" }); + var sink = new RecordingSink(); + using var model = new ChatOutputHtmlModel(new ObservableCollection(items), + new ObservableCollection(), () => true, sink); + await model.HistoryLoaded; + var html = string.Concat(sink.ContentOperations.Select(op => op.Content)); + Assert.True(html.IndexOf("first_tool(…)", StringComparison.Ordinal) < + html.IndexOf("between calls", StringComparison.Ordinal)); + Assert.True(html.IndexOf("between calls", StringComparison.Ordinal) < + html.IndexOf("second_tool(…)", StringComparison.Ordinal)); + Assert.Equal(2, System.Text.RegularExpressions.Regex.Matches(html, "data-assistant-run-id=\"chunk-run\"").Count); + Assert.Equal(2, System.Text.RegularExpressions.Regex.Matches(html, "class=\"chat-content chat-tool-group\"").Count); } [AvaloniaFact(Timeout = 30_000)] @@ -3011,8 +3114,7 @@ void CheckCompletion() var reloadedHtml = string.Concat(reloadSink.ContentOperations.Select(op => op.Content)); foreach (var html in new[] { liveHtml, reloadedHtml }) { - Assert.Single(System.Text.RegularExpressions.Regex.Matches(html, "class=\"chat-content chat-tool-group\"").Cast()); - Assert.Contains("2 calls", html); + Assert.True(System.Text.RegularExpressions.Regex.Matches(html, "class=\"chat-content chat-tool-group\"").Count >= 1); Assert.Contains("read-result", html); Assert.Contains("write-result", html); Assert.True(html.IndexOf("read(…)", StringComparison.Ordinal) < diff --git a/Phantom.Workspaces.Agent.Gui.Tests/ChatOutputHtmlRendererTests.cs b/Phantom.Workspaces.Agent.Gui.Tests/ChatOutputHtmlRendererTests.cs index 73fd5c81..3c11b2cf 100644 --- a/Phantom.Workspaces.Agent.Gui.Tests/ChatOutputHtmlRendererTests.cs +++ b/Phantom.Workspaces.Agent.Gui.Tests/ChatOutputHtmlRendererTests.cs @@ -25,6 +25,31 @@ public void RenderToolCallGroup_ActiveAndCompletedStates_EmitsAccessibleStatusWi Assert.DoesNotContain("tool-active-indicator", completed); Assert.Contains("2 calls", completed); } + + [Fact] + public void ChatOutput_GroupWithSuppressedRoleHeader_PlacesActionsBesideVisibleSummary() + { + var html = ChatOutputHtmlRenderer.RenderToolCallGroup("group", ["read"], 1, + "
", suppressRoleHeader: true); + var actions = html.IndexOf("class=\"chat-tool-group-actions\"", StringComparison.Ordinal); + var details = html.IndexOf("id=\"group-details\"", StringComparison.Ordinal); + var summary = html.IndexOf("id=\"group-summary\"", StringComparison.Ordinal); + Assert.True(actions > 0 && actions < details && details < summary); + Assert.Contains("aria-label=\"Tool group actions\"", html, StringComparison.Ordinal); + Assert.DoesNotContain("call payload"); + Assert.Contains("id=\"history-0-1\"", html); + Assert.Contains("id=\"history-0-1-details\"", html); + Assert.Contains("data-assistant-run-id=\"run\"", html); + Assert.Contains("data-segment-call-count=\"1\"", html); + Assert.Contains("call payload", html); + } [Fact] public void RenderCollapsible_EmitsDataStickyLevelOnSummary() { @@ -530,9 +555,8 @@ public void RenderToolCallGroupSummary_DuplicateToolNames_DedupedOnce() // The model dedupes; the renderer lists whatever it is given. Verify a single entry renders once. var html = ChatOutputHtmlRenderer.RenderToolCallGroupSummary("grp-0", new[] { "powershell" }, 3); - var first = html.IndexOf("powershell", StringComparison.Ordinal); - var last = html.LastIndexOf("powershell", StringComparison.Ordinal); - Assert.Equal(first, last); + Assert.Single(System.Text.RegularExpressions.Regex.Matches( + html, "powershell").Cast()); } [Fact] diff --git a/Phantom.Workspaces.Agent.Gui.Tests/ChatOutputHtmlStructuralInvariantsTests.cs b/Phantom.Workspaces.Agent.Gui.Tests/ChatOutputHtmlStructuralInvariantsTests.cs index 0f308671..1f8a835c 100644 --- a/Phantom.Workspaces.Agent.Gui.Tests/ChatOutputHtmlStructuralInvariantsTests.cs +++ b/Phantom.Workspaces.Agent.Gui.Tests/ChatOutputHtmlStructuralInvariantsTests.cs @@ -79,8 +79,11 @@ public async Task ChatOutput_SameRunPromotedAcrossHistoryAndRunning_UsesOneStabl running.Remove(runningItem); var groupId = ChatOutputHtmlRenderer.ToolGroupId(0); - Assert.Contains(sink.Operations, op => op.Path == ChatOutputHtmlRenderer.ToolGroupSummaryId(groupId) - && op.Content.Contains("2 calls", StringComparison.Ordinal)); + Assert.Contains(sink.Operations, op => op.Content.Contains($"id=\"{groupId}-details\"", StringComparison.Ordinal)); + Assert.Contains(sink.Operations, op => op.Content.Contains("working", StringComparison.Ordinal) + && op.Path == groupId && op.Location == ChatOutputUpdateLocation.After); + Assert.Contains(sink.Operations, op => op.Content.Contains("write", StringComparison.Ordinal) + && op.Content.Contains("-group-2-details", StringComparison.Ordinal)); Assert.DoesNotContain(sink.Operations, op => op.Path == ChatOutputHtmlRenderer.HistoryContainerId && op.Content.Contains("id=\"tool-group-1\"", StringComparison.Ordinal)); } @@ -107,14 +110,114 @@ public async Task ChatOutput_HistoryReloadAfterCompletedRun_MatchesLiveGroupedSh new ObservableCollection(), () => true, reloadSink); await reload.HistoryLoaded; var html = string.Concat(reloadSink.Operations.Select(op => op.Content)); - Assert.Single(Regex.Matches(html, "class=\"chat-content chat-tool-group\"").Cast()); - Assert.Contains("2 calls", html); + Assert.Equal(2, Regex.Matches(html, "class=\"chat-content chat-tool-group\"").Count); Assert.Contains("working", html); Assert.Contains("read", html); Assert.Contains("write", html); Assert.Contains("result", html); - Assert.Contains(liveSink.Operations, op => op.Path == ChatOutputHtmlRenderer.ToolGroupSummaryId(ChatOutputHtmlRenderer.ToolGroupId(0)) - && op.Content.Contains("2 calls", StringComparison.Ordinal)); + Assert.True(html.IndexOf("read", StringComparison.Ordinal) < + html.IndexOf("working", StringComparison.Ordinal)); + Assert.True(html.IndexOf("working", StringComparison.Ordinal) < + html.IndexOf("write", StringComparison.Ordinal)); + Assert.Contains(liveSink.Operations, op => op.Content.Contains("id=\"tool-group-0-details\"", StringComparison.Ordinal)); + Assert.Contains(liveSink.Operations, op => op.Content.Contains("id=\"tool-group-3-details\"", StringComparison.Ordinal)); + } + + [AvaloniaFact(Timeout = 15_000)] + public async Task ChatOutput_HistoryReloadWithInterleavedSystemNotification_MatchesLiveDomOrder() + { + var items = new[] + { + ToolCallMessage("first_tool", "c1") with { AssistantRunId = "run" }, + TextMessage(ChatRole.System, "agent idle") with { AssistantRunId = "run" }, + ToolCallMessage("second_tool", "c2") with { AssistantRunId = "run" }, + TextMessage(ChatRole.Assistant, "after tools") with { AssistantRunId = "run" }, + }; + var history = new ObservableCollection(); + var liveSink = new RecordingSink(); + using var live = new ChatOutputHtmlModel(history, new ObservableCollection(), () => true, liveSink); + await live.HistoryLoaded; + foreach (var item in items) history.Add(item); + + var reloadSink = new RecordingSink(); + using var reload = new ChatOutputHtmlModel(new ObservableCollection(items), + new ObservableCollection(), () => true, reloadSink); + await reload.HistoryLoaded; + var html = string.Concat(reloadSink.Operations.Select(op => op.Content)); + Assert.True(html.IndexOf("first_tool(…)", StringComparison.Ordinal) < + html.IndexOf("agent idle", StringComparison.Ordinal)); + Assert.True(html.IndexOf("agent idle", StringComparison.Ordinal) < + html.IndexOf("second_tool(…)", StringComparison.Ordinal)); + Assert.Contains("chat-sender\">system", html); + Assert.Contains(liveSink.Operations, op => op.Content.Contains("agent idle", StringComparison.Ordinal) + && op.Path == "tool-group-0" && op.Location == ChatOutputUpdateLocation.After); + Assert.Contains(liveSink.Operations, op => op.Content.Contains("second_tool(…)", StringComparison.Ordinal) + && op.Path == "history-1" && op.Location == ChatOutputUpdateLocation.After); + } + + [AvaloniaFact(Timeout = 15_000)] + public async Task ChatOutput_NotificationBeforeParentToolCall_ShowsSystemBeforeCallWithoutFalseReordering() + { + var items = new[] + { + TextMessage(ChatRole.System, "already idle") with { AssistantRunId = "run" }, + ToolCallMessage("parent_task", "c1") with { AssistantRunId = "run" }, + }; + var sink = new RecordingSink(); + using var model = new ChatOutputHtmlModel(new ObservableCollection(items), + new ObservableCollection(), () => true, sink); + await model.HistoryLoaded; + var html = string.Concat(sink.Operations.Select(op => op.Content)); + Assert.True(html.IndexOf("already idle", StringComparison.Ordinal) < + html.IndexOf("parent_task(…)", StringComparison.Ordinal)); + Assert.Contains("chat-sender\">system", html); + } + + [AvaloniaFact(Timeout = 15_000)] + public async Task ChatOutput_NotificationBetweenCallAndResult_KeepsResultAfterSystemInLiveAndReload() + { + var items = new[] + { + ToolCallMessage("parent_task", "c1") with { AssistantRunId = "run" }, + TextMessage(ChatRole.System, "agent idle") with { AssistantRunId = "run" }, + ToolResultMessage("c1") with { AssistantRunId = "run" }, + }; + var liveHistory = new ObservableCollection(); + var liveSink = new RecordingSink(); + using var live = new ChatOutputHtmlModel(liveHistory, new ObservableCollection(), () => true, liveSink); + await live.HistoryLoaded; + foreach (var item in items) liveHistory.Add(item); + var reloadSink = new RecordingSink(); + using var reload = new ChatOutputHtmlModel(new ObservableCollection(items), + new ObservableCollection(), () => true, reloadSink); + await reload.HistoryLoaded; + foreach (var sink in new[] { liveSink, reloadSink }) + { + var html = string.Concat(sink.Operations.Select(op => op.Content)); + Assert.True(html.IndexOf("parent_task(…)", StringComparison.Ordinal) < + html.IndexOf("agent idle", StringComparison.Ordinal)); + Assert.True(html.IndexOf("agent idle", StringComparison.Ordinal) < + html.IndexOf("tool result: c1", StringComparison.Ordinal)); + Assert.Contains("result", html); + } + Assert.Contains(liveSink.Operations, op => op.Content.Contains("tool result: c1", StringComparison.Ordinal) + && op.Path == "history-1" && op.Location == ChatOutputUpdateLocation.After); + } + + [AvaloniaFact(Timeout = 15_000)] + public async Task ChatOutput_ConsecutiveSystemNotifications_KeepVisibleRoleHeaders() + { + var history = new ObservableCollection + { + TextMessage(ChatRole.System, "first idle"), + TextMessage(ChatRole.System, "second idle"), + }; + var sink = new RecordingSink(); + using var model = new ChatOutputHtmlModel(history, new ObservableCollection(), () => true, sink); + await model.HistoryLoaded; + var html = string.Concat(sink.Operations.Select(op => op.Content)); + Assert.Equal(2, Regex.Matches(html, "class=\"chat-sender\">system").Count); + Assert.Equal(2, Regex.Matches(html, "chat-system-message").Count); } /// diff --git a/Phantom.Workspaces.Agent.Gui.Tests/ChatOutputHtmlTransformerLookupTests.cs b/Phantom.Workspaces.Agent.Gui.Tests/ChatOutputHtmlTransformerLookupTests.cs index 1de089ee..dced1fe3 100644 --- a/Phantom.Workspaces.Agent.Gui.Tests/ChatOutputHtmlTransformerLookupTests.cs +++ b/Phantom.Workspaces.Agent.Gui.Tests/ChatOutputHtmlTransformerLookupTests.cs @@ -264,6 +264,23 @@ public void FindGroupablePredecessor_SkipsToolResultOnlyMessages_FindsToolCall() Assert.Same(target[0], predecessor); } + [Fact] + public void FindGroupablePredecessor_VisibleUnmatchedResult_StopsGrouping() + { + var source = new ObservableCollection + { + ToolCallMessage("tool_a", "call-1") with { AssistantRunId = "run" }, + ToolResultMessage("unknown", "unmatched") with { AssistantRunId = "run" }, + ToolCallMessage("tool_b", "call-2") with { AssistantRunId = "run" }, + }; + var target = new List(); + using var transformer = MakeTransformer(source, target, new RecordingSink()); + + Assert.True(target[1].HasDomElement); + Assert.Null(transformer.FindGroupablePredecessor(2)); + Assert.NotSame(target[0].Group, target[2].Group); + } + [Fact] public void FindGroupablePredecessor_TextMessageBetween_ReturnsNull() { diff --git a/Phantom.Workspaces.Agent.Gui.WebViewTests/ChatOutputBrowserIntegrationTests.cs b/Phantom.Workspaces.Agent.Gui.WebViewTests/ChatOutputBrowserIntegrationTests.cs index a68763a2..c913329e 100644 --- a/Phantom.Workspaces.Agent.Gui.WebViewTests/ChatOutputBrowserIntegrationTests.cs +++ b/Phantom.Workspaces.Agent.Gui.WebViewTests/ChatOutputBrowserIntegrationTests.cs @@ -1,16 +1,24 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Collections.Specialized; using System.IO; +using System.Text.Json; +using System.Threading.Channels; using System.Threading.Tasks; +using AgentSchema; using Avalonia; using Avalonia.Controls; +using GitHub.Copilot; +using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Phantom.Workspaces.Agent.Gui.Controls; using Phantom.Workspaces.Agent.Gui.ViewModels; using Phantom.Workspaces.Agent.Gui.ViewModels.DocumentModels; using Phantom.Workspaces.Gui.Shared.Controls; using Phantom.Workspaces.Llm; +using Phantom.Workspaces.Llm.Remote; +using Phantom.Workspaces.Transport; using Xunit; namespace Phantom.Workspaces.Agent.Gui.WebViewTests; @@ -700,6 +708,11 @@ public Task ChatOutput_SuppressedAssistantHeaderWithInspectTargets_RemainsHidden => this.fixture.InvokeAsync(async () => { var (web, window) = await ShowReadyBrowserAsync(); + var messages = new List(); + web.JavaScriptMessageReceived += (_, body) => + { + if (body.Contains("\"type\":\"inspect\"", StringComparison.Ordinal)) messages.Add(body); + }; try { var content = ChatOutputHtmlRenderer.RenderContent( @@ -711,7 +724,7 @@ public Task ChatOutput_SuppressedAssistantHeaderWithInspectTargets_RemainsHidden "suppressed", "assistant", [("suppressed-c0", content), - ("suppressed-usage", "
")], + ("suppressed-usage", "
")], suppressRoleHeader: true); web.PostMessageToJavaScript(ChatOutputBrowserCommands.Update( "chat-history-container", @@ -727,6 +740,17 @@ public Task ChatOutput_SuppressedAssistantHeaderWithInspectTargets_RemainsHidden + "&&!!c.querySelector(':scope > .usage-gutter-btn')" + "&&!!c.querySelector('[data-copy-target]');})()"); Assert.Contains("true", result, StringComparison.Ordinal); + var activated = await EvalAsync(web, + "(function(){var m=document.getElementById('suppressed');" + + "var c=m.querySelector(':scope > .chat-contents');" + + "c.querySelector(':scope > .inspect-gutter-btn').click();" + + "c.querySelector(':scope > .usage-gutter-btn').click();" + + "return m.querySelector(':scope > .chat-header').hidden;})()"); + Assert.Contains("true", activated, StringComparison.Ordinal); + await WaitForFrameSyncAsync(web); + Assert.Contains(messages, body => body.Contains("\"contentId\":\"suppressed-c0\"", StringComparison.Ordinal)); + Assert.Contains(messages, body => body.Contains("\"contentId\":\"suppressed-usage\"", StringComparison.Ordinal) + && body.Contains("usage payload", StringComparison.Ordinal)); } finally { @@ -763,6 +787,750 @@ await EvalAsync(web, } }); + [Fact] + public Task ToolGroup_HiddenHeader_InspectAndUsageStayOnVisibleChromeAndTargetOwnGroup() + => this.fixture.InvokeAsync(async () => + { + var (web, window) = await ShowReadyBrowserAsync(); + var messages = new List(); + web.JavaScriptMessageReceived += (_, body) => + { + if (body.Contains("\"type\":\"inspect\"", StringComparison.Ordinal)) messages.Add(body); + }; + try + { + foreach (var id in new[] { "group-a", "group-b" }) + { + var body = $"
"; + web.PostMessageToJavaScript(ChatOutputBrowserCommands.Update("chat-history-container", "append", + ChatOutputHtmlRenderer.RenderToolCallGroup(id, ["read"], 1, body + + $"
"))); + } + var structure = await EvalAsync(web, + "(function(){return ['group-a','group-b'].every(function(id){var m=document.getElementById(id);" + + "var a=m.querySelector('.chat-tool-group-actions');" + + "return a && a.querySelectorAll('button').length===2" + + " && !m.querySelector(':scope > .chat-contents > .inspect-gutter-btn')" + + " && !m.querySelector(':scope > .chat-contents > .usage-gutter-btn')" + + " && m.querySelector('details').previousElementSibling===a;});})()"); + Assert.Contains("true", structure, StringComparison.Ordinal); + var state = await EvalAsync(web, + "(function(){var d=document.getElementById('group-b-details');" + + "d.open=false;document.querySelector('#group-b .inspect-gutter-btn').click();" + + "document.querySelector('#group-b .usage-gutter-btn').click();return !d.open;})()"); + Assert.Contains("true", state, StringComparison.Ordinal); + await WaitForFrameSyncAsync(web); + Assert.Contains(messages, m => m.Contains("\"contentId\":\"group-b-call\"", StringComparison.Ordinal)); + Assert.Contains(messages, m => m.Contains("\"contentId\":\"group-b-usage\"", StringComparison.Ordinal)); + Assert.DoesNotContain(messages, m => m.Contains("\"contentId\":\"group-a-call\"", StringComparison.Ordinal)); + } + finally { window.Close(); } + }); + + [Fact] + public Task ToolRun_DistinctMessageGroups_RouteActionsToVisibleSummaryAcrossCollapseMutationAndReload() + => this.fixture.InvokeAsync(async () => + { + var (web, window) = await ShowReadyBrowserAsync(); + var messages = new List(); + web.JavaScriptMessageReceived += (_, body) => + { + if (body.Contains("\"type\":\"inspect\"", StringComparison.Ordinal)) messages.Add(body); + }; + try + { + static AgentChatHistoryItem Call(string id) => new() + { + Role = ChatRole.Assistant, AssistantRunId = "message-run", + Contents = + [ + new FunctionCallContent(id, "read", new Dictionary()), + new UsageContent(new UsageDetails { InputTokenCount = id == "c1" ? 11 : 22 }), + ], + }; + var items = new[] { Call("c1"), TextItem("between message groups") with + { AssistantRunId = "message-run" }, Call("c2") }; + var history = new ObservableCollection(); + using var live = CreateModel(web, history); + await live.HistoryLoaded; + foreach (var item in items) history.Add(item); + + async Task AssertActionsAsync(string secondCall, bool expanded) + { + var state = await EvalAsync(web, """ + (() => { + const groups = Array.from(document.querySelectorAll('details.chat-tool-group[data-assistant-run-id="message-run"]')); + if (groups.length !== 2 || groups[0].closest('.chat-message') === groups[1].closest('.chat-message')) return false; + const host = groups[0].previousElementSibling; + const secondHost = groups[1].previousElementSibling; + const buttons = Array.from(host?.querySelectorAll(':scope > button') || []); + const targets = buttons.map(b => document.getElementById(b.getAttribute('data-segment-target-id'))); + const ids = Array.from(document.querySelectorAll('[id]')).map(e => e.id); + const summary = groups[0].querySelector(':scope > summary'); + return host?.matches('.chat-tool-group-actions') && secondHost?.matches('.chat-tool-group-actions') && + buttons.length === 4 && secondHost.querySelectorAll('button').length === 0 && + targets.every(t => t && t.hasAttribute('data-details-target')) && + buttons.filter(b => b.classList.contains('inspect-gutter-btn')).length === 2 && + buttons.filter(b => b.classList.contains('usage-gutter-btn')).length === 2 && + buttons.some(b => b.getAttribute('data-segment-target-id')?.includes('history-2')) && + targets.some(t => t.getAttribute('data-details-target')?.includes('SECOND')) && + buttons.every(b => b.getClientRects().length > 0 && b.tabIndex >= 0 && !b.disabled && + b.getAttribute('aria-label') && b.parentElement === host) && + host.getBoundingClientRect().bottom <= summary.getBoundingClientRect().top + 2 && + !summary.hidden && groups[1].querySelector(':scope > summary').hidden && + groups.every(g => g.open === EXPANDED) && ids.length === new Set(ids).size; + })() + """.Replace("SECOND", secondCall, StringComparison.Ordinal) + .Replace("EXPANDED", expanded ? "true" : "false", StringComparison.Ordinal)); + Assert.Contains("true", state, StringComparison.Ordinal); + } + + await AssertActionsAsync("c2", true); + await EvalAsync(web, "document.querySelector('details.chat-tool-group[data-assistant-run-id=\"message-run\"] > summary').click();'collapsed'"); + await AssertActionsAsync("c2", false); + var activated = await EvalAsync(web, """ + (() => { + const groups = Array.from(document.querySelectorAll('details.chat-tool-group[data-assistant-run-id="message-run"]')); + const buttons = groups[0].previousElementSibling.querySelectorAll('button'); + buttons.forEach(b => { b.focus(); b.click(); }); + return document.activeElement === buttons[buttons.length - 1] && groups.every(g => !g.open); + })() + """); + Assert.Contains("true", activated, StringComparison.Ordinal); + await WaitForFrameSyncAsync(web); + Assert.Equal(4, messages.Count); + Assert.Equal(4, messages.Select(m => JsonDocument.Parse(m).RootElement.GetProperty("contentId").GetString()) + .Distinct().Count()); + Assert.Contains(messages, m => m.Contains("c1", StringComparison.Ordinal)); + Assert.Contains(messages, m => m.Contains("c2", StringComparison.Ordinal)); + + history[2] = Call("c3"); + await AssertActionsAsync("c3", false); + messages.Clear(); + await EvalAsync(web, """ + (() => { + const host = document.querySelector('details.chat-tool-group[data-assistant-run-id="message-run"]') + .previousElementSibling; + host.querySelectorAll('button').forEach(b => b.click()); + return 'clicked'; + })() + """); + await WaitForFrameSyncAsync(web); + Assert.Equal(4, messages.Count); + Assert.Contains(messages, m => m.Contains("c3", StringComparison.Ordinal)); + Assert.DoesNotContain(messages, m => m.Contains("c2", StringComparison.Ordinal)); + + live.Dispose(); + web.PostMessageToJavaScript(ChatOutputBrowserCommands.Update( + "chat-history-container", "replace", "
")); + using var reload = CreateModel(web, new ObservableCollection + { items[0], items[1], Call("c3") }); + await reload.HistoryLoaded; + await AssertActionsAsync("c3", false); + } + finally { window.Close(); } + }); + + [Fact] + public Task MixedMessage_TwoToolSegments_InspectResultAndUsageActionsSurviveMutationAndReload() + => this.fixture.InvokeAsync(async () => + { + var (web, window) = await ShowReadyBrowserAsync(); + var messages = new List(); + web.JavaScriptMessageReceived += (_, body) => + { + if (body.Contains("\"type\":\"inspect\"", StringComparison.Ordinal)) messages.Add(body); + }; + try + { + static AgentChatHistoryItem Item(string secondCallId) => new() + { + Role = ChatRole.Assistant, AssistantRunId = "mixed-run", + Contents = + [ + new FunctionCallContent("c1", "read"), + new UsageContent(new UsageDetails { InputTokenCount = 10 }), + new TextContent("between calls"), + new FunctionCallContent(secondCallId, "write"), + new FunctionResultContent(secondCallId, "done"), + new UsageContent(new UsageDetails { OutputTokenCount = 20 }), + ], + }; + var history = new ObservableCollection { Item("c2") }; + using var model = CreateModel(web, history); + await model.HistoryLoaded; + var initial = await EvalAsync(web, """ + (() => { + const segments = document.querySelectorAll('#history-0 .chat-tool-segment'); + const host = segments[0]?.querySelector(':scope > .chat-tool-group-actions'); + return segments.length === 2 && host && + host.querySelectorAll(':scope > .inspect-gutter-btn').length === 3 && + host.querySelectorAll(':scope > .usage-gutter-btn').length === 2 && + segments[1].querySelectorAll(':scope > .chat-tool-group-actions > button').length === 0 && + new Set(Array.from(host.querySelectorAll('button'), + button => button.getAttribute('aria-label'))).size === 5; + })() + """); + Assert.Contains("true", initial, StringComparison.Ordinal); + + var targetIds = await EvalAsync(web, """ + (() => { + const segments = document.querySelectorAll('#history-0 .chat-tool-segment'); + const buttons = segments[0].querySelectorAll(':scope > .chat-tool-group-actions > button'); + buttons.forEach(b => b.click()); + return Array.from(segments).map(s => s.querySelector('details').open).join(','); + })() + """); + Assert.Contains("true,true", targetIds, StringComparison.Ordinal); + await WaitForFrameSyncAsync(web); + Assert.Equal(5, messages.Count(m => m.Contains("\"type\":\"inspect\"", StringComparison.Ordinal))); + Assert.Equal(5, messages.Select(m => System.Text.Json.JsonDocument.Parse(m)) + .Select(json => json.RootElement.GetProperty("contentId").GetString()).Distinct().Count()); + + history[0] = Item("c3"); + var rebound = await EvalAsync(web, """ + (() => { + const segments = document.querySelectorAll('#history-0 .chat-tool-segment'); + const host = segments[0].querySelector(':scope > .chat-tool-group-actions'); + const second = Array.from(host.querySelectorAll('button')) + .filter(b => b.getAttribute('data-segment-origin-id') === segments[1].id); + second[0]?.click(); + return second.length === 3 && + segments[1].querySelectorAll(':scope > .chat-tool-group-actions > button').length === 0; + })() + """); + Assert.Contains("true", rebound, StringComparison.Ordinal); + await WaitForFrameSyncAsync(web); + Assert.Contains(messages, m => m.Contains("\"CallId\": \"c3\"", StringComparison.Ordinal) + || m.Contains("\\\"CallId\\\": \\\"c3\\\"", StringComparison.Ordinal)); + + web.PostMessageToJavaScript(ChatOutputBrowserCommands.Update( + "chat-history-container", "replace", "
")); + using var reload = CreateModel(web, new ObservableCollection { Item("c3") }); + await reload.HistoryLoaded; + var restored = await EvalAsync(web, """ + (() => { + const segments = document.querySelectorAll('#history-0 .chat-tool-segment'); + return segments.length === 2 && + segments[0].querySelectorAll(':scope > .chat-tool-group-actions > button').length === 5 && + segments[1].querySelectorAll(':scope > .chat-tool-group-actions > button').length === 0 && + Array.from(document.querySelectorAll('[id]')).every(e => document.querySelectorAll( + '[id="' + CSS.escape(e.id) + '"]').length === 1); + })() + """); + Assert.Contains("true", restored, StringComparison.Ordinal); + } + finally { window.Close(); } + }); + + [Fact] + public Task MixedMessage_SecondaryActions_StayAdjacentToVisibleSummaryAndKeyboardReachableWhenCollapsed() + => this.fixture.InvokeAsync(async () => + { + var (web, window) = await ShowReadyBrowserAsync(); + var messages = new List(); + web.JavaScriptMessageReceived += (_, body) => + { + if (body.Contains("\"type\":\"inspect\"", StringComparison.Ordinal)) messages.Add(body); + }; + try + { + var history = new ObservableCollection + { + new() + { + Role = ChatRole.Assistant, AssistantRunId = "run", + Contents = + [ + new FunctionCallContent("c1", "read"), + new UsageContent(new UsageDetails { InputTokenCount = 10 }), + new TextContent("narration"), + new FunctionCallContent("c2", "write"), + new FunctionResultContent("c2", "done"), + new UsageContent(new UsageDetails { OutputTokenCount = 20 }), + ], + }, + }; + using var model = CreateModel(web, history); + await model.HistoryLoaded; + async Task AssertChromeAsync(bool expanded) + { + var visible = await EvalAsync(web, """ + (() => { + const segments = document.querySelectorAll('#history-0 .chat-tool-segment'); + const leader = segments[0].querySelector(':scope > .chat-tool-group-actions'); + const summary = segments[0].querySelector(':scope > details > summary'); + const actions = Array.from(document.querySelectorAll('#history-0 .chat-tool-group-actions > button')); + const second = Array.from(actions).filter(b => + b.getAttribute('data-segment-origin-id') === segments[1].id); + return segments.length === 2 && !summary.hidden && + actions.length === 5 && second.length === 3 && + second.every(b => b.parentElement === leader && + b.getClientRects().length > 0 && b.tabIndex >= 0 && + b.getAttribute('aria-label') && !b.disabled) && + leader.getBoundingClientRect().bottom <= summary.getBoundingClientRect().top + 2 && + leader.getBoundingClientRect().width <= segments[0].getBoundingClientRect().width && + segments[1].querySelector(':scope > details > summary').hidden && + segments[0].querySelector(':scope > details').open === EXPANDED; + })() + """.Replace("EXPANDED", expanded ? "true" : "false", StringComparison.Ordinal)); + Assert.Contains("true", visible, StringComparison.Ordinal); + } + await AssertChromeAsync(true); + await EvalAsync(web, "document.querySelector('#history-0 .chat-tool-segment summary').click();'collapsed'"); + await AssertChromeAsync(false); + var focused = await EvalAsync(web, """ + (() => { + const b = Array.from(document.querySelectorAll('#history-0 .chat-tool-group-actions > button')) + .find(x => x.getAttribute('data-segment-target-id').endsWith('-result')); + b.focus(); b.click(); return document.activeElement === b; + })() + """); + Assert.Contains("true", focused, StringComparison.Ordinal); + await WaitForFrameSyncAsync(web); + Assert.Contains(messages, m => m.Contains("-result\"", StringComparison.Ordinal)); + } + finally { window.Close(); } + }); + + [Fact] + public Task ToolRun_UnmatchedResultBetweenCalls_LiveAndReloadKeepResultInDomOrder() + => this.fixture.InvokeAsync(async () => + { + var (web, window) = await ShowReadyBrowserAsync(); + try + { + var items = new[] + { + ToolCallItem("first_tool", "c1") with { AssistantRunId = "run" }, + new AgentChatHistoryItem { Role = ChatRole.Tool, AssistantRunId = "run", + Contents = [new FunctionResultContent("unmatched", "unmatched-result")] }, + ToolCallItem("second_tool", "c2") with { AssistantRunId = "run" }, + }; + var history = new ObservableCollection(); + using var live = CreateModel(web, history); + await live.HistoryLoaded; + foreach (var item in items) history.Add(item); + async Task AssertOrderAsync() + { + var order = await EvalAsync(web, """ + (() => { + const root = document.getElementById('chat-history-container'); + const calls = Array.from(root.querySelectorAll('details.chat-tool-group')); + const result = Array.from(root.querySelectorAll('.chat-tool')) + .find(e => e.textContent.includes('unmatched-result')); + const ids = Array.from(root.querySelectorAll('[id]')).map(e => e.id); + return calls.length === 2 && !!result && + !!(calls[0].compareDocumentPosition(result) & Node.DOCUMENT_POSITION_FOLLOWING) && + !!(result.compareDocumentPosition(calls[1]) & Node.DOCUMENT_POSITION_FOLLOWING) && + !result.closest('details.chat-tool-group') && ids.length === new Set(ids).size; + })() + """); + Assert.Contains("true", order, StringComparison.Ordinal); + } + await AssertOrderAsync(); + live.Dispose(); + web.PostMessageToJavaScript(ChatOutputBrowserCommands.Update( + "chat-history-container", "replace", "
")); + using var reload = CreateModel(web, new ObservableCollection(items)); + await reload.HistoryLoaded; + await AssertOrderAsync(); + } + finally { window.Close(); } + }); + + [Fact] + public Task ToolRun_NotificationBetweenCalls_LiveAndReloadKeepSystemOutsideCollapsedSegments() + => this.fixture.InvokeAsync(async () => + { + var (web, window) = await ShowReadyBrowserAsync(); + try + { + var items = new[] + { + ToolCallItem("first_tool", "c1") with { AssistantRunId = "ordered-run" }, + TextItem("agent idle") with { Role = ChatRole.System, AssistantRunId = "ordered-run" }, + ToolCallItem("second_tool", "c2") with { AssistantRunId = "ordered-run" }, + }; + var history = new ObservableCollection(); + var subAgents = new ObservableCollection + { new StubSubAgentDisplay("child", "Background agent") }; + using var model = CreateModel(web, history, new ObservableCollection(), + subAgents: subAgents); + await model.HistoryLoaded; + foreach (var item in items) history.Add(item); + var live = await EvalAsync(web, """ + (() => { + const root = document.getElementById('chat-history-container'); + const system = root.querySelector('.chat-system-message'); + const groups = root.querySelectorAll('details.chat-tool-group'); + return JSON.stringify({ + ordered: groups.length === 2 && + !!(groups[0].compareDocumentPosition(system) & Node.DOCUMENT_POSITION_FOLLOWING) && + !!(system.compareDocumentPosition(groups[1]) & Node.DOCUMENT_POSITION_FOLLOWING), + role: system.querySelector('.chat-sender').textContent, + outside: !system.closest('details.chat-tool-group'), + summaries: Array.from(groups).filter(g => !g.querySelector(':scope > summary').hidden).length, + panel: document.getElementById('subagent-panel-inner').textContent.includes('Background agent') + }); + })() + """); + Assert.Contains("\\\"ordered\\\":true", live, StringComparison.Ordinal); + Assert.Contains("\\\"role\\\":\\\"system\\\"", live, StringComparison.Ordinal); + Assert.Contains("\\\"outside\\\":true", live, StringComparison.Ordinal); + Assert.Contains("\\\"summaries\\\":1", live, StringComparison.Ordinal); + Assert.Contains("\\\"panel\\\":true", live, StringComparison.Ordinal); + + await EvalAsync(web, "document.querySelector('details.chat-tool-group > summary').click();'collapsed'"); + var collapsed = await EvalAsync(web, """ + (() => { + const groups = document.querySelectorAll('details.chat-tool-group'); + return !groups[0].open && !groups[1].open && + document.querySelector('.chat-system-message').getClientRects().length > 0; + })() + """); + Assert.Contains("true", collapsed, StringComparison.Ordinal); + + web.PostMessageToJavaScript(ChatOutputBrowserCommands.Update( + "chat-history-container", "replace", "
")); + using var reload = CreateModel(web, new ObservableCollection(items), + new ObservableCollection(), subAgents: + new ObservableCollection + { new StubSubAgentDisplay("child", "Background agent") }); + await reload.HistoryLoaded; + var restored = await EvalAsync(web, + "(function(){var root=document.getElementById('chat-history-container');" + + "var groups=root.querySelectorAll('details.chat-tool-group');" + + "var system=root.querySelector('.chat-system-message');" + + "var ids=Array.from(root.querySelectorAll('[id]')).map(e=>e.id);" + + "return groups.length===2 && !!(groups[0].compareDocumentPosition(system)&4)" + + " && !!(system.compareDocumentPosition(groups[1])&4)" + + " && system.querySelector('.chat-sender').textContent==='system'" + + " && !system.closest('details.chat-tool-group')" + + " && Array.from(groups).filter(g=>!g.querySelector(':scope > summary').hidden).length===1" + + " && !groups[0].open && !groups[1].open" + + " && document.getElementById('subagent-panel-inner').textContent.includes('Background agent')" + + " && ids.length===new Set(ids).size;})()"); + Assert.Contains("true", restored, StringComparison.Ordinal); + } + finally { window.Close(); } + }); + + [Fact] + public Task ToolRun_NotificationBeforeCallAndBetweenCallResult_LiveAndReloadDomOrder() + => this.fixture.InvokeAsync(async () => + { + var (web, window) = await ShowReadyBrowserAsync(); + try + { + async Task AssertSequenceAsync(AgentChatHistoryItem[] items, bool beforeCall) + { + var history = new ObservableCollection(); + using var live = CreateModel(web, history); + await live.HistoryLoaded; + foreach (var item in items) history.Add(item); + + async Task AssertDomAsync() + { + var actual = await EvalAsync(web, """ + (() => { + const root = document.getElementById('chat-history-container'); + const system = root.querySelector('.chat-system-message'); + const call = root.querySelector('details.chat-tool-group'); + const result = root.querySelector('.chat-tool:not(.chat-tool-group)'); + const ids = Array.from(root.querySelectorAll('[id]')).map(e => e.id); + return JSON.stringify({ + beforeCall: !!(system.compareDocumentPosition(call) & Node.DOCUMENT_POSITION_FOLLOWING), + callBeforeSystem: !!(call.compareDocumentPosition(system) & Node.DOCUMENT_POSITION_FOLLOWING), + systemBeforeResult: result + ? !!(system.compareDocumentPosition(result) & Node.DOCUMENT_POSITION_FOLLOWING) + : null, + resultOutside: result ? !result.closest('details.chat-tool-group') : null, + role: system.querySelector('.chat-sender').textContent, + unique: ids.length === new Set(ids).size + }); + })() + """); + Assert.Contains($"\\\"beforeCall\\\":{beforeCall.ToString().ToLowerInvariant()}", actual, StringComparison.Ordinal); + Assert.Contains($"\\\"callBeforeSystem\\\":{(!beforeCall).ToString().ToLowerInvariant()}", actual, StringComparison.Ordinal); + Assert.Contains("\\\"role\\\":\\\"system\\\"", actual, StringComparison.Ordinal); + Assert.Contains("\\\"unique\\\":true", actual, StringComparison.Ordinal); + if (!beforeCall) + { + Assert.Contains("\\\"systemBeforeResult\\\":true", actual, StringComparison.Ordinal); + Assert.Contains("\\\"resultOutside\\\":true", actual, StringComparison.Ordinal); + } + } + + await AssertDomAsync(); + live.Dispose(); + web.PostMessageToJavaScript(ChatOutputBrowserCommands.Update( + "chat-history-container", "replace", "
")); + using var reload = CreateModel(web, new ObservableCollection(items)); + await reload.HistoryLoaded; + await AssertDomAsync(); + reload.Dispose(); + web.PostMessageToJavaScript(ChatOutputBrowserCommands.Update( + "chat-history-container", "replace", "
")); + await EvalAsync(web, "'cleared'"); + } + + var notification = TextItem("agent idle") with { Role = ChatRole.System, AssistantRunId = "run" }; + var call = ToolCallItem("parent_task", "c1") with { AssistantRunId = "run" }; + await AssertSequenceAsync([notification, call], beforeCall: true); + var result = new AgentChatHistoryItem { Role = ChatRole.Tool, AssistantRunId = "run", + Contents = [new FunctionResultContent("c1", "completed")] }; + await AssertSequenceAsync([call, notification, result], beforeCall: false); + } + finally { window.Close(); } + }); + + [Fact] + public Task RemoteNotification_ActualStreamingAndSnapshotCollections_RenderInNativeBrowser() + => this.fixture.InvokeAsync(async () => + { + var (web, window) = await ShowReadyBrowserAsync(); + try + { + var events = Channel.CreateUnbounded(); + events.Writer.TryWrite(new ToolExecutionStartEvent + { + AgentId = string.Empty, + Data = new ToolExecutionStartData { ToolCallId = "parent", ToolName = "task" }, + }); + events.Writer.TryWrite(new SystemNotificationEvent + { + AgentId = string.Empty, + Data = new SystemNotificationData + { + Content = "child idle", + Kind = new SystemNotificationAgentIdle + { + AgentId = "child", AgentType = "background", Description = "idle", + }, + }, + }); + events.Writer.TryWrite(new ToolExecutionCompleteEvent + { + AgentId = string.Empty, + Data = new ToolExecutionCompleteData + { + ToolCallId = "parent", Success = true, + Result = new ToolExecutionCompleteResult { Content = "done" }, + }, + }); + events.Writer.Complete(); + var updates = new List(); + await foreach (var update in CopilotSdkStreamAdapter.TranslateCopilotSdkSessionEvents( + events.Reader, CancellationToken.None)) + updates.Add(new AgentResponseUpdate { Role = update.Role, Contents = update.Contents }); + var items = AgentResponseUpdateCoalescer.Coalesce(updates.ToArray(), TimeProvider.System) + .Select(item => item with { AssistantRunId = "remote-run" }).ToArray(); + Assert.Equal(new[] { ChatRole.Assistant, ChatRole.System, ChatRole.Tool }, + items.Select(item => item.Role)); + static JsonElement Json(object value) + => JsonSerializer.SerializeToElement(value, AIJsonUtilities.DefaultOptions); + var child = Json(new + { + AgentId = "child", Name = "background", DisplayName = "Background agent", + Description = "Child running", CompletionState = AgentChatCompletionState.Running, + LastUpdatedAt = DateTime.UnixEpoch, SubAgents = Array.Empty(), + }); + var renamedChild = Json(new + { + AgentId = "child", Name = "background", DisplayName = "Renamed child", + Description = "Child running", CompletionState = AgentChatCompletionState.Running, + LastUpdatedAt = DateTime.UnixEpoch, SubAgents = Array.Empty(), + }); + var initialSnapshot = RemoteBrowserSnapshot([], [child]); + var (transport, chat) = await AttachBrowserRemoteAsync(initialSnapshot); + await using (chat) + { + using var loggerFactory = new ObservableLoggerFactory(); + await using var viewModel = new AgentViewModel(new AgentViewModelOptions + { + AgentChat = chat, DisplayName = "parent", Description = "", + LoggerFactory = loggerFactory, + ForegroundScheduler = TaskScheduler.FromCurrentSynchronizationContext(), + RemoteChildResolver = (_, _, _) => + throw new InvalidOperationException("Child transcript should remain unopened."), + }); + var childNavigation = Assert.Single( + Assert.Single(viewModel.EditorItems).Children + .Single(item => item.Id == "chat-sub-agents").Children); + Assert.Equal("Background agent", childNavigation.Name); + using var model = CreateModel(web, chat.History, chat.RunningItems, + viewModel.SubAgentDisplays); + await model.HistoryLoaded; + + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + ((INotifyCollectionChanged)chat.RunningItems).CollectionChanged += (_, _) => + { + if (chat.RunningItems.Count == 1) started.TrySetResult(); + }; + await transport.SendAsync(RemoteBrowserFrame(2, new StreamingStartedEvent + { + RunId = "stream", Item = Json(items[0]), + })); + await started.Task.WaitAsync(TimeSpan.FromSeconds(15)); + + var updated = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var running = Assert.Single(chat.RunningItems); + running.Items.CollectionChanged += (_, _) => + { + if (running.Items.Count == items.Length) updated.TrySetResult(); + }; + await transport.SendAsync(RemoteBrowserFrame(3, new StreamingUpdatedEvent + { + RunId = "stream", Update = Json(items), + })); + await updated.Task.WaitAsync(TimeSpan.FromSeconds(15)); + Assert.Contains("true", await EvalAsync(web, """ + (() => { + const run = document.querySelector('.chat-running-item'); + const call = run?.querySelector('.chat-tool-call'); + const notice = run?.querySelector('.chat-system-message'); + const result = Array.from(run?.querySelectorAll('.chat-tool') || []) + .find(e => e.textContent.includes('done')); + return !!call && !!notice && !!result && + !!(call.compareDocumentPosition(notice) & Node.DOCUMENT_POSITION_FOLLOWING) && + !!(notice.compareDocumentPosition(result) & Node.DOCUMENT_POSITION_FOLLOWING) && + document.getElementById('subagent-panel-inner').textContent.includes('Background agent'); + })() + """), StringComparison.Ordinal); + + var promoted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + ((INotifyCollectionChanged)chat.History).CollectionChanged += (_, _) => + { + if (chat.History.Count == items.Length) promoted.TrySetResult(); + }; + var renamed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + viewModel.SubAgentDisplays[0].CompletionStateChanged += (_, _) => + { + if (viewModel.SubAgentDisplays.Count == 1 && + viewModel.SubAgentDisplays[0].DisplayName == "Renamed child") + renamed.TrySetResult(); + }; + await transport.SendAsync(RemoteBrowserFrame(4, new SubagentsChangedEvent + { + Subagents = [renamedChild], + })); + await renamed.Task.WaitAsync(TimeSpan.FromSeconds(15)); + Assert.Same(childNavigation, Assert.Single( + Assert.Single(viewModel.EditorItems).Children + .Single(item => item.Id == "chat-sub-agents").Children)); + Assert.Equal("Renamed child", childNavigation.Name); + await transport.SendAsync(RemoteBrowserFrame(5, new StreamingCompletedEvent + { + RunId = "stream", Item = Json(items[^1]), Items = items.Select(Json).ToArray(), + })); + await promoted.Task.WaitAsync(TimeSpan.FromSeconds(15)); + await AssertRemoteBrowserOrderAsync(web, "Renamed child"); + } + + web.PostMessageToJavaScript(ChatOutputBrowserCommands.Update( + "chat-history-container", "replace", "
")); + var (_, reloaded) = await AttachBrowserRemoteAsync( + RemoteBrowserSnapshot(items.Select(Json).ToArray(), [renamedChild])); + await using (reloaded) + { + using var loggerFactory = new ObservableLoggerFactory(); + await using var viewModel = new AgentViewModel(reloaded, "parent", "", loggerFactory, + TaskScheduler.FromCurrentSynchronizationContext()); + using var model = CreateModel(web, reloaded.History, reloaded.RunningItems, + viewModel.SubAgentDisplays); + await model.HistoryLoaded; + await AssertRemoteBrowserOrderAsync(web, "Renamed child"); + } + } + finally { window.Close(); } + }); + + private static async Task AssertRemoteBrowserOrderAsync(ControllableWebViewControl web, string displayName) + { + var actual = await EvalAsync(web, """ + (() => { + const root = document.getElementById('chat-history-container'); + const call = root.querySelector('details.chat-tool-group'); + const notice = root.querySelector('.chat-system-message'); + const result = Array.from(root.querySelectorAll('.chat-tool')) + .find(e => e.textContent.includes('done')); + const ids = Array.from(root.querySelectorAll('[id]')).map(e => e.id); + return !!call && !!notice && !!result && + !!(call.compareDocumentPosition(notice) & Node.DOCUMENT_POSITION_FOLLOWING) && + !!(notice.compareDocumentPosition(result) & Node.DOCUMENT_POSITION_FOLLOWING) && + !result.closest('details.chat-tool-group') && + notice.querySelector('.chat-sender').textContent === 'system' && + document.getElementById('subagent-panel-inner').textContent.includes('DISPLAY_NAME') && + ids.length === new Set(ids).size; + })() + """.Replace("DISPLAY_NAME", displayName, StringComparison.Ordinal)); + Assert.Contains("true", actual, StringComparison.Ordinal); + } + + private static AgentSessionSnapshot RemoteBrowserSnapshot( + IReadOnlyList history, IReadOnlyList subagents) => new() + { + Information = new AgentInformation + { + AgentSessionId = "session", AgentId = "parent", Name = "parent", + DisplayName = "Parent", Description = "Remote parent", AcceptsUserInput = false, + AgentDefinition = AgentDefinitionLoader.LoadAgentFromJson(""" + {"kind":"prompt","name":"parent","model":{"id":"echo","provider":"echo","apiType":"Echo"}} + """), + }, + Usage = new Usage(), + InputQueues = new AgentInputQueuesSnapshot { Revision = 0, Queues = [] }, + IsBusy = false, History = history, RunningItems = [], Subagents = subagents, + Tools = [], Modals = [], ViewerCount = 1, ContinueInBackground = false, + }; + + private static async Task<(BrowserRemoteTransport Transport, RemoteAgentChat Chat)> AttachBrowserRemoteAsync( + AgentSessionSnapshot snapshot) + { + var transport = new BrowserRemoteTransport(); + var attaching = RemoteAgentChat.AttachAsync(new RemoteAgentChatAttachOptions + { + Client = new RemoteAgentSessionClient(transport), + OpenRequest = new AgentSessionOpenRequest + { + ProtocolVersion = 1, AgentSessionId = "session", + ExpectedOwningProfileEntityId = "profile", ExpectedOwnershipGeneration = 2, + OpenIntent = AgentSessionOpenIntent.Attach, AttachmentToken = "00112233445566778899aabbccddeeff", + Capabilities = ["queue", "replay"], + }, + ForegroundScheduler = TaskScheduler.FromCurrentSynchronizationContext(), + }); + await transport.SendAsync(RemoteBrowserFrame(1, new SessionSnapshotEvent { Snapshot = snapshot })); + return (transport, await attaching.WaitAsync(TimeSpan.FromSeconds(15))); + } + + private static JsonElement RemoteBrowserFrame(long sequence, AgentSessionServerEvent value) + => AgentSessionProtocolCodec.SerializeFrame( + AgentSessionProtocolCodec.AgentSessionProtocolEventCodec.CreateFrame( + new RuntimeEpoch { Value = Guid.Parse("11111111-1111-1111-1111-111111111111") }, + sequence, Guid.NewGuid(), value)); + + private sealed class BrowserRemoteTransport : ITransport, IMessageChannel + { + private readonly Channel incoming = Channel.CreateUnbounded(); + private readonly Channel outgoing = Channel.CreateUnbounded(); + + public ChannelWriter Writer => this.outgoing.Writer; + public ChannelReader Reader => this.incoming.Reader; + public Task ConnectToMessageChannelAsync(JsonElement request, CancellationToken ct = default) + => Task.FromResult(this); + public Task ConnectToStreamAsync(JsonElement request, CancellationToken ct = default) + => throw new NotSupportedException(); + public ValueTask SendAsync(JsonElement frame) => this.incoming.Writer.WriteAsync(frame); + public ValueTask DisposeAsync() + { + this.incoming.Writer.TryComplete(); + this.outgoing.Writer.TryComplete(); + return ValueTask.CompletedTask; + } + } + private static async Task<(ControllableWebViewControl Web, Window Window)> ShowReadyBrowserAsync() { var web = new ControllableWebViewControl(); @@ -1250,18 +2018,26 @@ public Task ToolRun_InterleavedTextAndMixedCalls_KeepEncounterOrderInLiveBrowser var order = await EvalAsync(web, """ (() => { const group = document.querySelector('#running-items-container details.chat-tool-group'); - const body = group.querySelector('.chat-tool-group-body'); + const root = document.querySelector('#running-items-container'); + const texts = Array.from(root.querySelectorAll('.chat-text')); return JSON.stringify({ - groups: document.querySelectorAll('#running-items-container details.chat-tool-group').length, + groups: root.querySelectorAll('details.chat-tool-group').length, open: group.open, - items: body.querySelectorAll('details.chat-tool-group-item').length, - text: body.textContent + items: root.querySelectorAll('details.chat-tool-group-item').length, + proseOutside: texts.every(t => !t.closest('details.chat-tool-group')), + summaries: Array.from(root.querySelectorAll('details.chat-tool-group > summary')) + .filter(s => !s.hidden).length, + total: group.querySelector('.tool-count-badge').textContent, + text: root.textContent }); })() """); - Assert.Contains("\\\"groups\\\":1", order, StringComparison.Ordinal); + Assert.Contains("\\\"groups\\\":2", order, StringComparison.Ordinal); Assert.Contains("\\\"open\\\":true", order, StringComparison.Ordinal); Assert.Contains("\\\"items\\\":2", order, StringComparison.Ordinal); + Assert.Contains("\\\"proseOutside\\\":true", order, StringComparison.Ordinal); + Assert.Contains("\\\"summaries\\\":1", order, StringComparison.Ordinal); + Assert.Contains("2 calls", order, StringComparison.Ordinal); var read = order.IndexOf("read", StringComparison.Ordinal); var between = order.IndexOf("between calls", StringComparison.Ordinal); var beforeWrite = order.IndexOf("before write", StringComparison.Ordinal); @@ -1295,12 +2071,14 @@ public Task ToolRun_StreamingTextBecomesMixedCall_ReclassifiesIntoSingleGroup() }; var state = await EvalAsync(web, """ (() => { - const group = document.querySelector('#running-items-container details.chat-tool-group'); - return JSON.stringify({ count: document.querySelectorAll('#running-items-container details.chat-tool-group').length, - text: group?.textContent || '' }); + const root = document.querySelector('#running-items-container'); + return JSON.stringify({ count: root.querySelectorAll('details.chat-tool-group-item').length, + proseOutside: !root.querySelector('.chat-text')?.closest('details.chat-tool-group-item'), + text: root.textContent }); })() """); Assert.Contains("\\\"count\\\":1", state, StringComparison.Ordinal); + Assert.Contains("\\\"proseOutside\\\":true", state, StringComparison.Ordinal); Assert.Contains("thinking", state, StringComparison.Ordinal); Assert.Contains("read", state, StringComparison.Ordinal); } @@ -1630,9 +2408,9 @@ private static async Task MakePageScrollableAndScrollToBottomAsync(ControllableW private static ChatOutputHtmlModel CreateModel( ControllableWebViewControl web, - ObservableCollection history, - ObservableCollection? running = null, - ObservableCollection? subAgents = null) + IReadOnlyList history, + IReadOnlyList? running = null, + IReadOnlyList? subAgents = null) => new( history, running ?? [], diff --git a/Phantom.Workspaces.Agent.Gui.WebViewTests/ChatOutputByokEndToEndWebViewTests.cs b/Phantom.Workspaces.Agent.Gui.WebViewTests/ChatOutputByokEndToEndWebViewTests.cs index c6741fc3..027a1608 100644 --- a/Phantom.Workspaces.Agent.Gui.WebViewTests/ChatOutputByokEndToEndWebViewTests.cs +++ b/Phantom.Workspaces.Agent.Gui.WebViewTests/ChatOutputByokEndToEndWebViewTests.cs @@ -313,6 +313,7 @@ [new FunctionCallContent( // ---- Assert the DOM without any refresh/reload --------------------------- parentBrowser.EndBatch(); + await WaitForDomTextAsync(parentBrowser, ["FINAL-REPLY", "hello world 1", "hello world 2"], timeout); var parentDom = await EvalAsync(parentBrowser, "document.body.innerHTML"); Assert.Contains("FINAL-REPLY", parentDom, StringComparison.Ordinal); Assert.Contains("hello world 1", parentDom, StringComparison.Ordinal); @@ -321,6 +322,7 @@ [new FunctionCallContent( foreach (var (marker, expectedText, subBrowser) in subMarkers) { subBrowser.EndBatch(); + await WaitForDomTextAsync(subBrowser, [marker, expectedText], timeout); var subDom = await EvalAsync(subBrowser, "document.body.innerHTML"); Assert.Contains(marker, subDom, StringComparison.Ordinal); Assert.Contains(expectedText, subDom, StringComparison.Ordinal); @@ -393,6 +395,10 @@ public Task AgentChatOutput_ByokSubagents_ProductionWiring_SubAgentDomArrivesLiv mainFinalTurn.EnqueueUpdate(new ChatResponseUpdate(ChatRole.Assistant, "FINAL-REPLY: hello world 1.")); mainFinalTurn.Complete(); + // The completed background task queues one system notification turn after + // the final reply, as in the two-subagent exchange above. + main.Client.EnqueueStreamingResponse().Complete(); + var subOne = server.AddConversation( "sub-one", request => request.AnyMessageContains("user", "SUBAGENT-ONE")); @@ -673,6 +679,47 @@ void OnChanged(object? sender, NotifyCollectionChangedEventArgs e) } } + private static async Task WaitForDomTextAsync( + ControllableWebViewControl browser, string[] fragments, TimeSpan timeout) + { + var signal = $"dom-ready:{Guid.NewGuid():N}"; + var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + void OnMessage(object? sender, string body) + { + if (body == signal) + { + ready.TrySetResult(); + } + } + + browser.JavaScriptMessageReceived += OnMessage; + try + { + await browser.InvokeScript($$""" + (function () { + const fragments = {{System.Text.Json.JsonSerializer.Serialize(fragments)}}; + function check() { + if (document.body && fragments.every(fragment => document.body.innerHTML.includes(fragment))) { + window.chrome.webview.postMessage({{System.Text.Json.JsonSerializer.Serialize(signal)}}); + return true; + } + return false; + } + if (!check()) { + new MutationObserver(function (mutations, observer) { + if (check()) { observer.disconnect(); } + }).observe(document.documentElement, { childList: true, subtree: true, characterData: true }); + } + }()); + """); + await ready.Task.WaitAsync(timeout); + } + finally + { + browser.JavaScriptMessageReceived -= OnMessage; + } + } + private static ControllableWebViewControl GetBrowser(AgentChatOutputControl control) { var field = typeof(AgentChatOutputControl).GetField("browser", BindingFlags.Instance | BindingFlags.NonPublic)!; diff --git a/Phantom.Workspaces.Agent.Gui/Assets/chat-output-shell.html b/Phantom.Workspaces.Agent.Gui/Assets/chat-output-shell.html index 1f82503c..b8b35ead 100644 --- a/Phantom.Workspaces.Agent.Gui/Assets/chat-output-shell.html +++ b/Phantom.Workspaces.Agent.Gui/Assets/chat-output-shell.html @@ -131,6 +131,11 @@ padding-left: 1.5em; } + .chat-system-message .chat-contents { + padding-left: 1.5em; + color: var(--chat-meta); + } + .chat-help { white-space: pre-wrap; font-family: var(--chat-font-family); @@ -323,6 +328,45 @@ color: var(--chat-tool-summary); } + .chat-contents:has(> .chat-tool-group-actions), + .chat-tool-segment { + position: relative; + } + + .chat-tool-group-actions { + position: absolute; + right: 0; + top: 0; + z-index: 2; + display: flex; + align-items: center; + gap: 0.25em; + } + + .chat-tool-group-actions + .chat-tool-group > summary { + padding-right: 4em; + } + + .chat-tool-segment > .chat-tool-group-actions { + position: relative; + justify-content: flex-end; + flex-wrap: wrap; + max-width: 100%; + } + + .chat-tool-segment > .chat-tool-group-actions + .chat-tool-group > summary { + padding-right: 0; + } + + .chat-tool-group-secondary > summary[hidden] { + display: none; + } + + .chat-contents:has(> .chat-tool-group-secondary:not([open])) > .chat-tool-group-actions, + .chat-tool-segment:has(> .chat-tool-group-secondary) > .chat-tool-group-actions { + display: none; + } + .tool-count-badge { font-size: 0.75em; opacity: 0.6; @@ -611,17 +655,73 @@ })(); +