diff --git a/lib/claude_code/adapter/port.ex b/lib/claude_code/adapter/port.ex index 852d5f8..860a629 100644 --- a/lib/claude_code/adapter/port.ex +++ b/lib/claude_code/adapter/port.ex @@ -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) @@ -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 diff --git a/test/claude_code/adapter/port_integration_test.exs b/test/claude_code/adapter/port_integration_test.exs index 2e31ed8..43e18f5 100644 --- a/test/claude_code/adapter/port_integration_test.exs +++ b/test/claude_code/adapter/port_integration_test.exs @@ -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(""" diff --git a/test/claude_code/adapter/port_test.exs b/test/claude_code/adapter/port_test.exs index 434ea76..6b28c15 100644 --- a/test/claude_code/adapter/port_test.exs +++ b/test/claude_code/adapter/port_test.exs @@ -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 @@ -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