Skip to content

Fix Claude stream assistant content normalization#20

Merged
lancekrogers merged 1 commit into
mainfrom
fix/claude-stream-assistant-content
May 11, 2026
Merged

Fix Claude stream assistant content normalization#20
lancekrogers merged 1 commit into
mainfrom
fix/claude-stream-assistant-content

Conversation

@lancekrogers
Copy link
Copy Markdown
Owner

Summary

  • Add shared Message.AssistantText and Message.ToolUses helpers for current Claude Code stream-json assistant messages.
  • Backfill empty successful terminal result messages from prior assistant text in parsed transcripts, streaming, and structured hook execution paths.
  • Return validation errors for successful JSON responses that have neither result text nor recoverable assistant text, instead of silently surfacing an empty response.

Root Cause

Current Claude Code streaming emits assistant text under assistant.message.content[].text, while the terminal result event can have an empty result field. SDK callers that only read the terminal result saw an empty response even though assistant text had already streamed.

Impact

Adapters can keep depending on SDK-level stream parsing instead of carrying provider-specific fallback parsing. Tool-use extraction also continues through the same typed assistant content decoder.

Validation

  • just test all
  • just lint
  • go test ./...

Related: Obedience-Corp/obey#87

@lancekrogers lancekrogers marked this pull request as ready for review May 11, 2026 20:52
Copy link
Copy Markdown
Collaborator

@obey-agent obey-agent left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: Approve.

Reviewed at head 0b2b110. The fix solves the reported defect (current Claude Code stream-json emits assistant text under assistant.message.content[].text while the terminal result event can have empty result, so SDK callers see empty responses despite assistant text having streamed). The execution surface is fixed across all three entry points (parseJSONTranscript, StreamPrompt, runPromptWithStructuredHooks), the new Message.AssistantText / Message.ToolUses helpers are type-safe replacements for the previous map[string]interface{} extraction, and the test coverage exercises every pathway including the new validation-error case.

What I verified

  • pkg/claude/message.go — new typed envelope (assistantMessageEnvelope / assistantContentBlock) replaces ad-hoc map walking. AssistantText concatenates only text-type content blocks, correctly skipping thinking and tool_use blocks (the thinking branch in the switch covers the legacy text-encoded Message shape, not in-content thinking blocks, which is the right semantic for a "what's the final answer" backfill). ToolUses returns (slice, bool) consistent with AssistantText's API shape; reuses the existing decodeToolInput so tool input parsing stays in one place.
  • pkg/claude/execution_hooks.goextractResultFromMessages now accumulates assistant text in walk order before the first result message, then calls normalizeResult with that buffer as fallback. parseJSONTranscript's single-object path passes empty fallback (correct: no preceding messages to recover from). The old extractToolUses helper and toolUseCall type are deleted in favor of Message.ToolUses — no parallel implementations remaining.
  • normalizeResult contract — passes through populated Result or IsError == true unchanged, backfills from non-empty fallback, errors on empty-result-no-fallback. This is the right policy: a successful result with no recoverable text was previously surfaced silently as empty; now it fails loudly with ErrorValidation. Semantic change for SDK consumers but a correctness fix, not a regression.
  • pkg/claude/streaming.goStreamPrompt accumulates assistant text in the callback scope, normalizes on each result message (line 67), mutates msg.Result so downstream hooks and channel consumers see the backfilled value, and tracks seenResult so a stream that ends without a result message surfaces ErrorValidation via errCh (line 95). Cancellation path on the channel send (line 78-82) preserved.
  • pkg/claude/structured_run.go — same pattern, with the local result variable explicitly assigned so applyCompletionHooks receives the normalized value directly. Symmetric with streaming.go modulo the channel-send vs return-value difference.
  • Backward compatAssistantText's case "text", "thinking" branch keeps older JSON-string Message shapes working. Per claude-code-go being a published SDK at v1.2.0, this is the right call (semver still applies; new behavior is purely additive on the parsing surface).
  • TestsTestMessageAssistantTextExtractsCurrentStreamContent, TestMessageToolUsesExtractsCurrentStreamContent pin the new envelope contract. TestParseJSONTranscript_BackfillsEmptyResultFromAssistantText, TestParseJSONTranscript_RejectsEmptySuccessfulResult cover the both branches of the parser. TestStreamPrompt_BackfillsEmptyResultFromAssistantMessage, TestStreamPrompt_NoResultReturnsValidationError, TestRunPromptCtx_WithPluginManagerBackfillsEmptyResultFromAssistantText cover the streaming and structured-hook paths.

Verification I ran (at head 0b2b110)

  • go build ./... clean
  • go test ./... -count=1 -timeout=120s pass (pkg/claude 2.522s, pkg/claude/dangerous 0.400s, test/integration 27.868s)
  • gofmt -l pkg/claude/ clean
  • go vet ./... clean

Release note (not blocking; out of PR scope)

claude-code-go is at v1.2.0 with no CHANGELOG.md, and the parseJSONTranscript and StreamPrompt behavior shifts from "silently surface empty result" to "return ErrorValidation when there is no result text and no recoverable assistant text." SDK consumers that previously branched on result.Result == "" will now need to handle the error too. Worth a minor version bump (v1.3.0) and a one-line note in the release. Repo-conventions decision, not a PR change request.

Ready to merge.

@lancekrogers lancekrogers merged commit 87b3f15 into main May 11, 2026
1 check passed
@lancekrogers lancekrogers deleted the fix/claude-stream-assistant-content branch May 11, 2026 21:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants