From cf7f0da40020207fc4722a08e1e4c73046746daa Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 4 Jun 2026 14:15:45 +0200 Subject: [PATCH] feat(roadblock): collect and strip CLAUDEDO_BLOCKED markers in StreamAnalyzer --- src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs | 39 ++++++++++++++++++- .../Runner/StreamAnalyzerTests.cs | 32 +++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs b/src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs index 4040b13..89060b6 100644 --- a/src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs +++ b/src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs @@ -1,3 +1,4 @@ +using System.Linq; using System.Text.Json; namespace ClaudeDo.Worker.Runner; @@ -11,6 +12,7 @@ public sealed class StreamResult public int TokensIn { get; set; } public int TokensOut { get; set; } public int ApiRetryCount { get; set; } + public IReadOnlyList Blocks { get; set; } = Array.Empty(); } public sealed class StreamAnalyzer @@ -22,6 +24,8 @@ public sealed class StreamAnalyzer private int _tokensIn; private int _tokensOut; private int _apiRetryCount; + private readonly List _blocks = new(); + private const string BlockedPrefix = "CLAUDEDO_BLOCKED:"; public void ProcessLine(string ndjsonLine) { @@ -39,7 +43,7 @@ public sealed class StreamAnalyzer { case "result": if (root.TryGetProperty("result", out var resultProp)) - _resultMarkdown = resultProp.GetString(); + _resultMarkdown = StripAndCollect(resultProp.GetString()); if (root.TryGetProperty("structured_output", out var structuredProp)) _structuredOutputJson = structuredProp.ToString(); if (root.TryGetProperty("session_id", out var sessionProp)) @@ -56,6 +60,7 @@ public sealed class StreamAnalyzer case "assistant": _turnCount++; + CollectFromAssistant(root); break; case "system": @@ -81,6 +86,7 @@ public sealed class StreamAnalyzer TokensIn = _tokensIn, TokensOut = _tokensOut, ApiRetryCount = _apiRetryCount, + Blocks = _blocks, }; private string? FallbackResult() @@ -97,6 +103,37 @@ public sealed class StreamAnalyzer return _structuredOutputJson; } + private void CollectFromAssistant(JsonElement root) + { + if (!root.TryGetProperty("message", out var msg)) return; + if (msg.ValueKind != JsonValueKind.Object) return; + if (!msg.TryGetProperty("content", out var content) || content.ValueKind != JsonValueKind.Array) return; + foreach (var block in content.EnumerateArray()) + if (block.TryGetProperty("type", out var t) && t.GetString() == "text" + && block.TryGetProperty("text", out var txt)) + ScanForBlocks(txt.GetString()); + } + + private void ScanForBlocks(string? text) + { + if (string.IsNullOrEmpty(text)) return; + foreach (var line in text.Split('\n')) + { + var trimmed = line.Trim(); + if (trimmed.StartsWith(BlockedPrefix, StringComparison.Ordinal)) + _blocks.Add(trimmed[BlockedPrefix.Length..].Trim()); + } + } + + private string? StripAndCollect(string? text) + { + if (string.IsNullOrEmpty(text)) return text; + ScanForBlocks(text); + var kept = text.Split('\n') + .Where(l => !l.Trim().StartsWith(BlockedPrefix, StringComparison.Ordinal)); + return string.Join('\n', kept).Trim(); + } + 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 03e3df7..371f952 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/StreamAnalyzerTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/StreamAnalyzerTests.cs @@ -120,4 +120,36 @@ public sealed class StreamAnalyzerTests Assert.Contains("output", result.ResultMarkdown); Assert.Contains("42", result.ResultMarkdown); } + + [Fact] + public void Collects_Blocked_Markers_From_Assistant_Text() + { + var analyzer = new StreamAnalyzer(); + analyzer.ProcessLine("""{"type":"assistant","message":{"content":[{"type":"text","text":"working\nCLAUDEDO_BLOCKED: missing API key\nmoving on"}]}}"""); + analyzer.ProcessLine("""{"type":"assistant","message":{"content":[{"type":"text","text":"CLAUDEDO_BLOCKED: cannot reach db"}]}}"""); + analyzer.ProcessLine("""{"type":"result","result":"done","session_id":"s1"}"""); + var result = analyzer.GetResult(); + Assert.Equal(2, result.Blocks.Count); + Assert.Equal("missing API key", result.Blocks[0]); + Assert.Equal("cannot reach db", result.Blocks[1]); + } + + [Fact] + public void Strips_Blocked_Markers_From_Result_Text() + { + var analyzer = new StreamAnalyzer(); + analyzer.ProcessLine("""{"type":"result","result":"All set.\nCLAUDEDO_BLOCKED: no creds\nDone.","session_id":"s1"}"""); + var result = analyzer.GetResult(); + Assert.DoesNotContain("CLAUDEDO_BLOCKED", result.ResultMarkdown); + Assert.Single(result.Blocks); + Assert.Equal("no creds", result.Blocks[0]); + } + + [Fact] + public void No_Markers_Means_Empty_Blocks() + { + var analyzer = new StreamAnalyzer(); + analyzer.ProcessLine("""{"type":"result","result":"done","session_id":"s1"}"""); + Assert.Empty(analyzer.GetResult().Blocks); + } }