11using System ;
22using System . Collections . Generic ;
33using System . Linq ;
4+ using System . Net ;
45using System . Text . Json ;
56using Microsoft . Extensions . AI ;
67using 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 "arg"" , 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 {
0 commit comments