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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions lib/claude_code/cli/parser.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"} ->
Expand Down Expand Up @@ -166,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
Expand Down
3 changes: 3 additions & 0 deletions lib/claude_code/message/system_message.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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
94 changes: 94 additions & 0 deletions lib/claude_code/message/system_message/generic.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
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{
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
{: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
24 changes: 22 additions & 2 deletions test/claude_code/cli/parser_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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
Expand Down
87 changes: 87 additions & 0 deletions test/claude_code/message/system_message/generic_test.exs
Original file line number Diff line number Diff line change
@@ -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
Loading