Fix Claude stream assistant content normalization#20
Conversation
obey-agent
left a comment
There was a problem hiding this comment.
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.AssistantTextconcatenates onlytext-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).ToolUsesreturns(slice, bool)consistent withAssistantText's API shape; reuses the existingdecodeToolInputso tool input parsing stays in one place.pkg/claude/execution_hooks.go—extractResultFromMessagesnow accumulates assistant text in walk order before the firstresultmessage, then callsnormalizeResultwith that buffer as fallback.parseJSONTranscript's single-object path passes empty fallback (correct: no preceding messages to recover from). The oldextractToolUseshelper andtoolUseCalltype are deleted in favor ofMessage.ToolUses— no parallel implementations remaining.normalizeResultcontract — passes through populatedResultorIsError == trueunchanged, 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 withErrorValidation. Semantic change for SDK consumers but a correctness fix, not a regression.pkg/claude/streaming.go—StreamPromptaccumulates assistant text in the callback scope, normalizes on eachresultmessage (line 67), mutatesmsg.Resultso downstream hooks and channel consumers see the backfilled value, and tracksseenResultso a stream that ends without aresultmessage surfacesErrorValidationviaerrCh(line 95). Cancellation path on the channel send (line 78-82) preserved.pkg/claude/structured_run.go— same pattern, with the localresultvariable explicitly assigned soapplyCompletionHooksreceives the normalized value directly. Symmetric with streaming.go modulo the channel-send vs return-value difference.- Backward compat —
AssistantText'scase "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). - Tests —
TestMessageAssistantTextExtractsCurrentStreamContent,TestMessageToolUsesExtractsCurrentStreamContentpin the new envelope contract.TestParseJSONTranscript_BackfillsEmptyResultFromAssistantText,TestParseJSONTranscript_RejectsEmptySuccessfulResultcover the both branches of the parser.TestStreamPrompt_BackfillsEmptyResultFromAssistantMessage,TestStreamPrompt_NoResultReturnsValidationError,TestRunPromptCtx_WithPluginManagerBackfillsEmptyResultFromAssistantTextcover the streaming and structured-hook paths.
Verification I ran (at head 0b2b110)
go build ./...cleango test ./... -count=1 -timeout=120spass (pkg/claude2.522s,pkg/claude/dangerous0.400s,test/integration27.868s)gofmt -l pkg/claude/cleango 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.
Summary
Message.AssistantTextandMessage.ToolUseshelpers for current Claude Codestream-jsonassistant messages.Root Cause
Current Claude Code streaming emits assistant text under
assistant.message.content[].text, while the terminalresultevent can have an emptyresultfield. SDK callers that only read the terminalresultsaw 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 alljust lintgo test ./...Related: Obedience-Corp/obey#87