Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion lib/claude_code/adapter/port.ex
Original file line number Diff line number Diff line change
Expand Up @@ -557,9 +557,14 @@ defmodule ClaudeCode.Adapter.Port do

defp process_line("", state), do: state

# The CLI may emit non-map JSON values (booleans, arrays, numbers, strings) on
# stdout when, for example, a hook callback fails and a Zod schema validation
# error is printed alongside the normal stream-JSON output. Filter those out
# here so they never reach `Control.classify/1` (which is spec'd to take a map)
# or `handle_sdk_message/2` (whose clauses index with `json["type"]`).
defp process_line(line, state) do
case Jason.decode(line) do
{:ok, json} ->
{:ok, json} when is_map(json) ->
case Control.classify(json) do
{:control_response, msg} ->
handle_control_response(msg, state)
Expand All @@ -574,6 +579,10 @@ defmodule ClaudeCode.Adapter.Port do
handle_sdk_message(json_msg, state)
end

{:ok, non_map} ->
Logger.warning("Dropping non-map CLI output: #{inspect(non_map)}")
state

{:error, _} ->
Logger.debug("Non-JSON CLI output: #{String.slice(line, 0, 500)}")
state
Expand Down
43 changes: 43 additions & 0 deletions test/claude_code/adapter/port_integration_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,49 @@ defmodule ClaudeCode.Adapter.PortIntegrationTest do
end
end

describe "non-map JSON in stream" do
setup do
# CLI emits a non-map JSON line (`true`) between a normal system message
# and the final result. Without the `when not is_map(json)` guard in
# `handle_sdk_message/2`, the Port GenServer would crash with a
# `FunctionClauseError` from `Access.get(true, "type", nil)` and abort the
# in-flight session.
MockCLI.setup_with_script("""
#!/bin/bash
while IFS= read -r line; do
if echo "$line" | grep -q '"type":"control_request"'; then
REQ_ID=$(echo "$line" | grep -o '"request_id":"[^"]*"' | cut -d'"' -f4)
echo "{\\\"type\\\":\\\"control_response\\\",\\\"response\\\":{\\\"subtype\\\":\\\"success\\\",\\\"request_id\\\":\\\"$REQ_ID\\\",\\\"response\\\":{}}}"
else
echo '{"type":"system","subtype":"init","cwd":"/test","session_id":"nonmap-test","tools":[],"mcp_servers":[],"model":"claude-3","permissionMode":"auto","apiKeySource":"ANTHROPIC_API_KEY"}'
echo 'true'
echo '[1,2,3]'
echo '42'
echo '{"type":"result","subtype":"success","is_error":false,"duration_ms":50,"duration_api_ms":40,"num_turns":1,"result":"survived","session_id":"nonmap-test","total_cost_usd":0.001,"usage":{}}'
fi
done
exit 0
""")
end

test "session survives and returns a result when CLI emits non-map JSON", %{
mock_script: mock_script
} do
{:ok, session} = ClaudeCode.start_link(api_key: "test-key", cli_path: mock_script)

messages =
session
|> ClaudeCode.stream("hello")
|> Enum.to_list()

result = Enum.find(messages, &match?(%ResultMessage{}, &1))
assert result != nil
assert result.result == "survived"

GenServer.stop(session)
end
end

describe "stream completed normally" do
setup do
MockCLI.setup_with_script("""
Expand Down
4 changes: 2 additions & 2 deletions test/claude_code/adapter/port_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -1741,7 +1741,7 @@ defmodule ClaudeCode.Adapter.PortTest do
Port.filter_system_env(%{"PATH" => "/usr/bin"}, ["NONEXISTENT_VAR"])
end)

assert log == ""
refute log =~ "no matching system env"
end

test "no logging for :all mode even with debug" do
Expand All @@ -1750,7 +1750,7 @@ defmodule ClaudeCode.Adapter.PortTest do
Port.filter_system_env(%{"PATH" => "/usr/bin"}, :all, debug: true)
end)

assert log == ""
refute log =~ "no matching system env"
end
end
end
Loading