From c007d53b5043211d0bc0b5525112ad0a37fb4daf Mon Sep 17 00:00:00 2001 From: AgentSeal Date: Mon, 22 Jun 2026 01:25:45 +0200 Subject: [PATCH 1/2] fix(menubar): surface CLI stdout/stderr on decode failure (#515) A failed decode of the CLI menubar-json output threw an opaque DataClientError.decode(error) that surfaced only "not valid JSON", hiding whether stdout was empty or carried a non-JSON prefix (e.g. a stray Node banner on stdout, the root cause in #515). Wrap it in a CLIDecodeFailure that carries a bounded stdout snippet, the stdout byte count, and stderr, so String(describing:) is self-diagnosing in logs and the UI. --- .../CodeBurnMenubar/Data/DataClient.swift | 28 ++++++++++++++++++- .../DataClientProcessTests.swift | 25 +++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/mac/Sources/CodeBurnMenubar/Data/DataClient.swift b/mac/Sources/CodeBurnMenubar/Data/DataClient.swift index df9891b7..7eabd4c4 100644 --- a/mac/Sources/CodeBurnMenubar/Data/DataClient.swift +++ b/mac/Sources/CodeBurnMenubar/Data/DataClient.swift @@ -17,6 +17,26 @@ enum DataClientError: Error { case outputTooLarge } +/// Wraps a `MenubarPayload` decode failure with a bounded snippet of what the CLI +/// actually wrote to stdout (plus stderr), so a malformed-output failure — for +/// example a stray Node banner landing on stdout ahead of the JSON (see #515) — +/// is self-diagnosing in logs and the UI instead of an opaque "not valid JSON". +struct CLIDecodeFailure: Error, CustomStringConvertible { + let underlying: Error + let stdoutByteCount: Int + let stdoutSnippet: String + let stderr: String + + var description: String { + var parts = [ + "decode failed: \(underlying)", + "stdout (\(stdoutByteCount) bytes): \(stdoutSnippet.isEmpty ? "" : stdoutSnippet)", + ] + if !stderr.isEmpty { parts.append("stderr: \(stderr)") } + return parts.joined(separator: " | ") + } +} + /// Runs the CLI via argv (no shell interpretation). See `CodeburnCLI` for why we never route /// commands through `/bin/zsh -c` anymore. struct DataClient { @@ -46,7 +66,13 @@ struct DataClient { do { return try JSONDecoder().decode(MenubarPayload.self, from: result.stdout) } catch { - throw DataClientError.decode(error) + let snippet = String(decoding: result.stdout.prefix(2048), as: UTF8.self) + throw DataClientError.decode(CLIDecodeFailure( + underlying: error, + stdoutByteCount: result.stdout.count, + stdoutSnippet: snippet, + stderr: result.stderr + )) } } diff --git a/mac/Tests/CodeBurnMenubarTests/DataClientProcessTests.swift b/mac/Tests/CodeBurnMenubarTests/DataClientProcessTests.swift index 79f1e976..b98449dd 100644 --- a/mac/Tests/CodeBurnMenubarTests/DataClientProcessTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/DataClientProcessTests.swift @@ -38,6 +38,31 @@ final class DataClientProcessTests: XCTestCase { "runProcess deadlocked: \(count) concurrent CLIs starved the cooperative pool") } + /// A decode failure surfaces the CLI's actual stdout/stderr so a stray banner + /// on stdout (see #515) is self-diagnosing instead of an opaque "not valid JSON". + func testDecodeFailureSurfacesOutput() { + struct Boom: Error {} + let failure = CLIDecodeFailure( + underlying: Boom(), + stdoutByteCount: 13, + stdoutSnippet: "(node) banner", + stderr: "warn: x" + ) + let text = String(describing: failure) + XCTAssertTrue(text.contains("(node) banner"), "should include the stdout snippet") + XCTAssertTrue(text.contains("13 bytes"), "should include the stdout byte count") + XCTAssertTrue(text.contains("warn: x"), "should include stderr") + } + + /// Empty stdout is reported distinctly (the JSONDecoder-on-empty-Data case). + func testDecodeFailureWithEmptyStdout() { + struct Boom: Error {} + let failure = CLIDecodeFailure(underlying: Boom(), stdoutByteCount: 0, stdoutSnippet: "", stderr: "") + let text = String(describing: failure) + XCTAssertTrue(text.contains("0 bytes")) + XCTAssertTrue(text.contains("")) + } + /// A normally-exiting process returns its real output and exit code through /// the off-pool wait path. func testProcessReturnsOutputAndExitCode() async throws { From aba6804f5de2df1360c1ef5c5d666035bf8c4029 Mon Sep 17 00:00:00 2001 From: AgentSeal Date: Mon, 22 Jun 2026 01:25:45 +0200 Subject: [PATCH 2/2] test(menubar): add missing codexCredits arg so the Swift test target compiles Two tests built CurrentBlock without the codexCredits parameter added in #510, so the Swift test target failed to compile (CI does not run the Swift tests, so it went unnoticed). Add codexCredits: nil to both. --- .../CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift | 1 + mac/Tests/CodeBurnMenubarTests/MenubarStatusCacheTests.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift b/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift index b90c51b9..1314fbd9 100644 --- a/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift @@ -14,6 +14,7 @@ private func menubarPayload(cost: Double) -> MenubarPayload { inputTokens: 1, outputTokens: 1, cacheHitPercent: 0, + codexCredits: nil, topActivities: [], topModels: [], localModelSavings: LocalModelSavings(totalUSD: 0, calls: 0, byModel: [], byProvider: []), diff --git a/mac/Tests/CodeBurnMenubarTests/MenubarStatusCacheTests.swift b/mac/Tests/CodeBurnMenubarTests/MenubarStatusCacheTests.swift index ff447ae4..1d347b18 100644 --- a/mac/Tests/CodeBurnMenubarTests/MenubarStatusCacheTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/MenubarStatusCacheTests.swift @@ -16,6 +16,7 @@ struct MenubarStatusCacheTests { current: CurrentBlock( label: "Today", cost: cost, calls: 1, sessions: 1, oneShotRate: nil, inputTokens: 1, outputTokens: 1, cacheHitPercent: 0, + codexCredits: nil, topActivities: [], topModels: [], localModelSavings: LocalModelSavings(totalUSD: 0, calls: 0, byModel: [], byProvider: []), providers: ["claude": cost], topProjects: [], modelEfficiency: [], topSessions: [], retryTax: RetryTax(totalUSD: 0, retries: 0, editTurns: 0, byModel: []),