From 003679699285a075ad709f097835884e1a90c2d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcin=20Jagu=C5=9B?= Date: Wed, 30 Sep 2026 15:48:29 +0200 Subject: [PATCH] Migrate to Jackson 3 --- claude-code-sdk/pom.xml | 29 +- .../agent/sdk/DefaultClaudeAsyncClient.java | 2 +- .../agent/sdk/DefaultClaudeSyncClient.java | 2 +- .../agent/sdk/mcp/McpMessageHandler.java | 4 +- .../claude/agent/sdk/mcp/McpServerConfig.java | 4 +- .../sdk/parsing/ControlMessageParser.java | 32 +- .../agent/sdk/parsing/JsonResultParser.java | 22 +- .../agent/sdk/parsing/MessageParser.java | 35 +- .../sdk/transport/StreamingTransport.java | 12 +- .../claude/agent/sdk/types/ContentBlock.java | 2 +- .../claude/agent/sdk/types/Message.java | 2 +- .../claude/agent/sdk/types/ResultMessage.java | 4 +- .../sdk/types/control/ControlResponse.java | 2 +- .../agent/sdk/types/control/HookInput.java | 4 +- .../sdk/NoPromptConnectRegressionTest.java | 8 +- .../agent/sdk/hooks/HookIntegrationIT.java | 2 +- .../agent/sdk/mcp/McpServerConfigTest.java | 6 +- .../sdk/parsing/ControlMessageParserTest.java | 27 ++ .../agent/sdk/parsing/MessageParserTest.java | 1 + .../sdk/transport/CLIFlagParityTest.java | 3 + .../2026-09-30-jackson3-migration-design.md | 333 ++++++++++++++++++ pom.xml | 26 +- scripts/standalone-consumer-gate.sh | 37 +- 23 files changed, 476 insertions(+), 123 deletions(-) create mode 100644 docs/superpowers/specs/2026-09-30-jackson3-migration-design.md diff --git a/claude-code-sdk/pom.xml b/claude-code-sdk/pom.xml index d5134aa9..147d139d 100644 --- a/claude-code-sdk/pom.xml +++ b/claude-code-sdk/pom.xml @@ -7,7 +7,7 @@ io.github.markpollack claude-agent-sdk-parent - 1.8.0-SNAPSHOT + 2.0.0-SNAPSHOT ../pom.xml @@ -31,15 +31,7 @@ zt-exec - - - com.fasterxml.jackson.core - jackson-core - - - com.fasterxml.jackson.core - jackson-databind - + com.fasterxml.jackson.core jackson-annotations @@ -63,16 +55,13 @@ mcp - + tools.jackson.core jackson-core diff --git a/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/DefaultClaudeAsyncClient.java b/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/DefaultClaudeAsyncClient.java index 14bed906..e6d33993 100644 --- a/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/DefaultClaudeAsyncClient.java +++ b/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/DefaultClaudeAsyncClient.java @@ -16,7 +16,6 @@ package io.github.markpollack.claude.agent.sdk; -import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import io.github.markpollack.claude.agent.sdk.exceptions.TransportException; @@ -39,6 +38,7 @@ import io.github.markpollack.claude.agent.sdk.permission.PermissionResult; import io.github.markpollack.claude.agent.sdk.permission.ToolPermissionCallback; import io.github.markpollack.claude.agent.sdk.permission.ToolPermissionContext; +import tools.jackson.databind.ObjectMapper; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; diff --git a/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/DefaultClaudeSyncClient.java b/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/DefaultClaudeSyncClient.java index 00f7c5a2..4723e3be 100644 --- a/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/DefaultClaudeSyncClient.java +++ b/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/DefaultClaudeSyncClient.java @@ -16,7 +16,6 @@ package io.github.markpollack.claude.agent.sdk; -import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import io.github.markpollack.claude.agent.sdk.exceptions.TransportException; @@ -43,6 +42,7 @@ import io.github.markpollack.claude.agent.sdk.permission.PermissionResult; import io.github.markpollack.claude.agent.sdk.permission.ToolPermissionCallback; import io.github.markpollack.claude.agent.sdk.permission.ToolPermissionContext; +import tools.jackson.databind.ObjectMapper; import reactor.core.publisher.Mono; import reactor.core.publisher.MonoSink; diff --git a/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/mcp/McpMessageHandler.java b/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/mcp/McpMessageHandler.java index 2e279e20..708b456d 100644 --- a/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/mcp/McpMessageHandler.java +++ b/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/mcp/McpMessageHandler.java @@ -16,12 +16,12 @@ package io.github.markpollack.claude.agent.sdk.mcp; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; import io.modelcontextprotocol.server.McpSyncServer; import io.modelcontextprotocol.spec.McpSchema; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; import java.util.LinkedHashMap; import java.util.List; diff --git a/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/mcp/McpServerConfig.java b/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/mcp/McpServerConfig.java index 74fdbcce..d0cad0dc 100644 --- a/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/mcp/McpServerConfig.java +++ b/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/mcp/McpServerConfig.java @@ -33,8 +33,8 @@ * In-process SDK servers are managed by the Java SDK and communicate via the mcp_message * control protocol. */ -@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type", defaultImpl = McpServerConfig.McpStdioServerConfig.class, - visible = true) +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type", + defaultImpl = McpServerConfig.McpStdioServerConfig.class, visible = true) @JsonSubTypes({ @JsonSubTypes.Type(value = McpServerConfig.McpStdioServerConfig.class, name = "stdio"), @JsonSubTypes.Type(value = McpServerConfig.McpSseServerConfig.class, name = "sse"), @JsonSubTypes.Type(value = McpServerConfig.McpHttpServerConfig.class, name = "http"), diff --git a/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/parsing/ControlMessageParser.java b/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/parsing/ControlMessageParser.java index f267330e..0f3307f0 100644 --- a/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/parsing/ControlMessageParser.java +++ b/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/parsing/ControlMessageParser.java @@ -16,10 +16,6 @@ package io.github.markpollack.claude.agent.sdk.parsing; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import io.github.markpollack.claude.agent.sdk.exceptions.MessageParseException; @@ -27,6 +23,10 @@ import io.github.markpollack.claude.agent.sdk.types.RateLimitEvent; import io.github.markpollack.claude.agent.sdk.types.control.ControlRequest; import io.github.markpollack.claude.agent.sdk.types.control.ControlResponse; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; /** * Parser for Claude CLI bidirectional control protocol messages. This parser handles both @@ -80,7 +80,7 @@ public ControlMessageParser() { * @param maxBufferSize maximum message size in bytes (for buffer overflow protection) */ public ControlMessageParser(int maxBufferSize) { - this.objectMapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + this.objectMapper = new ObjectMapper(); this.messageParser = new MessageParser(); this.maxBufferSize = maxBufferSize > 0 ? maxBufferSize : DEFAULT_MAX_BUFFER_SIZE; } @@ -99,7 +99,7 @@ public ControlMessageParser(ObjectMapper objectMapper) { * @param maxBufferSize maximum message size in bytes (for buffer overflow protection) */ public ControlMessageParser(ObjectMapper objectMapper, int maxBufferSize) { - this.objectMapper = objectMapper.copy().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + this.objectMapper = objectMapper.rebuild().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).build(); this.messageParser = new MessageParser(); this.maxBufferSize = maxBufferSize > 0 ? maxBufferSize : DEFAULT_MAX_BUFFER_SIZE; } @@ -127,7 +127,7 @@ public ParsedMessage parse(String json) throws MessageParseException { JsonNode root = objectMapper.readTree(json); return parseFromNode(root, json); } - catch (JsonProcessingException e) { + catch (JacksonException e) { throw MessageParseException.jsonDecodeError(json, e); } } @@ -149,11 +149,11 @@ public int getMaxBufferSize() { */ public ParsedMessage parseFromNode(JsonNode node, String originalJson) throws MessageParseException { JsonNode typeNode = node.get("type"); - if (typeNode == null || !typeNode.isTextual()) { + if (typeNode == null || !typeNode.isString()) { throw new MessageParseException("Missing or invalid 'type' field in message"); } - String type = typeNode.asText(); + String type = typeNode.asString(); if (TYPE_CONTROL_REQUEST.equals(type)) { return parseControlRequest(node, originalJson); @@ -185,7 +185,7 @@ private ParsedMessage parseControlRequest(JsonNode node, String originalJson) th return ParsedMessage.Control.of(request); } - catch (JsonProcessingException e) { + catch (JacksonException e) { throw new MessageParseException("Failed to parse control request: " + e.getMessage(), e); } } @@ -205,7 +205,7 @@ private ParsedMessage parseControlResponse(JsonNode node, String originalJson) t return ParsedMessage.ControlResponseMessage.of(response); } - catch (JsonProcessingException e) { + catch (JacksonException e) { throw new MessageParseException("Failed to parse control response: " + e.getMessage(), e); } } @@ -222,7 +222,7 @@ private ParsedMessage parseRateLimitEvent(JsonNode node) throws MessageParseExce event.rateLimitInfo() != null ? event.rateLimitInfo().resetsAt() : 0); return ParsedMessage.RateLimitEventMessage.of(event); } - catch (JsonProcessingException e) { + catch (JacksonException e) { throw new MessageParseException("Failed to parse rate_limit_event: " + e.getMessage(), e); } } @@ -255,9 +255,9 @@ public boolean isControlRequest(String json) { try { JsonNode root = objectMapper.readTree(json); JsonNode typeNode = root.get("type"); - return typeNode != null && TYPE_CONTROL_REQUEST.equals(typeNode.asText()); + return typeNode != null && TYPE_CONTROL_REQUEST.equals(typeNode.asString()); } - catch (JsonProcessingException e) { + catch (JacksonException e) { return false; } } @@ -276,9 +276,9 @@ public String extractRequestId(String json) { try { JsonNode root = objectMapper.readTree(json); JsonNode requestIdNode = root.get("request_id"); - return requestIdNode != null && requestIdNode.isTextual() ? requestIdNode.asText() : null; + return requestIdNode != null && requestIdNode.isString() ? requestIdNode.asString() : null; } - catch (JsonProcessingException e) { + catch (JacksonException e) { return null; } } diff --git a/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/parsing/JsonResultParser.java b/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/parsing/JsonResultParser.java index 4e130c4d..80f79506 100644 --- a/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/parsing/JsonResultParser.java +++ b/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/parsing/JsonResultParser.java @@ -18,11 +18,11 @@ import io.github.markpollack.claude.agent.sdk.exceptions.MessageParseException; import io.github.markpollack.claude.agent.sdk.types.ResultMessage; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; import java.util.HashMap; import java.util.Map; @@ -84,7 +84,7 @@ public ResultMessage parseJsonResult(String json) throws MessageParseException { JsonNode root = objectMapper.readTree(json); return parseResultFromNode(root); } - catch (JsonProcessingException e) { + catch (JacksonException e) { throw MessageParseException.jsonDecodeError(json, e); } } @@ -152,7 +152,7 @@ private Map parseUsageMap(JsonNode usageNode) { } // Parse all usage fields - usageNode.fields().forEachRemaining(entry -> { + usageNode.properties().forEach(entry -> { String key = entry.getKey(); JsonNode value = entry.getValue(); @@ -162,19 +162,19 @@ private Map parseUsageMap(JsonNode usageNode) { else if (value.isDouble()) { usage.put(key, value.asDouble()); } - else if (value.isTextual()) { - usage.put(key, value.asText()); + else if (value.isString()) { + usage.put(key, value.asString()); } else if (value.isObject()) { // Handle nested objects like server_tool_use Map nestedMap = new HashMap<>(); - value.fields().forEachRemaining(nestedEntry -> { + value.properties().forEach(nestedEntry -> { JsonNode nestedValue = nestedEntry.getValue(); if (nestedValue.isInt()) { nestedMap.put(nestedEntry.getKey(), nestedValue.asInt()); } - else if (nestedValue.isTextual()) { - nestedMap.put(nestedEntry.getKey(), nestedValue.asText()); + else if (nestedValue.isString()) { + nestedMap.put(nestedEntry.getKey(), nestedValue.asString()); } else { nestedMap.put(nestedEntry.getKey(), nestedValue.toString()); @@ -192,7 +192,7 @@ else if (nestedValue.isTextual()) { private String getStringField(JsonNode node, String fieldName) { JsonNode field = node.get(fieldName); - return (field != null && !field.isNull()) ? field.asText() : null; + return (field != null && !field.isNull()) ? field.asString() : null; } private int getIntField(JsonNode node, String fieldName, int defaultValue) { diff --git a/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/parsing/MessageParser.java b/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/parsing/MessageParser.java index 82023a1d..ec265d4b 100644 --- a/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/parsing/MessageParser.java +++ b/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/parsing/MessageParser.java @@ -18,11 +18,11 @@ import io.github.markpollack.claude.agent.sdk.exceptions.MessageParseException; import io.github.markpollack.claude.agent.sdk.types.*; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; import java.util.ArrayList; import java.util.HashMap; @@ -51,7 +51,7 @@ public Message parseMessage(String json) throws MessageParseException { JsonNode root = objectMapper.readTree(json); return parseMessageFromNode(root); } - catch (JsonProcessingException e) { + catch (JacksonException e) { throw MessageParseException.jsonDecodeError(json, e); } } @@ -60,6 +60,15 @@ public Message parseMessage(String json) throws MessageParseException { * Parses a JsonNode into a Message object. */ public Message parseMessageFromNode(JsonNode node) throws MessageParseException { + try { + return tryParseMessageFromNode(node); + } + catch (JacksonException e) { + throw new MessageParseException("Unexpected error when reading json fields: " + e.getMessage(), e); + } + } + + private Message tryParseMessageFromNode(JsonNode node) throws MessageParseException { String type = getStringField(node, "type"); if (type == null) { throw new MessageParseException("Missing 'type' field in message"); @@ -91,8 +100,8 @@ private UserMessage parseUserMessage(JsonNode node) throws MessageParseException throw new MessageParseException("Missing 'content' field in user message"); } - if (contentNode.isTextual()) { - return UserMessage.of(contentNode.asText()); + if (contentNode.isString()) { + return UserMessage.of(contentNode.asString()); } else if (contentNode.isArray()) { List blocks = parseContentBlocks(contentNode); @@ -222,8 +231,8 @@ private ToolResultBlock parseToolResultBlock(JsonNode node) throws MessageParseE JsonNode contentNode = node.get("content"); Object content = null; if (contentNode != null) { - if (contentNode.isTextual()) { - content = contentNode.asText(); + if (contentNode.isString()) { + content = contentNode.asString(); } else if (contentNode.isArray()) { content = parseDataList(contentNode); @@ -246,7 +255,7 @@ else if (content instanceof List) { private Map parseDataMap(JsonNode node) { Map map = new HashMap<>(); - node.fields().forEachRemaining(entry -> { + node.properties().forEach(entry -> { map.put(entry.getKey(), parseJsonValue(entry.getValue())); }); return map; @@ -261,8 +270,8 @@ private List parseDataList(JsonNode node) { } private Object parseJsonValue(JsonNode node) { - if (node.isTextual()) { - return node.asText(); + if (node.isString()) { + return node.asString(); } else if (node.isNumber()) { return node.isInt() ? node.asInt() : node.asDouble(); @@ -288,7 +297,7 @@ private Map parseUsageMap(JsonNode node) { // Utility methods for safe field extraction private String getStringField(JsonNode node, String fieldName) { JsonNode field = node.get(fieldName); - return field != null && field.isTextual() ? field.asText() : null; + return field != null && field.isString() ? field.asString() : null; } private int getIntField(JsonNode node, String fieldName, int defaultValue) { @@ -311,4 +320,4 @@ private Double getDoubleField(JsonNode node, String fieldName) { return field != null && field.isNumber() ? field.asDouble() : null; } -} \ No newline at end of file +} diff --git a/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/transport/StreamingTransport.java b/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/transport/StreamingTransport.java index 24195d98..947892fe 100644 --- a/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/transport/StreamingTransport.java +++ b/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/transport/StreamingTransport.java @@ -16,8 +16,6 @@ package io.github.markpollack.claude.agent.sdk.transport; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import io.github.markpollack.claude.agent.sdk.config.ClaudeCliDiscovery; @@ -37,6 +35,8 @@ import reactor.core.publisher.Sinks; import reactor.core.scheduler.Scheduler; import reactor.core.scheduler.Schedulers; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; import java.io.*; import java.nio.charset.StandardCharsets; @@ -534,7 +534,7 @@ List buildStreamingCommand(CLIOptions options) { command.add("--json-schema"); command.add(schemaJson); } - catch (JsonProcessingException e) { + catch (JacksonException e) { logger.warn("Failed to serialize JSON schema, skipping --json-schema flag", e); } } @@ -552,7 +552,7 @@ List buildStreamingCommand(CLIOptions options) { logger.debug("Wrote MCP config to temp file: {}", this.mcpConfigFile); } } - catch (IOException e) { + catch (IOException | JacksonException e) { logger.warn("Failed to write MCP config file, skipping --mcp-config flag", e); } } @@ -931,7 +931,7 @@ public void sendUserMessage(String content, String sid) throws ClaudeSDKExceptio throw new TransportException("Failed to queue user message: " + result); } } - catch (IOException e) { + catch (JacksonException e) { throw new TransportException("Failed to serialize user message", e); } } @@ -953,7 +953,7 @@ public void sendResponse(ControlResponse response) throws ClaudeSDKException { throw new TransportException("Failed to queue control response: " + result); } } - catch (IOException e) { + catch (JacksonException e) { throw new TransportException("Failed to serialize control response", e); } } diff --git a/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/types/ContentBlock.java b/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/types/ContentBlock.java index 026e18ed..65afae46 100644 --- a/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/types/ContentBlock.java +++ b/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/types/ContentBlock.java @@ -23,7 +23,7 @@ * Base interface for content blocks in Claude messages. Corresponds to ContentBlock union * type in Python SDK. */ -@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type") @JsonSubTypes({ @JsonSubTypes.Type(value = TextBlock.class, name = "text"), @JsonSubTypes.Type(value = ToolUseBlock.class, name = "tool_use"), @JsonSubTypes.Type(value = ToolResultBlock.class, name = "tool_result"), diff --git a/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/types/Message.java b/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/types/Message.java index e041da3c..4a259edb 100644 --- a/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/types/Message.java +++ b/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/types/Message.java @@ -22,7 +22,7 @@ /** * Base interface for all message types. Corresponds to Message union type in Python SDK. */ -@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type") @JsonSubTypes({ @JsonSubTypes.Type(value = UserMessage.class, name = "user"), @JsonSubTypes.Type(value = AssistantMessage.class, name = "assistant"), @JsonSubTypes.Type(value = SystemMessage.class, name = "system"), diff --git a/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/types/ResultMessage.java b/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/types/ResultMessage.java index 606495df..8268e4ce 100644 --- a/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/types/ResultMessage.java +++ b/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/types/ResultMessage.java @@ -17,7 +17,7 @@ package io.github.markpollack.claude.agent.sdk.types; import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import java.util.Map; @@ -235,4 +235,4 @@ public ResultMessage build() { } } -} \ No newline at end of file +} diff --git a/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/types/control/ControlResponse.java b/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/types/control/ControlResponse.java index d1328441..b89b3b10 100644 --- a/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/types/control/ControlResponse.java +++ b/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/types/control/ControlResponse.java @@ -47,7 +47,7 @@ public static ControlResponse error(String requestId, String errorMessage) { /** * Sealed interface for response payload types. */ - @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "subtype") + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "subtype") @JsonSubTypes({ @JsonSubTypes.Type(value = SuccessPayload.class, name = "success"), @JsonSubTypes.Type(value = ErrorPayload.class, name = "error") }) public sealed interface ResponsePayload permits SuccessPayload, ErrorPayload { diff --git a/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/types/control/HookInput.java b/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/types/control/HookInput.java index a1a2a616..68ef9f46 100644 --- a/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/types/control/HookInput.java +++ b/claude-code-sdk/src/main/java/io/github/markpollack/claude/agent/sdk/types/control/HookInput.java @@ -28,8 +28,8 @@ * Base sealed interface for all hook input types. Each hook event receives a specific * input type with relevant data. */ -@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "hook_event_name", - visible = true) +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, + property = "hook_event_name", visible = true) @JsonSubTypes({ @JsonSubTypes.Type(value = HookInput.PreToolUseInput.class, name = "PreToolUse"), @JsonSubTypes.Type(value = HookInput.PostToolUseInput.class, name = "PostToolUse"), @JsonSubTypes.Type(value = HookInput.UserPromptSubmitInput.class, name = "UserPromptSubmit"), diff --git a/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/NoPromptConnectRegressionTest.java b/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/NoPromptConnectRegressionTest.java index a0c2f94a..4e07810a 100644 --- a/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/NoPromptConnectRegressionTest.java +++ b/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/NoPromptConnectRegressionTest.java @@ -25,8 +25,6 @@ import java.util.ArrayList; import java.util.List; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -35,6 +33,8 @@ import org.junit.jupiter.api.condition.OS; import org.junit.jupiter.api.io.TempDir; import reactor.core.Disposable; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; import static org.assertj.core.api.Assertions.assertThat; @@ -258,8 +258,8 @@ private List userMessages() throws IOException { List contents = new ArrayList<>(); for (String line : recordedLines()) { JsonNode node = MAPPER.readTree(line); - if ("user".equals(node.path("type").asText())) { - contents.add(node.path("message").path("content").asText()); + if ("user".equals(node.path("type").asString())) { + contents.add(node.path("message").path("content").asString()); } } return contents; diff --git a/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/hooks/HookIntegrationIT.java b/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/hooks/HookIntegrationIT.java index 21b762c9..cabc20f1 100644 --- a/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/hooks/HookIntegrationIT.java +++ b/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/hooks/HookIntegrationIT.java @@ -16,7 +16,6 @@ package io.github.markpollack.claude.agent.sdk.hooks; -import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; @@ -30,6 +29,7 @@ import io.github.markpollack.claude.agent.sdk.types.control.ControlResponse; import io.github.markpollack.claude.agent.sdk.types.control.HookInput; import io.github.markpollack.claude.agent.sdk.types.control.HookOutput; +import tools.jackson.databind.ObjectMapper; import java.time.Duration; import java.util.List; diff --git a/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/mcp/McpServerConfigTest.java b/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/mcp/McpServerConfigTest.java index 5a60ca85..d5b3227f 100644 --- a/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/mcp/McpServerConfigTest.java +++ b/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/mcp/McpServerConfigTest.java @@ -16,9 +16,9 @@ package io.github.markpollack.claude.agent.sdk.mcp; -import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import tools.jackson.databind.ObjectMapper; import java.util.List; import java.util.Map; @@ -44,6 +44,7 @@ void stdioConfigSerialization() throws Exception { String json = objectMapper.writeValueAsString(config); + assertThat(json).containsOnlyOnce("\"type\""); assertThat(json).contains("\"type\":\"stdio\""); assertThat(json).contains("\"command\":\"npx\""); assertThat(json).contains("\"args\""); @@ -75,6 +76,7 @@ void sseConfigSerialization() throws Exception { String json = objectMapper.writeValueAsString(config); + assertThat(json).containsOnlyOnce("\"type\""); assertThat(json).contains("\"type\":\"sse\""); assertThat(json).contains("\"url\":\"http://localhost:8080/sse\""); assertThat(json).contains("\"headers\""); @@ -94,6 +96,7 @@ void httpConfigSerialization() throws Exception { String json = objectMapper.writeValueAsString(config); + assertThat(json).containsOnlyOnce("\"type\""); assertThat(json).contains("\"type\":\"http\""); assertThat(json).contains("\"url\":\"http://localhost:8080/mcp\""); @@ -111,6 +114,7 @@ void sdkConfigDoesNotSerializeInstance() throws Exception { String json = objectMapper.writeValueAsString(config); + assertThat(json).containsOnlyOnce("\"type\""); assertThat(json).contains("\"type\":\"sdk\""); assertThat(json).contains("\"name\":\"calculator\""); // Instance should not be serialized diff --git a/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/parsing/ControlMessageParserTest.java b/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/parsing/ControlMessageParserTest.java index 0f69a745..3e9b148e 100644 --- a/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/parsing/ControlMessageParserTest.java +++ b/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/parsing/ControlMessageParserTest.java @@ -27,6 +27,8 @@ import io.github.markpollack.claude.agent.sdk.types.SystemMessage; import io.github.markpollack.claude.agent.sdk.types.UserMessage; import io.github.markpollack.claude.agent.sdk.types.control.ControlRequest; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.json.JsonMapper; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -92,6 +94,31 @@ void parseHookCallbackRequest() throws Exception { assertThat(hookCallback.input()).containsEntry("tool_name", "Bash"); } + @Test + @DisplayName("Should ignore unknown fields even with a strict caller-supplied mapper") + void parseWithStrictCallerMapper() throws Exception { + var strict = JsonMapper.builder().enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).build(); + var strictParser = new ControlMessageParser(strict); + String json = """ + { + "type": "control_request", + "request_id": "req_strict", + "unknown_top": 1, + "request": { + "subtype": "hook_callback", + "callback_id": "hook_0", + "unknown_inner": "x", + "input": {"tool_name": "Bash"} + } + } + """; + + ParsedMessage result = strictParser.parse(json); + + assertThat(result.isControlRequest()).isTrue(); + assertThat(result.asControlRequest().requestId()).isEqualTo("req_strict"); + } + @Test @DisplayName("Should parse can_use_tool control request") void parseCanUseToolRequest() throws Exception { diff --git a/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/parsing/MessageParserTest.java b/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/parsing/MessageParserTest.java index a4895368..20c3e15a 100644 --- a/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/parsing/MessageParserTest.java +++ b/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/parsing/MessageParserTest.java @@ -25,6 +25,7 @@ import io.github.markpollack.claude.agent.sdk.types.Message; import io.github.markpollack.claude.agent.sdk.types.ResultMessage; import io.github.markpollack.claude.agent.sdk.types.UserMessage; +import tools.jackson.databind.ObjectMapper; import java.util.List; import java.util.Map; diff --git a/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/transport/CLIFlagParityTest.java b/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/transport/CLIFlagParityTest.java index 9504a1bc..0da79d31 100644 --- a/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/transport/CLIFlagParityTest.java +++ b/claude-code-sdk/src/test/java/io/github/markpollack/claude/agent/sdk/transport/CLIFlagParityTest.java @@ -23,10 +23,13 @@ import io.github.markpollack.claude.agent.sdk.config.PermissionMode; import io.github.markpollack.claude.agent.sdk.config.PluginConfig; import io.github.markpollack.claude.agent.sdk.mcp.McpServerConfig; +import tools.jackson.databind.ObjectMapper; +import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; diff --git a/docs/superpowers/specs/2026-09-30-jackson3-migration-design.md b/docs/superpowers/specs/2026-09-30-jackson3-migration-design.md new file mode 100644 index 00000000..83a2cf17 --- /dev/null +++ b/docs/superpowers/specs/2026-09-30-jackson3-migration-design.md @@ -0,0 +1,333 @@ +# Jackson 3 Migration — Design + +- **Date:** 2026-09-30 +- **Branch:** `jackson3` +- **Target version:** `2.0.0` (development version `2.0.0-SNAPSHOT`) + +## Goal + +Move `claude-code-sdk` completely off Jackson 2 (`com.fasterxml.jackson.core:jackson-core` / +`jackson-databind`) and onto Jackson 3 (`tools.jackson`). + +Today every consumer receives two JSON stacks: Jackson 2 for the SDK's own parsing and Jackson +3 because `mcp` 2.0.1 requires it. Two stacks means two CVE floors to police and two sets of +jars on every classpath. After this change there is one. + +"No Jackson 2" means no `com.fasterxml.jackson.core:jackson-core` or `jackson-databind`. +`com.fasterxml.jackson.core:jackson-annotations` **stays**: Jackson 3 deliberately reuses the +2.x annotations artifact and the `com.fasterxml.jackson.annotation` package (the +`tools.jackson` 3.2.2 BOM pins it at `2.22`). + +## Decisions + +| Question | Decision | +|---|---| +| Public API exposes Jackson 2 types | Swap to `tools.jackson` equivalents in place; signal the break with a major version bump to **2.0.0**. No Jackson 2 overloads are kept. | +| Mapper defaults | Adopt **Jackson 3 defaults** as-is. Every 2→3 default change is catalogued in Appendix A for later review. | +| Approach | **In-place swap**: each class keeps its own mapper; imports, types, and method names are renamed mechanically. No shared-mapper class, no OpenRewrite. | + +## Non-goals + +- No shared/central mapper. (Revisit if the Appendix A review leads to overriding any default — + a single knob then earns its keep.) +- No change to wire behavior beyond what Appendix A documents. +- No `release-notes/2.0.0.md` now; release notes are written after the release. The + [Breaking changes](#breaking-changes-release-notes-input) section is its input. +- Historical release notes (`release-notes/1.5.0.md`) are not edited. + +## 1. Build and dependencies + +**Parent `pom.xml`** +- Remove the `jackson.version` property and the `com.fasterxml.jackson:jackson-bom` import. +- Keep the `tools.jackson:jackson-bom` import (`jackson3.version` = 3.2.2); it manages + `jackson-annotations` too. +- Bump `` to `2.0.0-SNAPSHOT`. + +**`claude-code-sdk/pom.xml`** +- Update the parent reference to `2.0.0-SNAPSHOT`. +- Remove `com.fasterxml.jackson.core:jackson-core` and `jackson-databind`. +- Keep `com.fasterxml.jackson.core:jackson-annotations` (the SDK's source uses the + annotations directly; version now comes from the Jackson 3 BOM). +- Keep the direct `tools.jackson.core:jackson-core`, `jackson-databind`, and + `tools.jackson.dataformat:jackson-dataformat-yaml` declarations. Rewrite their comment + block: the SDK now imports `tools.jackson` itself, and the declarations still exist so the + version floor survives POM flattening. + +**`scripts/standalone-consumer-gate.sh`** +- Remove `JACKSON2_FLOOR` and the 2.x floor branch. +- Any `jackson-core-2.*` or `jackson-databind-2.*` jar in the resolved consumer closure is a + **fail** ("Jackson 2 must not reach consumers"). `jackson-annotations-2.*` is allowed. +- Keep the Jackson 3 floor check and the single-minor alignment check. +- Rename the smoke's "Jackson 2 path" comment to "SDK parsing path"; update the section-7 + header comment accordingly. + +**Docs** +- Update the Jackson-floor wording in `.github/workflows/ci.yml` (gate job comment) and + `RELEASING.md` so it no longer implies a Jackson 2 floor. + +## 2. Code changes + +Scope: 28 main-source files import Jackson, but 20 of them use only annotations (unchanged). +The 8 that change are `DefaultClaudeSyncClient`, `DefaultClaudeAsyncClient`, `ResultMessage`, +`StreamingTransport`, `McpMessageHandler`, `MessageParser`, `ControlMessageParser`, and +`JsonResultParser`. There are also 3 test files. Separately, 5 annotation-only files get a +one-attribute `@JsonTypeInfo` change: `Message`, `ContentBlock`, `HookInput`, `ControlResponse`, +and `McpServerConfig` (see Polymorphic type ids). + +### Mechanical renames + +| Jackson 2 | Jackson 3 | +|---|---| +| `com.fasterxml.jackson.databind.ObjectMapper` / `JsonNode` / `DeserializationFeature` | `tools.jackson.databind.ObjectMapper` / `JsonNode` / `DeserializationFeature` | +| `com.fasterxml.jackson.core.type.TypeReference` | `tools.jackson.core.type.TypeReference` | +| `com.fasterxml.jackson.core.JsonProcessingException` | `tools.jackson.core.JacksonException` (unchecked) | +| `node.isTextual()` / `node.asText()` | `node.isString()` / `node.asString()` | +| `node.fields().forEachRemaining(...)` | `node.properties().forEach(...)` (`fields()` no longer exists) | +| `mapper.copy().configure(F, false)` | `mapper.rebuild().disable(F).build()` | +| `com.fasterxml.jackson.annotation.*` | **unchanged** | + +### Public API changes + +Same names, same parameter order; only the Jackson type's package changes. + +| Member | Before | After | +|---|---|---| +| `ResultMessage.getStructuredOutputAs(Class, ObjectMapper)` | `com.fasterxml.jackson.databind.ObjectMapper` | `tools.jackson.databind.ObjectMapper` | +| `McpMessageHandler(ObjectMapper)` | same | same | +| `ControlMessageParser(ObjectMapper)` | same | same | +| `ControlMessageParser(ObjectMapper, int)` | same | same | +| `ControlMessageParser.parseFromNode(JsonNode, String)` | `com.fasterxml.jackson.databind.JsonNode` | `tools.jackson.databind.JsonNode` | +| `MessageParser.parseMessageFromNode(JsonNode)` | same | same | + +### Error handling + +Jackson 3 silently changes two contracts the SDK relies on. Both are preserved. + +1. **Parsers keep throwing `MessageParseException`.** + - Every `catch (JsonProcessingException e)` becomes `catch (JacksonException e)`. + `JacksonException` also covers `JsonNodeException`, which Jackson 3 accessors now throw + for values they cannot coerce (see Appendix A, JsonNode rows). + - The public node-level entry points `MessageParser.parseMessageFromNode(JsonNode)` and + `ControlMessageParser.parseFromNode(JsonNode, String)` also map `JacksonException` to + `MessageParseException`, so a direct caller never sees a raw Jackson exception. + - `isControlRequest` / `extractRequestId` keep returning `false` / `null` on any + `JacksonException`. +2. **`StreamingTransport` keeps its serialization-failure behavior.** Three + `catch (IOException e)` blocks around `writeValueAsString` only worked because Jackson 2's + `JsonProcessingException extends IOException`; Jackson 3's `JacksonException` does not. + - `sendUserMessage` and `sendResponse`: the compiler flags the now-dead `IOException` + catch. Replace with `catch (JacksonException e)`, still wrapping in `TransportException`. + - MCP config temp-file block: `Files` calls still throw `IOException`, so the compiler does + **not** flag it. Change to `catch (IOException | JacksonException e)` so a serialization + failure still logs and skips `--mcp-config` instead of aborting startup. + - `--json-schema` block: `catch (JsonProcessingException e)` becomes + `catch (JacksonException e)`; still logs and skips the flag. + +### Polymorphic type ids + +*(Amended 2026-09-30 after probing found this during planning.)* + +Five `@JsonTypeInfo` hierarchies declare their type-id property with `As.PROPERTY`, while their +subtypes also expose a property of the same name. Jackson 2 serialized them with a duplicate key +(e.g. `{"subtype":"success","subtype":"success",...}`). Jackson 3 refuses and throws +`InvalidDefinitionException: Conflict between type id property ... and bean property with same +name` at serialization time. Two of these hierarchies are on the SDK's own wire path: + +| Hierarchy | Type-id property | SDK serializes it? | +|---|---|---| +| `ControlResponse.ResponsePayload` | `subtype` | **Yes**: `StreamingTransport.sendResponse`. Without a fix, every permission, hook, and MCP control response fails. | +| `McpServerConfig` | `type` | **Yes**: `--mcp-config` temp file. Without a fix, every external MCP server silently disappears (the log-and-skip catch hides it). | +| `HookInput` | `hook_event_name` | No (deserialized only); consumers who serialize it break. | +| `Message` | `type` | No; consumers who serialize it break. | +| `ContentBlock` | `type` | No; consumers who serialize it break. | + +`ControlRequest.ControlRequestPayload` has no conflict and is unchanged. + +**Fix:** change `include = JsonTypeInfo.As.PROPERTY` to `include = JsonTypeInfo.As.EXISTING_PROPERTY` +on those five declarations (for `McpServerConfig`, add the attribute; it currently relies on the +`PROPERTY` default). Leave every other attribute unchanged (`HookInput` and `McpServerConfig` +keep `visible = true`; `McpServerConfig` keeps `defaultImpl`). A probe using Jackson 3 mix-ins +confirmed that all five serialize with a single key and deserialize exactly as they do under +Jackson 2. + +With `EXISTING_PROPERTY` the key's value comes from the record's own component or accessor +instead of the class. The SDK always populates it (`ControlResponse.success/error`, the +`McpServerConfig` convenience constructors). A caller who builds one of these records through +its canonical constructor with a `null` type gets no type key; Jackson 2 still wrote the +class-derived id. + +### Defaults + +All mappers use Jackson 3 defaults. `ControlMessageParser` keeps explicitly disabling +`FAIL_ON_UNKNOWN_PROPERTIES` on caller-supplied mappers (via `rebuild()`), because a caller may +have enabled it. The default-constructed mapper needs no configuration, since Jackson 3 already +defaults that feature to off. + +## 3. Testing and verification + +1. **Migrate test code**: `NoPromptConnectRegressionTest`, `McpServerConfigTest`, + `HookIntegrationIT` — same renames as main. +2. **New tests, written first (TDD):** + - **Parser error contract.** A wrong-typed field (e.g. `"num_turns": {}` on a result line, + `"type": {}`) must surface as `MessageParseException` from + `ControlMessageParser.parse`, `MessageParser.parseMessage`, + `JsonResultParser.parseJsonResult`, `MessageParser.parseMessageFromNode`, and + `ControlMessageParser.parseFromNode`. A naive rename fails the `JsonResultParser` and + node-level cases with `JsonNodeException`. + - **`--json-schema` serialization failure.** A schema value whose getter throws must yield a + command without `--json-schema` and no exception, exercised through + `StreamingTransport.buildStreamingCommand` (the seam `CLIFlagParityTest` uses). + - **MCP temp-file multi-catch:** not unit-tested. Once the type-id fix is in, the external + configs are records of strings and string maps and cannot realistically fail to + serialize; the catch is defensive and covered by review. + - **Polymorphic serialization** (all fail under a naive rename with + `InvalidDefinitionException`): + - `ControlResponse.success(...)` / `ControlResponse.error(...)` serialize with exactly one + `subtype` key carrying `"success"` / `"error"`. + - `HookInput`, `Message`, `ContentBlock`, and `McpServerConfig` serialize through their + base type with a single type key, and read back to the same subtype. The existing + `McpServerConfigTest` already covers `McpServerConfig`. + - `--mcp-config` content: `buildStreamingCommand` with a stdio and an http server writes a + temp file whose JSON tree equals the expected `{"mcpServers":{...}}`. This is the guard + the log-and-skip catch would otherwise defeat. +3. **Wire regression.** `WireFixtureTest` (real CLI 2.1.162 stdout lines) and the existing unit + suite pass unchanged. This is the evidence that Jackson 3 defaults do not change how real + traffic parses. A test failing only on JSON property order is fixed by comparing JSON trees, + not by pinning the new order. +4. **Verification commands** (all credential-free): + - `./mvnw verify` + - `./scripts/standalone-consumer-gate.sh` + - `./mvnw -pl claude-code-sdk dependency:tree` shows no + `com.fasterxml.jackson.core:jackson-core` / `jackson-databind` + - `grep -rE 'com\.fasterxml\.jackson\.(core|databind)' --include='*.java' claude-code-sdk/src` + returns nothing + - The paid live suite (`-Dfailsafe.excluded.groups=`) runs at release time per + `RELEASING.md`, not as part of this change. + +## Success criteria + +- No `com.fasterxml.jackson.core:jackson-core` or `jackson-databind` in the SDK's source, + POMs, or consumer runtime closure, enforced by the standalone consumer gate. +- `./mvnw verify` and the standalone consumer gate pass. +- Parser and transport error contracts unchanged (`MessageParseException`, + `TransportException`, log-and-skip for optional CLI flags). +- Appendix A delivered for the maintainer's later review of Jackson 3 defaults. + +## Breaking changes (release-notes input) + +- **Jackson 3.** The SDK now uses Jackson 3 (`tools.jackson`) exclusively; Jackson 2 + `jackson-core` / `jackson-databind` are no longer dependencies. `jackson-annotations` 2.x + remains, as Jackson 3 requires. +- **Public signatures now take Jackson 3 types:** + `ResultMessage.getStructuredOutputAs(Class, ObjectMapper)`, + `McpMessageHandler(ObjectMapper)`, `ControlMessageParser(ObjectMapper)`, + `ControlMessageParser(ObjectMapper, int)`, `ControlMessageParser.parseFromNode(JsonNode, String)`, + `MessageParser.parseMessageFromNode(JsonNode)`. Callers change imports from + `com.fasterxml.jackson.databind` to `tools.jackson.databind`. +- **Stricter parsing** (from Jackson 3 defaults): wrong-typed or out-of-range fields in parsed + messages, explicit JSON `null` for the four primitive fields listed in Appendix A, and + lines with trailing content after the JSON value now fail with `MessageParseException` + rather than being silently defaulted or truncated. +- **Exception type change.** `ResultMessage.getStructuredOutputAs` conversion failures now throw + `JacksonException` (unchecked) instead of `IllegalArgumentException`; an existing + `catch (IllegalArgumentException e)` still compiles but no longer catches them. +- **No more duplicate type keys.** Serialized `ControlResponse` payloads, `McpServerConfig`, + `HookInput`, `Message`, and `ContentBlock` now carry their type key (`subtype`, `type`, + `hook_event_name`) once instead of twice. Its value comes from the record itself, so a record + built with a `null` type now has no type key. + +## Appendix A — Jackson 2 → 3 default and behavior changes + +Compared: Jackson **2.22.2** (current) vs **3.2.2** (target). + +Sources: +- [JSTEP-2](https://github.com/FasterXML/jackson-future-ideas/wiki/JSTEP-2) (default changes) +- [JSTEP-3](https://github.com/FasterXML/jackson-future-ideas/wiki/JSTEP-3) (`JsonNode`) +- [Migration guide](https://github.com/FasterXML/jackson/blob/main/jackson3/MIGRATING_TO_JACKSON_3.md) +- [3.1](https://github.com/FasterXML/jackson/wiki/Jackson-Release-3.1) and [3.2](https://github.com/FasterXML/jackson/wiki/Jackson-Release-3.2) release notes + +Rows marked *probed* were confirmed by running both jar versions side by side in `jshell` on +2026-09-30. + +**Impact key:** **None** — no effect on this SDK. **Consumer** — no wire effect; affects only +consumers who serialize SDK types with their own mapper. **Change** — observable SDK behavior +change, accepted and handled. **Risk** — could bite if the CLI's output changes; watch it. + +### Streaming (jackson-core) + +| Setting | 2.x | 3.x | SDK impact | +|---|---|---|---| +| `StreamReadConstraints` max nesting depth *(probed)* | 1000 | 500 | **Risk (low).** A CLI line nested deeper than 500 levels now fails as `MessageParseException`. Real tool inputs/results are far shallower. | +| `StreamWriteConstraints` max nesting depth *(probed)* | 1000 | 500 | **Risk (low).** Writing a >500-deep structure (user JSON schema, MCP tool result) fails. | +| `StreamReadConstraints` max string length *(probed)* | 20,000,000 | 100,000,000 | **None.** Looser; the SDK already rejects lines above `maxBufferSize` (1 MB default) before Jackson sees them. | +| Other read constraints: number length 1000, name length 50000, document length and token count unlimited *(probed)* | — | same | **None.** | +| `StreamReadFeature.USE_FAST_DOUBLE_PARSER` | off | on | **None.** Same values, faster. | +| `StreamReadFeature.USE_FAST_BIG_NUMBER_PARSER` | off | on | **None.** | +| `TokenStreamFactory.Feature.INTERN_PROPERTY_NAMES` | on | off | **None.** Memory/performance only. | +| `JsonWriteFeature.ESCAPE_FORWARD_SLASHES` | off | off (planned change reverted in 3.0.0-rc7) | **None.** | +| `JsonReadFeature` / `JsonWriteFeature` (all others) | — | unchanged | **None.** | + +### `MapperFeature` + +| Setting | 2.x | 3.x | SDK impact | +|---|---|---|---| +| `SORT_PROPERTIES_ALPHABETICALLY` | off | on | **None on the wire.** Serialized POJO key order may change; JSON key order is not semantic to the CLI. Records keep declaration order for creator properties (`SORT_CREATOR_PROPERTIES_FIRST` stays on). Tests pinning exact JSON strings compare trees instead. | +| `SORT_CREATOR_PROPERTIES_BY_DECLARATION_ORDER` | off | removed; behaves as on | **None.** See above. | +| `DETECT_PARAMETER_NAMES` (parameter-names module built in) | module not registered | on | **None.** Every record component carries an explicit `@JsonProperty`. | +| `ALLOW_FINAL_FIELDS_AS_MUTATORS` | on | off | **None.** SDK types are records bound through constructors. | +| `USE_GETTERS_AS_SETTERS` | on | off | **None.** Records bound through constructors. | +| `DEFAULT_VIEW_INCLUSION` | on | off | **None.** No `@JsonView` in the SDK. | +| `FIX_FIELD_NAME_UPPER_CASE_PREFIX` | off | on | **None.** All wire names are explicit via `@JsonProperty`. | +| `USE_STD_BEAN_NAMING` | off | removed; behaves as on | **None.** Explicit names. | +| `AUTO_DETECT_CREATORS/FIELDS/GETTERS/IS_GETTERS/SETTERS` | features | removed (use `changeDefaultVisibility`) | **None.** Not configured. | +| `EXTERNAL_TYPE_ID_ALWAYS_VISIBLE` (3.2) | behaves as on | off | **None.** No polymorphic type uses `EXTERNAL_PROPERTY`. | +| `OVERRIDE_PUBLIC_ACCESS_MODIFIERS` | on | on (may flip in a later 3.x) | **None today.** | + +### `DeserializationFeature` / `EnumFeature` + +| Setting | 2.x | 3.x | SDK impact | +|---|---|---|---| +| `FAIL_ON_UNKNOWN_PROPERTIES` | on | off | **None.** Every typed read path already ignored unknown properties (`ControlMessageParser` disables it; `HookInput` records use `ignoreUnknown`). | +| `FAIL_ON_NULL_FOR_PRIMITIVES` (3.2: explicit `null` only; absent fields still default) | off | on | **Risk.** Explicit JSON `null` now fails binding for the four primitive components reached through Jackson binding: `RateLimitInfo.resetsAt` (`long`), `RateLimitInfo.isUsingOverage` (`boolean`), and `HookInput.StopInput` / `SubagentStopInput.stop_hook_active` (`boolean`). The `RateLimitInfo` fields fail as `MessageParseException`. `HookInput` is never bound by a parser, only in `handleHookCallback` via `convertValue` (`DefaultClaudeSyncClient.java:491`, `DefaultClaudeAsyncClient.java:669`), so an explicit `"stop_hook_active": null` raises `JacksonException`, which the client's `catch (Exception)` turns into `ControlResponse.error("Hook execution failed: ...")`: the user's hook does not run and the CLI receives an error response. `ResultMessage`/`Usage`/`Cost`/`Metadata` are built manually from nodes, not bound, so they are unaffected by this row. | +| `FAIL_ON_TRAILING_TOKENS` *(probed)* | off | on | **Change.** Applies to `readTree` as well. A line carrying content after its first JSON value (e.g. two concatenated objects) now fails with `MessageParseException`; 2.x silently parsed the first value. The CLI emits one object per line. In `RobustStreamParser` (`RobustStreamParser.java:149`), the `readTree` failure carries `rawInput`, which it treats as "incomplete, keep accumulating", so a line with trailing content poisons its buffer until the size limit (2.x only dropped the trailing value). `RobustStreamingProcessor` is not used by either client. | +| `EnumFeature.READ_ENUMS_USING_TO_STRING` | off | on | **None.** No enum is bound by Jackson on the wire; wire enums are strings. | +| `FAIL_ON_UNEXPECTED_VIEW_PROPERTIES` | off | off (planned change reverted) | **None.** | + +### `SerializationFeature` / `EnumFeature` / `DateTimeFeature` + +| Setting | 2.x | 3.x | SDK impact | +|---|---|---|---| +| `EnumFeature.WRITE_ENUMS_USING_TO_STRING` | off | on | **Consumer.** The SDK serializes no enums on the wire. A consumer serializing `PermissionMode` gets its CLI value (e.g. `acceptEdits`) instead of the constant name, because it overrides `toString()`. `ResultStatus`, `HookEvent`, and `OutputFormat` do not override it and are unchanged. | +| `FAIL_ON_EMPTY_BEANS` | on | off | **None.** The SDK serializes no property-less beans. | +| `FAIL_ON_ORDER_MAP_BY_INCOMPARABLE_KEY` | on | off | **None.** `ORDER_MAP_ENTRIES_BY_KEYS` not enabled. | +| `DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS` | on | off (ISO-8601 strings) | **None.** No date/time values on the wire. | +| `DateTimeFeature.WRITE_DURATIONS_AS_TIMESTAMPS` | on | off (ISO-8601 strings) | **Consumer.** `java.time` support is now built in: a consumer serializing `Metadata` gets `duration` / `apiDuration` as ISO-8601 strings from its `Duration` getters, where 2.x without the JSR-310 module failed. | +| `DateTimeFeature.ONE_BASED_MONTHS` | off | on | **None.** No `Month` values. | +| UTC rendering (`WRITE_UTC_AS_OFFSET`) | `+00` offset | trailing `Z` | **None.** | +| `java.time.Month` handled as date/time (numeric) rather than enum | enum | date/time | **None.** | + +### `JsonNode` (JSTEP-3) + +| Behavior | 2.x | 3.x | SDK impact | +|---|---|---|---| +| `asText()` on object/array *(probed)* | `""` | throws `JsonNodeException` | **Change, handled.** Surfaces as `MessageParseException` (section 2). | +| `asInt()` / `asDouble()` on non-numeric string, object, array *(probed)* | `0` / `0.0` | throws `JsonNodeException` | **Change, handled.** `JsonResultParser` getters (which only guard `null`) now fail the message instead of defaulting to `0`. `MessageParser` guards types first and is unaffected. | +| `asInt()` / `asDouble()` on boolean *(probed)* | `1`/`0`, `1.0`/`0.0` | throws `JsonNodeException` | **Change, handled.** Same mapping. | +| `asInt()` on a long beyond `int` range *(probed)* | silent overflow (`12345678901` → `-539222987`) | throws `JsonNodeException` | **Change, handled.** `MessageParser.getIntField` / `JsonResultParser.getIntField` fail the message instead of returning garbage. | +| `asBoolean()` on non-boolean string, object, array, or float *(probed)* | `false` | throws `JsonNodeException` | **Change, handled.** Same mapping. (`asBoolean()` on an int is `true`/`false` in both.) | +| `asText()` → `asString()` on JSON `null` *(probed)* | `"null"` | `""` | **None.** Every call site checks `isNull()`/`isString()` first; `isControlRequest` compares to `"control_request"`, which neither value matches. | +| `asInt()` / `asDouble()` / `asBoolean()` on JSON `null` *(probed)* | `0` / `0.0` / `false` | same | **None.** | +| `asXxx(defaultValue)` on `NullNode` (3.1, databind#5558) | returns coerced value | returns `defaultValue` | **None.** The SDK does not use default-arg variants. | +| `fields()`, `elements()` | present | removed → `properties()`, `values()` | Handled by the rename table. | +| `asText()`, `isTextual()`, `textValue()` | present | still present; 3.x names are `asString()`, `isString()`, `stringValue()` | Handled by the rename table (3.x names used). | +| `DecimalNode` via `JsonNodeFactory` | trailing zeros stripped | preserved | **None.** Floats parse to `DoubleNode` (`USE_BIG_DECIMAL_FOR_FLOATS` off). | + +### Exceptions and API shape + +| Behavior | 2.x | 3.x | SDK impact | +|---|---|---|---| +| Type-id property (`@JsonTypeInfo`, `As.PROPERTY`) with the same name as a bean property *(probed)* | writes the key twice | throws `InvalidDefinitionException` at serialization | **Change, handled.** Five hierarchies switch to `As.EXISTING_PROPERTY` (section 2, Polymorphic type ids). | +| Base exception | `JsonProcessingException`, checked, `extends IOException` | `JacksonException`, unchecked, `extends RuntimeException` | **Change, handled.** Section 2 error handling; the three `IOException` catches are the non-obvious part. | +| `ObjectMapper` mutability | mutable (`configure`, `copy`) | immutable (`builder()`, `rebuild()`) | Handled by the rename table. | +| Java baseline | 8 | 17 | **None.** SDK targets Java 21. | +| JDK8 / JSR-310 / parameter-names modules | separate | built in | **Consumer** (see `WRITE_DURATIONS_AS_TIMESTAMPS`). | diff --git a/pom.xml b/pom.xml index fb818f9a..d3bf3e88 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.markpollack claude-agent-sdk-parent - 1.8.0-SNAPSHOT + 2.0.0-SNAPSHOT Claude Agent SDK Parent @@ -57,8 +57,7 @@ 1.13.0 - 2.22.2 - 3.2.2 + 3.2.2 3.8.7 2.0.18 2.0.1 @@ -115,24 +114,15 @@ ${zt-exec.version} - - - com.fasterxml.jackson - jackson-bom - ${jackson.version} - pom - import - - - + tools.jackson jackson-bom - ${jackson3.version} + ${jackson.version} pom import diff --git a/scripts/standalone-consumer-gate.sh b/scripts/standalone-consumer-gate.sh index 8c1d96f8..a182859e 100755 --- a/scripts/standalone-consumer-gate.sh +++ b/scripts/standalone-consumer-gate.sh @@ -4,7 +4,7 @@ # # Why this exists # --------------- -# The parent POM's — including its Jackson BOM imports — does +# The parent POM's — including its Jackson BOM import — does # NOT survive POM flattening (flattenMode=ossrh). An ordinary consumer that depends on # claude-code-sdk without this project's parent and without any BOM therefore resolves # whatever the flattened POM declares plus whatever transitives Maven picks. Released @@ -35,11 +35,10 @@ cd "$REPO_ROOT" GROUP_ID="io.github.markpollack" ARTIFACT_ID="claude-code-sdk" -# Required floors. Both are consumer-visible: Jackson 2 is declared directly by the SDK, -# Jackson 3 arrives through mcp -> mcp-json-jackson3 and is declared directly so the -# floor travels in the flattened POM. -JACKSON2_FLOOR="2.21.6" -JACKSON3_FLOOR="3.1.6" +# Required floor. Jackson 3 is the SDK's JSON stack and is also required by +# mcp -> mcp-json-jackson3; the SDK declares it directly so the floor travels in the +# flattened POM. Jackson 2 (jackson-core/jackson-databind) must not reach consumers at all. +JACKSON_FLOOR="3.1.6" # Expected Java shape: class-file major 65 == Java 21. EXPECTED_CLASSFILE_MAJOR="65" @@ -169,27 +168,25 @@ check_floor() { # $1 label, $2 jar prefix, $3 floor, $4 expected-groupId marker } # Jackson 2 and Jackson 3 both publish a jackson-core/jackson-databind pair. Disambiguate -# by version line rather than by file name. +# by version line rather than by file name. Any Jackson 2 core/databind is a failure: the +# SDK is Jackson 3 only. (jackson-annotations keeps 2.x coordinates by Jackson 3's design +# and is not matched here.) for prefix in jackson-core jackson-databind; do for jar in "$CLOSURE/$prefix"-*.jar; do [ -e "$jar" ] || continue v="$(basename "$jar" | sed -E "s/^$prefix-(.+)\.jar$/\1/")" case "$v" in - 2.*) if version_ge "$v" "$JACKSON2_FLOOR"; then - pass "com.fasterxml.jackson.core:$prefix resolved $v (floor $JACKSON2_FLOOR)" + 2.*) fail "com.fasterxml.jackson.core:$prefix $v is in the consumer closure; Jackson 2 must not reach consumers" ;; + 3.*) if version_ge "$v" "$JACKSON_FLOOR"; then + pass "tools.jackson.core:$prefix resolved $v (floor $JACKSON_FLOOR)" else - fail "com.fasterxml.jackson.core:$prefix resolved $v, below floor $JACKSON2_FLOOR" - fi ;; - 3.*) if version_ge "$v" "$JACKSON3_FLOOR"; then - pass "tools.jackson.core:$prefix resolved $v (floor $JACKSON3_FLOOR)" - else - fail "tools.jackson.core:$prefix resolved $v, below floor $JACKSON3_FLOOR" + fail "tools.jackson.core:$prefix resolved $v, below floor $JACKSON_FLOOR" fi ;; *) fail "$prefix resolved an unexpected version line: $v" ;; esac done done -check_floor "tools.jackson.dataformat:jackson-dataformat-yaml" jackson-dataformat-yaml "$JACKSON3_FLOOR" +check_floor "tools.jackson.dataformat:jackson-dataformat-yaml" jackson-dataformat-yaml "$JACKSON_FLOOR" # Every Jackson 3 artifact must sit on one minor; skew across tools.jackson modules is a # runtime hazard, not a cosmetic difference. @@ -231,8 +228,8 @@ echo # --------------------------------------------------------------------------- # 7. Offline runtime smoke against the resolved closure. # -# This links the SDK's Jackson 2 parsing path and the Jackson 3 stack that mcp -# supplies, on exactly the versions a consumer receives. It spawns no Claude CLI +# This links the SDK's own parsing path and the JSON mapper mcp binds, both on +# Jackson 3, on exactly the versions a consumer receives. It spawns no Claude CLI # process, opens no network connection, and uses no credentials. # --------------------------------------------------------------------------- echo "[runtime smoke]" @@ -258,7 +255,7 @@ public class ConsumerSmoke { throw new IllegalStateException("expected a Java 21+ runtime, got " + feature); } - // Jackson 2 path: the SDK's own message parsing. + // SDK path: the SDK's own message parsing. String assistant = "{\"type\":\"assistant\",\"message\":{\"id\":\"msg_1\",\"model\":\"m\"," + "\"content\":[{\"type\":\"text\",\"text\":\"hello\"}]}}"; Message parsed = new MessageParser().parseMessage(assistant); @@ -272,7 +269,7 @@ public class ConsumerSmoke { throw new IllegalStateException("CLIOptions.builder() returned null"); } - // Jackson 3 path: the JSON mapper mcp actually binds at runtime, loaded through + // mcp path: the JSON mapper mcp actually binds at runtime, loaded through // its ServiceLoader SPI so core/databind must genuinely link. McpJsonMapperSupplier supplier = ServiceLoader.load(McpJsonMapperSupplier.class) .findFirst()