From cb2db6bab021a797b3befb81ed0a80f85b37f8ce Mon Sep 17 00:00:00 2001 From: Peter Shoukry Date: Fri, 29 May 2026 07:27:20 +0300 Subject: [PATCH 1/2] feat: parse unknown system-message subtypes into a Generic struct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A newer CLI emits system-message subtypes this SDK version does not model yet (e.g. thinking_tokens). The strict subtype dispatch returned {:error, {:unknown_system_subtype, _}}, so ClaudeCode.Session.Server dropped the message and logged a warning for every one (observed 500+ times in a run). Add ClaudeCode.Message.SystemMessage.Generic — a fallback carrying the raw subtype string and the full data payload — and have the parser return it for unrecognized system subtypes. Consumers now receive the event with its data, and the SDK stays forward-compatible with new CLI system messages without a release. The subtype is kept as a string (not an atom) since the set of future subtypes is open-ended. --- CHANGELOG.md | 4 + lib/claude_code/cli/parser.ex | 7 +- .../message/system_message/generic.ex | 87 +++++++++++++++++++ test/claude_code/cli/parser_test.exs | 24 ++++- 4 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 lib/claude_code/message/system_message/generic.ex diff --git a/CHANGELOG.md b/CHANGELOG.md index 65d57ed2..2486a448 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`ClaudeCode.Message.SystemMessage.Generic`** — a fallback struct for system-message subtypes the SDK does not model with a dedicated struct yet. `ClaudeCode.CLI.Parser` now returns a `Generic` (carrying the raw `subtype` and full `data` payload) for unknown system subtypes instead of failing with `{:error, {:unknown_system_subtype, _}}`. This keeps the SDK forward-compatible with new CLI system messages (e.g. `thinking_tokens`) — consumers receive the event with its data instead of it being dropped and logged by `ClaudeCode.Session.Server` as a parse failure on every message. + ## [0.36.5] - 2026-05-28 | CC 2.1.76 ### Fixed diff --git a/lib/claude_code/cli/parser.ex b/lib/claude_code/cli/parser.ex index dee8e64b..58b5732e 100644 --- a/lib/claude_code/cli/parser.ex +++ b/lib/claude_code/cli/parser.ex @@ -34,6 +34,7 @@ defmodule ClaudeCode.CLI.Parser do alias ClaudeCode.Message.SystemMessage.CompactBoundary alias ClaudeCode.Message.SystemMessage.ElicitationComplete alias ClaudeCode.Message.SystemMessage.FilesPersisted + alias ClaudeCode.Message.SystemMessage.Generic alias ClaudeCode.Message.SystemMessage.HookProgress alias ClaudeCode.Message.SystemMessage.HookResponse alias ClaudeCode.Message.SystemMessage.HookStarted @@ -137,7 +138,11 @@ defmodule ClaudeCode.CLI.Parser do %{"type" => "system", "subtype" => subtype} when is_binary(subtype) -> case Map.fetch(@system_parsers, subtype) do {:ok, parser} -> parser.(data) - :error -> {:error, {:unknown_system_subtype, subtype}} + # Forward compatibility: a newer CLI may emit system-message subtypes + # this SDK version does not model yet (e.g. "thinking_tokens"). Return a + # Generic so the event is delivered with its full payload instead of + # being dropped as a parse error. + :error -> Generic.new(data) end %{"type" => "system"} -> diff --git a/lib/claude_code/message/system_message/generic.ex b/lib/claude_code/message/system_message/generic.ex new file mode 100644 index 00000000..ff97430d --- /dev/null +++ b/lib/claude_code/message/system_message/generic.ex @@ -0,0 +1,87 @@ +defmodule ClaudeCode.Message.SystemMessage.Generic do + @moduledoc """ + Fallback struct for system messages whose `subtype` this SDK version does not + model with a dedicated struct (e.g. a `thinking_tokens` message emitted by a + newer CLI). + + Rather than failing to parse — and dropping the message — the parser returns a + `Generic` so consumers still receive the event with its full payload. This + keeps the SDK forward-compatible with new CLI system-message subtypes without a + release. When a subtype becomes common enough to warrant typed access, promote + it to its own module under `ClaudeCode.Message.SystemMessage.*` and register it + in `ClaudeCode.CLI.Parser`. + + ## Fields + + - `:type` - Always `:system` + - `:subtype` - The raw subtype string (kept as a string, not an atom, since the + set of future subtypes is open-ended) + - `:session_id` - Session identifier, when present + - `:uuid` - Message UUID, when present + - `:data` - The remaining message fields (everything except `type`, `subtype`, + `session_id`, and `uuid`), with keys normalized to snake_case + + ## JSON Format + + ```json + { + "type": "system", + "subtype": "thinking_tokens", + "session_id": "...", + "uuid": "...", + "...": "subtype-specific fields" + } + ``` + """ + + use ClaudeCode.JSONEncoder + + @reserved_keys ~w(type subtype session_id uuid) + + @enforce_keys [:type, :subtype] + defstruct [:type, :subtype, :session_id, :uuid, data: %{}] + + @type t :: %__MODULE__{ + type: :system, + subtype: String.t(), + session_id: String.t() | nil, + uuid: String.t() | nil, + data: map() + } + + @doc """ + Creates a new `Generic` system message from JSON data. + + Expects keys to already be normalized (see `ClaudeCode.CLI.Parser.normalize_keys/1`). + + ## Examples + + iex> Generic.new(%{ + ...> "type" => "system", + ...> "subtype" => "thinking_tokens", + ...> "session_id" => "session-1", + ...> "max_thinking_tokens" => 10_000 + ...> }) + {:ok, %Generic{subtype: "thinking_tokens", data: %{"max_thinking_tokens" => 10_000}}} + """ + @spec new(map()) :: {:ok, t()} | {:error, atom()} + def new(%{"type" => "system", "subtype" => subtype} = json) when is_binary(subtype) do + {:ok, + %__MODULE__{ + type: :system, + subtype: subtype, + session_id: json["session_id"], + uuid: json["uuid"], + data: Map.drop(json, @reserved_keys) + }} + end + + def new(_), do: {:error, :invalid_message_type} + + @doc """ + Type guard to check if a value is a `Generic` system message. + """ + @spec generic?(any()) :: boolean() + def generic?(%__MODULE__{type: :system}), do: true + def generic?(_), do: false +end diff --git a/test/claude_code/cli/parser_test.exs b/test/claude_code/cli/parser_test.exs index 512e599e..2304c378 100644 --- a/test/claude_code/cli/parser_test.exs +++ b/test/claude_code/cli/parser_test.exs @@ -22,6 +22,7 @@ defmodule ClaudeCode.CLI.ParserTest do alias ClaudeCode.Message.SystemMessage.CompactBoundary alias ClaudeCode.Message.SystemMessage.ElicitationComplete alias ClaudeCode.Message.SystemMessage.FilesPersisted + alias ClaudeCode.Message.SystemMessage.Generic alias ClaudeCode.Message.SystemMessage.HookProgress alias ClaudeCode.Message.SystemMessage.HookResponse alias ClaudeCode.Message.SystemMessage.HookStarted @@ -383,7 +384,7 @@ defmodule ClaudeCode.CLI.ParserTest do }} = Parser.parse_message(data) end - test "returns error for unknown system subtypes" do + test "parses unknown system subtypes into a Generic message, preserving payload" do data = %{ "type" => "system", "subtype" => "some_future_subtype", @@ -392,7 +393,26 @@ defmodule ClaudeCode.CLI.ParserTest do "custom_field" => "custom_value" } - assert {:error, {:unknown_system_subtype, "some_future_subtype"}} = Parser.parse_message(data) + assert {:ok, + %Generic{ + type: :system, + subtype: "some_future_subtype", + session_id: "session-1", + uuid: "event-uuid", + data: %{"custom_field" => "custom_value"} + }} = Parser.parse_message(data) + end + + test "parses a thinking_tokens system message into a Generic message" do + data = %{ + "type" => "system", + "subtype" => "thinking_tokens", + "session_id" => "session-1", + "max_thinking_tokens" => 10_000 + } + + assert {:ok, %Generic{subtype: "thinking_tokens", data: %{"max_thinking_tokens" => 10_000}}} = + Parser.parse_message(data) end test "parses rate_limit_event messages" do From 1186162e6ecd1685956a29763d9e9c419f56c373 Mon Sep 17 00:00:00 2001 From: Peter Shoukry Date: Fri, 5 Jun 2026 14:28:36 +0300 Subject: [PATCH 2/2] fix: enroll Generic in system-message family + address review - Register Generic in SystemMessage.type?/1 and the @type union so Message.message?/1 and Session.Server session-id tracking recognize it (previously Generic events were delivered by the parser but dropped by message?/1 stream filters and ignored for session-id extraction). - Drop dead :unknown_system_subtype from the parser skip set; the parser no longer emits that error. - Correct the Generic.new/1 @doc example to reflect the fields the code sets (type, session_id) so it satisfies @enforce_keys. - Add generic_test.exs covering new/1, generic?/1, and the type?/message? family-enrollment wiring, plus run the doctest. --- lib/claude_code/cli/parser.ex | 1 - lib/claude_code/message/system_message.ex | 3 + .../message/system_message/generic.ex | 9 +- .../message/system_message/generic_test.exs | 87 +++++++++++++++++++ 4 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 test/claude_code/message/system_message/generic_test.exs diff --git a/lib/claude_code/cli/parser.ex b/lib/claude_code/cli/parser.ex index 58b5732e..506ac4bd 100644 --- a/lib/claude_code/cli/parser.ex +++ b/lib/claude_code/cli/parser.ex @@ -171,7 +171,6 @@ defmodule ClaudeCode.CLI.Parser do def parse_messages(messages) when is_list(messages) do reduce_parsed(messages, &parse_message/1, [ :unknown_message_type, - :unknown_system_subtype, :unknown_event_type ]) end diff --git a/lib/claude_code/message/system_message.ex b/lib/claude_code/message/system_message.ex index afc498e0..27117b11 100644 --- a/lib/claude_code/message/system_message.ex +++ b/lib/claude_code/message/system_message.ex @@ -11,6 +11,7 @@ defmodule ClaudeCode.Message.SystemMessage do alias __MODULE__.CompactBoundary alias __MODULE__.ElicitationComplete alias __MODULE__.FilesPersisted + alias __MODULE__.Generic alias __MODULE__.HookProgress alias __MODULE__.HookResponse alias __MODULE__.HookStarted @@ -34,6 +35,7 @@ defmodule ClaudeCode.Message.SystemMessage do | TaskStarted.t() | TaskProgress.t() | TaskNotification.t() + | Generic.t() @doc """ Checks if a value is any type of system message. @@ -51,5 +53,6 @@ defmodule ClaudeCode.Message.SystemMessage do def type?(%TaskStarted{}), do: true def type?(%TaskProgress{}), do: true def type?(%TaskNotification{}), do: true + def type?(%Generic{type: :system}), do: true def type?(_), do: false end diff --git a/lib/claude_code/message/system_message/generic.ex b/lib/claude_code/message/system_message/generic.ex index ff97430d..38fb22bd 100644 --- a/lib/claude_code/message/system_message/generic.ex +++ b/lib/claude_code/message/system_message/generic.ex @@ -62,7 +62,14 @@ defmodule ClaudeCode.Message.SystemMessage.Generic do ...> "session_id" => "session-1", ...> "max_thinking_tokens" => 10_000 ...> }) - {:ok, %Generic{subtype: "thinking_tokens", data: %{"max_thinking_tokens" => 10_000}}} + {:ok, + %Generic{ + type: :system, + subtype: "thinking_tokens", + session_id: "session-1", + uuid: nil, + data: %{"max_thinking_tokens" => 10_000} + }} """ @spec new(map()) :: {:ok, t()} | {:error, atom()} def new(%{"type" => "system", "subtype" => subtype} = json) when is_binary(subtype) do diff --git a/test/claude_code/message/system_message/generic_test.exs b/test/claude_code/message/system_message/generic_test.exs new file mode 100644 index 00000000..a761b863 --- /dev/null +++ b/test/claude_code/message/system_message/generic_test.exs @@ -0,0 +1,87 @@ +defmodule ClaudeCode.Message.SystemMessage.GenericTest do + use ExUnit.Case, async: true + + alias ClaudeCode.Message + alias ClaudeCode.Message.SystemMessage + alias ClaudeCode.Message.SystemMessage.Generic + + doctest Generic + + describe "new/1" do + test "parses an unknown system subtype, preserving the payload" do + json = %{ + "type" => "system", + "subtype" => "thinking_tokens", + "session_id" => "session-abc", + "uuid" => "uuid-123", + "max_thinking_tokens" => 10_000 + } + + assert {:ok, message} = Generic.new(json) + assert message.type == :system + assert message.subtype == "thinking_tokens" + assert message.session_id == "session-abc" + assert message.uuid == "uuid-123" + assert message.data == %{"max_thinking_tokens" => 10_000} + end + + test "keeps subtype as a string rather than an atom" do + json = %{"type" => "system", "subtype" => "thinking_tokens"} + + assert {:ok, message} = Generic.new(json) + assert is_binary(message.subtype) + end + + test "handles missing optional fields" do + json = %{"type" => "system", "subtype" => "thinking_tokens"} + + assert {:ok, message} = Generic.new(json) + assert message.session_id == nil + assert message.uuid == nil + assert message.data == %{} + end + + test "returns error for a non-binary subtype" do + json = %{"type" => "system", "subtype" => 123} + assert {:error, :invalid_message_type} = Generic.new(json) + end + + test "returns error for a non-system type" do + json = %{"type" => "assistant", "subtype" => "thinking_tokens"} + assert {:error, :invalid_message_type} = Generic.new(json) + end + end + + describe "generic?/1" do + test "returns true for a Generic struct" do + assert Generic.generic?(%Generic{type: :system, subtype: "thinking_tokens"}) == true + end + + test "returns false for other values" do + assert Generic.generic?(%{}) == false + assert Generic.generic?(nil) == false + assert Generic.generic?("string") == false + end + end + + describe "system-message family enrollment" do + setup do + {:ok, message} = + Generic.new(%{ + "type" => "system", + "subtype" => "thinking_tokens", + "session_id" => "session-abc" + }) + + %{message: message} + end + + test "SystemMessage.type?/1 recognizes a Generic message", %{message: message} do + assert SystemMessage.type?(message) == true + end + + test "Message.message?/1 recognizes a Generic message", %{message: message} do + assert Message.message?(message) == true + end + end +end