From 4c6e6594dc4187310a588942b8910abeb1d04c6b Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Mon, 1 Jun 2026 16:01:11 +0200 Subject: [PATCH] =?UTF-8?q?=EF=BB=BFfix(claude-do):=20Run=20reporting:=20t?= =?UTF-8?q?oken=20accounting=20+=20populate=20empty=20result?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BUNDLE — both fixes live in the Worker run-recording / persistence layer (where a TaskRun is written after an agent finishes), NOT in ExternalMcpService.cs. Keep this disjoint from the MCP-surface bundle so the two can run in parallel without worktree conflicts. The DTO fields (tokensIn, tokensOut, resultMarkdown) already exist and are surfaced by list_runs/get_run — the bug is at write time. 1. ClaudeDo-Task: 49a6060a-5044-4f1b-8665-5cfc064b8a82 --- src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs | 24 ++++++++++- .../Runner/StreamAnalyzerTests.cs | 41 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs b/src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs index 39f9f7e..4040b13 100644 --- a/src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs +++ b/src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs @@ -44,6 +44,14 @@ public sealed class StreamAnalyzer _structuredOutputJson = structuredProp.ToString(); if (root.TryGetProperty("session_id", out var sessionProp)) _sessionId = sessionProp.GetString(); + // Authoritative token totals live on the result event. + if (root.TryGetProperty("usage", out var resultUsage)) + { + if (resultUsage.TryGetProperty("input_tokens", out var inp)) + _tokensIn = inp.GetInt32(); + if (resultUsage.TryGetProperty("output_tokens", out var outp)) + _tokensOut = outp.GetInt32(); + } break; case "assistant": @@ -66,7 +74,7 @@ public sealed class StreamAnalyzer public StreamResult GetResult() => new() { - ResultMarkdown = _resultMarkdown, + ResultMarkdown = FallbackResult(), StructuredOutputJson = _structuredOutputJson, SessionId = _sessionId, TurnCount = _turnCount, @@ -75,6 +83,20 @@ public sealed class StreamAnalyzer ApiRetryCount = _apiRetryCount, }; + private string? FallbackResult() + { + if (!string.IsNullOrEmpty(_resultMarkdown)) return _resultMarkdown; + if (_structuredOutputJson is null) return _resultMarkdown; + try + { + using var doc = JsonDocument.Parse(_structuredOutputJson); + if (doc.RootElement.TryGetProperty("summary", out var s)) + return s.GetString(); + } + catch { } + return _structuredOutputJson; + } + private void TryAccumulateUsage(JsonElement root) { if (!root.TryGetProperty("event", out var eventProp)) return; diff --git a/tests/ClaudeDo.Worker.Tests/Runner/StreamAnalyzerTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/StreamAnalyzerTests.cs index 009df99..03e3df7 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/StreamAnalyzerTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/StreamAnalyzerTests.cs @@ -79,4 +79,45 @@ public sealed class StreamAnalyzerTests Assert.Null(result.ResultMarkdown); Assert.Null(result.SessionId); } + + [Fact] + public void Token_Usage_From_Result_Event() + { + var analyzer = new StreamAnalyzer(); + analyzer.ProcessLine("""{"type":"result","result":"done","session_id":"s1","usage":{"input_tokens":150,"output_tokens":75,"cache_read_input_tokens":0}}"""); + var result = analyzer.GetResult(); + Assert.Equal(150, result.TokensIn); + Assert.Equal(75, result.TokensOut); + } + + [Fact] + public void Result_Usage_Overrides_Stream_Event_Accumulation() + { + var analyzer = new StreamAnalyzer(); + analyzer.ProcessLine("""{"type":"stream_event","event":{"type":"message_start","message":{"usage":{"input_tokens":10,"output_tokens":5}}}}"""); + analyzer.ProcessLine("""{"type":"result","result":"done","session_id":"s1","usage":{"input_tokens":200,"output_tokens":90}}"""); + var result = analyzer.GetResult(); + Assert.Equal(200, result.TokensIn); + Assert.Equal(90, result.TokensOut); + } + + [Fact] + public void Empty_Result_Falls_Back_To_Structured_Output_Summary() + { + var analyzer = new StreamAnalyzer(); + analyzer.ProcessLine("""{"type":"result","result":"","structured_output":{"summary":"Task completed successfully.","data":{}},"session_id":"s1"}"""); + var result = analyzer.GetResult(); + Assert.Equal("Task completed successfully.", result.ResultMarkdown); + Assert.Contains("summary", result.StructuredOutputJson); + } + + [Fact] + public void Empty_Result_Falls_Back_To_Full_Json_When_No_Summary() + { + var analyzer = new StreamAnalyzer(); + analyzer.ProcessLine("""{"type":"result","result":"","structured_output":{"output":"42"},"session_id":"s1"}"""); + var result = analyzer.GetResult(); + Assert.Contains("output", result.ResultMarkdown); + Assert.Contains("42", result.ResultMarkdown); + } }