Skip to content

fix(claude-code): fix empty responses from claude -p driver (issue #295)#803

Open
jam676767 wants to merge 5 commits intoRightNow-AI:mainfrom
jam676767:fix/claude-code-empty-response-295
Open

fix(claude-code): fix empty responses from claude -p driver (issue #295)#803
jam676767 wants to merge 5 commits intoRightNow-AI:mainfrom
jam676767:fix/claude-code-empty-response-295

Conversation

@jam676767
Copy link

Summary

Fixes #295 — the claude-code driver always returns empty responses when using claude -p.

Five root causes were identified and each fixed in a separate commit:

Fix 1 — #[serde(default)] on ClaudeJsonOutput.result

serde_json fails to deserialize the CLI's JSON output if the result field is absent (e.g. when the response is in content or text instead). Adding #[serde(default)] makes the field optional and prevents silent deserialization failure that swallows the response.

Fix 2 — New structs for nested assistant message content

CLI ≥2.x emits type=assistant stream-json events where the text is nested in message.content[{"type":"text","text":"..."}]. ClaudeStreamEvent had no message field, so these events were always skipped. Added ClaudeMessageBlock, ClaudeAssistantMessage, and a message: Option<ClaudeAssistantMessage> field.

Fix 3 — Concurrent pipe drain in complete() to prevent deadlock

complete() called child.wait() before reading stdout/stderr. For responses larger than the OS pipe buffer (~64 KB), the subprocess blocks on write(), wait() never returns, the timeout fires, and stdout is empty. Fixed by spawning two tokio::spawn tasks to drain pipes concurrently with child.wait().

Also injects HOME from home_dir() so the CLI can find ~/.claude/credentials when OpenFang runs as a service, and sets stdin to null to prevent the CLI from blocking on interactive input.

Fix 4 — Inject HOME and null stdin in stream()

Same environment hygiene as Fix 3, applied to the streaming path.

Fix 5 — Extract text from message.content[].text in stream() handler

The type=assistant match arm only checked event.content (flat string). For CLI ≥2.x this field is null, so every streamed token was dropped and the method returned an empty string. The handler now checks event.content first (backward-compatible with older CLI), then falls back to joining all text-type blocks from message.content[].

Test plan

  • cargo test -p openfang-runtime --lib826 passed, 0 failed
  • Verified claude -p "Say hello" --output-format json returns non-empty result on CLI ≥2.x
  • Verified claude -p "Say hello" --output-format stream-json --verbose emits type=assistant events with nested content on CLI ≥2.x
  • Each fix is in its own commit for reviewability

🤖 Generated with Claude Code

jam and others added 5 commits March 23, 2026 09:31
Without this attribute, serde treats a missing `result` field as a
deserialization error even though `Option<T>` implies the field is
optional.  Some Claude CLI versions emit the response in `content` or
`text` rather than `result`; the silent parse failure caused the
driver to fall through to a plain-text read which could be empty,
triggering the "model returned an empty response" guard in the agent
loop.

Closes RightNow-AI#295.

Co-Authored-By: Claude <noreply@anthropic.com>
…ssistant content

Newer Claude CLI versions (≥2.x) emit assistant responses inside a nested
`message.content[].text` structure in stream-json events, rather than a
flat `content` string.

Add ClaudeMessageBlock and ClaudeAssistantMessage structs, plus a new
`message` field on ClaudeStreamEvent, so the stream handler can extract
text from both layouts.

Refs: RightNow-AI#295
…urrent drain

When complete() called child.wait() before reading stdout/stderr, large
responses (>64 KB) caused a deadlock: the subprocess blocked on write()
because the OS pipe buffer was full, and wait() never returned.

Fix by spawning two tokio tasks to drain stdout/stderr concurrently with
child.wait(), then collecting after the process exits.

Also inject HOME from home_dir() so the CLI finds ~/.claude/credentials
when OpenFang runs as a service, and set stdin to null so the CLI does
not stall waiting for interactive input.

Refs: RightNow-AI#295
Mirror the same environment fixes applied to complete(): inject HOME so
the CLI locates ~/.claude/credentials when running as a service, and set
stdin to null so the process does not block on interactive input.

Refs: RightNow-AI#295
…in stream()

Claude CLI ≥2.x emits type=assistant events where the response text is
inside message.content[{"type":"text","text":"..."}] rather than a flat
content string. The old handler only checked event.content, so every
token was silently dropped and streaming always returned an empty response.

The handler now checks the flat content field first (backward-compatible),
then falls back to joining all text blocks from message.content[].

Refs: RightNow-AI#295
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.

1 participant