Skip to content

Commit cc384f0

Browse files
Fix #1270: render usage metadata as inspectable marker
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 9c289fd commit cc384f0

5 files changed

Lines changed: 218 additions & 2 deletions

File tree

Phantom.Workspaces.Agent.Gui.Tests/AgentChatOutputControlTests.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ public void ChatOutputShellHtml_CopyAndInspectGutters_AreRetained()
5151

5252
Assert.Contains("CopyGutter.init(document);", html, StringComparison.Ordinal);
5353
Assert.Contains("InspectGutter.init(document);", html, StringComparison.Ordinal);
54+
Assert.Contains("UsageInspectGutter.init(document);", html, StringComparison.Ordinal);
5455
Assert.Contains("inspect-gutter-btn", html, StringComparison.Ordinal);
56+
Assert.Contains("usage-gutter-btn", html, StringComparison.Ordinal);
5557
// The inspect gutter still relies on the co-located data-details-target attribute.
5658
Assert.Contains("data-details-target", html, StringComparison.Ordinal);
5759
}

Phantom.Workspaces.Agent.Gui.Tests/ChatOutputHtmlRendererTests.cs

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System;
22
using System.Collections.Generic;
33
using System.Linq;
4+
using System.Net;
45
using System.Text.Json;
56
using Microsoft.Extensions.AI;
67
using Phantom.Workspaces.Agent.Gui.ViewModels.DocumentModels;
@@ -269,6 +270,100 @@ public void RenderContent_TextContent_DataDetailsTargetIsJsonNotRawText()
269270
Assert.Contains("data-details-target=\"{", html, StringComparison.Ordinal);
270271
}
271272

273+
[Fact]
274+
public void RenderContent_UsageContent_DoesNotLeakTypeName()
275+
{
276+
var usage = new UsageContent(new UsageDetails { InputTokenCount = 10, OutputTokenCount = 5 });
277+
278+
var html = ChatOutputHtmlRenderer.RenderContent("c0", usage, includeReasoning: false, isDiagnostic: false);
279+
280+
Assert.NotNull(html);
281+
Assert.DoesNotContain("Microsoft.Extensions.AI.UsageContent", html, StringComparison.Ordinal);
282+
}
283+
284+
[Fact]
285+
public void RenderContent_UsageContent_EmitsUsageMarkerWithInspectAttributes()
286+
{
287+
var usage = new UsageContent(new UsageDetails { InputTokenCount = 10, OutputTokenCount = 5 });
288+
289+
var html = ChatOutputHtmlRenderer.RenderContent("c0", usage, includeReasoning: false, isDiagnostic: false);
290+
291+
Assert.NotNull(html);
292+
Assert.Contains("class=\"chat-usage-marker\"", html, StringComparison.Ordinal);
293+
Assert.Contains("data-usage-inspect-target", html, StringComparison.Ordinal);
294+
Assert.Contains("data-details-target=", html, StringComparison.Ordinal);
295+
Assert.Contains("id=\"c0\"", html, StringComparison.Ordinal);
296+
}
297+
298+
[Fact]
299+
public void RenderContent_UsageContent_DetailsPayloadContainsTokenCounts()
300+
{
301+
var usage = new UsageContent(new UsageDetails { InputTokenCount = 10, OutputTokenCount = 5, TotalTokenCount = 15 });
302+
303+
var html = ChatOutputHtmlRenderer.RenderContent("c0", usage, includeReasoning: false, isDiagnostic: false);
304+
305+
var payload = ExtractDataDetailsTarget(html);
306+
Assert.Contains("\"InputTokenCount\": 10", payload, StringComparison.Ordinal);
307+
Assert.Contains("\"OutputTokenCount\": 5", payload, StringComparison.Ordinal);
308+
Assert.Contains("\"TotalTokenCount\": 15", payload, StringComparison.Ordinal);
309+
}
310+
311+
[Fact]
312+
public void RenderContent_UsageContent_CarriesAdditionalCounts()
313+
{
314+
var usage = new UsageContent(new UsageDetails
315+
{
316+
InputTokenCount = 10,
317+
OutputTokenCount = 5,
318+
TotalTokenCount = 15,
319+
AdditionalCounts = new()
320+
{
321+
["copilot.sdk.reasoning_tokens"] = 2,
322+
["copilot.sdk.cache_read_tokens"] = 3,
323+
["copilot.sdk.cache_write_tokens"] = 4,
324+
["copilot.sdk.cost_micro_usd"] = 5,
325+
},
326+
});
327+
328+
var html = ChatOutputHtmlRenderer.RenderContent("c0", usage, includeReasoning: false, isDiagnostic: false);
329+
330+
var payload = ExtractDataDetailsTarget(html);
331+
Assert.Contains("copilot.sdk.reasoning_tokens", payload, StringComparison.Ordinal);
332+
Assert.Contains("copilot.sdk.cache_read_tokens", payload, StringComparison.Ordinal);
333+
Assert.Contains("copilot.sdk.cache_write_tokens", payload, StringComparison.Ordinal);
334+
Assert.Contains("copilot.sdk.cost_micro_usd", payload, StringComparison.Ordinal);
335+
}
336+
337+
[Fact]
338+
public void RenderContent_TextContent_DoesNotEmitUsageMarker()
339+
{
340+
var html = ChatOutputHtmlRenderer.RenderContent("c0", new TextContent("hello"), includeReasoning: false, isDiagnostic: false);
341+
342+
Assert.NotNull(html);
343+
Assert.DoesNotContain("chat-usage-marker", html, StringComparison.Ordinal);
344+
Assert.DoesNotContain("data-usage-inspect-target", html, StringComparison.Ordinal);
345+
}
346+
347+
[Fact]
348+
public void RenderContent_UnknownAIContentSubtype_DoesNotLeakTypeName()
349+
{
350+
var html = ChatOutputHtmlRenderer.RenderContent("c0", new UnknownAIContent(), includeReasoning: false, isDiagnostic: false);
351+
352+
Assert.NotNull(html);
353+
Assert.Contains(">[UnknownAIContent]</div>", html, StringComparison.Ordinal);
354+
Assert.DoesNotContain($">{typeof(UnknownAIContent).FullName}</div>", html, StringComparison.Ordinal);
355+
}
356+
357+
[Fact]
358+
public void ComputeContentKey_UsageContent_DoesNotCallToString()
359+
{
360+
var usage = new UsageContent(new UsageDetails { InputTokenCount = 10, OutputTokenCount = 5 });
361+
362+
var key = ChatOutputHtmlRenderer.ComputeContentKey(usage, isDiagnostic: false);
363+
364+
Assert.DoesNotContain("Microsoft.Extensions.AI.UsageContent", key, StringComparison.Ordinal);
365+
}
366+
272367
[Fact]
273368
public void RenderContent_TextReasoningContent_DataDetailsTargetIsJsonNotRawText()
274369
{
@@ -292,6 +387,20 @@ public void RenderContent_FunctionCallContent_DataDetailsTargetIsJsonNotRawText(
292387
Assert.DoesNotContain("data-details-target=\"{\n &quot;arg&quot;", html, StringComparison.Ordinal);
293388
}
294389

390+
private static string ExtractDataDetailsTarget(string? html)
391+
{
392+
Assert.NotNull(html);
393+
const string marker = "data-details-target=\"";
394+
var start = html!.IndexOf(marker, StringComparison.Ordinal);
395+
Assert.True(start >= 0, "Expected data-details-target attribute.");
396+
start += marker.Length;
397+
var end = html.IndexOf('"', start);
398+
Assert.True(end > start, "Expected non-empty data-details-target attribute.");
399+
return WebUtility.HtmlDecode(html[start..end]);
400+
}
401+
402+
private sealed class UnknownAIContent : AIContent;
403+
295404
[Fact]
296405
public void RenderContent_ErrorContent_DataDetailsTargetIsJsonNotRawText()
297406
{

Phantom.Workspaces.Agent.Gui/Assets/chat-output-shell.html

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,25 @@
417417
.chat-content-row:hover .inspect-gutter-btn { opacity: 0.6; }
418418
.inspect-gutter-btn:hover { opacity: 1; }
419419

420+
.usage-gutter-btn {
421+
position: absolute;
422+
right: -3.6em;
423+
top: 1.7em;
424+
opacity: 0.3;
425+
cursor: pointer;
426+
transition: opacity 0.15s;
427+
background: transparent;
428+
border: none;
429+
color: inherit;
430+
font-size: inherit;
431+
font-family: inherit;
432+
padding: 0;
433+
}
434+
435+
.chat-content-row:hover .usage-gutter-btn { opacity: 0.6; }
436+
.usage-gutter-btn:hover { opacity: 1; }
437+
.chat-usage-marker { display: none; }
438+
420439
/* Running sub-agents panel styles */
421440
.running-subagents-panel {
422441
margin: 1em 0;
@@ -636,6 +655,65 @@
636655
return { init: init };
637656
})();
638657
</script>
658+
<script>
659+
/* usage-inspect-gutter.js — attaches UsageContent payload markers to inspect gutters. */
660+
var UsageInspectGutter = (function () {
661+
"use strict";
662+
663+
function postToHost(message) {
664+
if (window.chrome && window.chrome.webview && window.chrome.webview.postMessage) {
665+
window.chrome.webview.postMessage(JSON.stringify(message));
666+
}
667+
}
668+
669+
function init(root) {
670+
root.querySelectorAll("[data-usage-inspect-target]").forEach(attach);
671+
var observer = new MutationObserver(function (mutations) {
672+
mutations.forEach(function (mutation) {
673+
mutation.addedNodes.forEach(function (node) {
674+
if (node.nodeType !== 1) { return; }
675+
if (node.hasAttribute("data-usage-inspect-target")) { attach(node); }
676+
node.querySelectorAll("[data-usage-inspect-target]").forEach(attach);
677+
});
678+
});
679+
});
680+
observer.observe(root, { childList: true, subtree: true });
681+
}
682+
683+
function attach(marker) {
684+
if (marker._usageGutterAttached) { return; }
685+
marker._usageGutterAttached = true;
686+
687+
var btn = document.createElement("button");
688+
btn.className = "usage-gutter-btn";
689+
btn.setAttribute("aria-label", "Open token usage details");
690+
btn.textContent = "#";
691+
692+
btn.addEventListener("click", function (e) {
693+
e.stopPropagation();
694+
postToHost({
695+
type: "inspect",
696+
contentId: marker.id,
697+
contentJson: marker.getAttribute("data-details-target") || ""
698+
});
699+
});
700+
701+
var prev = marker.previousElementSibling;
702+
var host = prev && prev.querySelector(".inspect-gutter-btn");
703+
if (host && host.parentNode) {
704+
host.parentNode.insertBefore(btn, host.nextSibling);
705+
} else if (prev) {
706+
prev.classList.add("chat-content-row");
707+
prev.appendChild(btn);
708+
} else {
709+
marker.classList.add("chat-content-row");
710+
marker.appendChild(btn);
711+
}
712+
}
713+
714+
return { init: init };
715+
})();
716+
</script>
639717
<script>
640718
/* tool-group-expand-collapse.js — bulk expand/collapse for tool groups.
641719
* Call ToolGroupExpandCollapse.init(root) to wire up all [data-tool-expand-toggle] elements.
@@ -896,6 +974,7 @@
896974
// Initialise gutter components once the host bridge is wired up.
897975
CopyGutter.init(document);
898976
InspectGutter.init(document);
977+
UsageInspectGutter.init(document);
899978
ToolGroupExpandCollapse.init(document);
900979
TimestampFormatter.init(document);
901980
})();

Phantom.Workspaces.Agent.Gui/ViewModels/DocumentModels/ChatOutputHtmlRenderer.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -511,8 +511,10 @@ public static string RoleClass(string roleLabel)
511511
return TextBlock(contentId, "chat-error", error.Message ?? string.Empty, SerializeContentJson(error));
512512
case UriContent uri:
513513
return TextBlock(contentId, "chat-uri", uri.Uri.ToString(), SerializeContentJson(uri));
514+
case UsageContent usage:
515+
return UsageMarker(contentId, SerializeContentJson(usage));
514516
default:
515-
return TextBlock(contentId, "chat-text", content.ToString() ?? string.Empty, SerializeContentJson(content));
517+
return TextBlock(contentId, "chat-meta", $"[{content.GetType().Name}]", SerializeContentJson(content));
516518
}
517519
}
518520

@@ -532,7 +534,8 @@ public static string ComputeContentKey(AIContent content, bool isDiagnostic)
532534
DataContent data => $"data:{data.MediaType}\u0001{data.Data.Length}",
533535
ErrorContent error => "error:" + error.Message,
534536
UriContent uri => "uri:" + uri.Uri,
535-
_ => $"other:{content.GetType().FullName}\u0001{content}",
537+
UsageContent usage => $"usage:{usage.Details.InputTokenCount}\u0001{usage.Details.OutputTokenCount}\u0001{usage.Details.TotalTokenCount}",
538+
_ => $"other:{content.GetType().FullName}",
536539
};
537540
}
538541

@@ -841,6 +844,9 @@ private static string DiagnosticBody(string text)
841844
private static bool IsImageMediaType(string? mediaType)
842845
=> !string.IsNullOrWhiteSpace(mediaType) && mediaType.StartsWith("image/", StringComparison.OrdinalIgnoreCase);
843846

847+
private static string UsageMarker(string contentId, string detailsJson)
848+
=> $"<span class=\"chat-usage-marker\" data-usage-inspect-target data-details-target=\"{HtmlEscape(detailsJson)}\" id=\"{contentId}\"></span>";
849+
844850
private static string SerializeContentJson(AIContent content)
845851
{
846852
try

Phantom.Workspaces.Llm.Core.Tests/AgentResponseUpdateCoalescerTests.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,26 @@ public void Coalesce_AssistantTextThenToolCall_ProducesSingleMessageWithBothCont
4343
c => Assert.Equal("c1", Assert.IsType<FunctionCallContent>(c).CallId));
4444
}
4545

46+
[Fact]
47+
public void Coalesce_AssistantToolCallThenUsage_PreservesUsageAsLastContent()
48+
{
49+
var usage = new UsageContent(new UsageDetails { InputTokenCount = 10, OutputTokenCount = 5 });
50+
var updates = new[]
51+
{
52+
Update(ChatRole.Assistant, [new FunctionCallContent("c1", "tool", null)]),
53+
Update(ChatRole.Assistant, [usage]),
54+
};
55+
56+
var result = AgentResponseUpdateCoalescer.Coalesce(updates, Time);
57+
58+
var item = Assert.Single(result);
59+
Assert.Equal(ChatRole.Assistant, item.Role);
60+
Assert.Collection(
61+
item.Contents,
62+
c => Assert.Equal("c1", Assert.IsType<FunctionCallContent>(c).CallId),
63+
c => Assert.Same(usage, Assert.IsType<UsageContent>(c)));
64+
}
65+
4666
[Fact]
4767
public void Coalesce_MultipleAssistantTextDeltas_ConcatenatesIntoSingleTextContent()
4868
{

0 commit comments

Comments
 (0)