diff --git a/bun.lock b/bun.lock index 6bc6922..549cae9 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "name": "claude-agent-sdk-scalajs", "dependencies": { "@a2a-js/sdk": "^0.3.12", - "@anthropic-ai/claude-agent-sdk": "^0.2.77", + "@anthropic-ai/claude-agent-sdk": "^0.2.78", "zod": "^4.0.0", }, "devDependencies": { @@ -20,7 +20,7 @@ "packages": { "@a2a-js/sdk": ["@a2a-js/sdk@0.3.12", "", { "dependencies": { "uuid": "^11.1.0" }, "peerDependencies": { "@bufbuild/protobuf": "^2.10.2", "@grpc/grpc-js": "^1.11.0", "express": "^4.21.2 || ^5.1.0" }, "optionalPeers": ["@bufbuild/protobuf", "@grpc/grpc-js", "express"] }, "sha512-iYV4rkOrAiGGN0ID54NiszHmgtBI0zJwgY3ZfsGOxwfz5CC8ERSSG1te5pwXApyqlZM+GxENad9gvmTk9oS1vQ=="], - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.77", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-t+R1BW3ahCFMNM7/8WJq7+Gw9KPA9Cl7UUK8fWPokJZ75cf/xwEd9MqB+MVNoQT45dJiom/wxybT7tqYPkCqyg=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.78", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-pmaAkTp/zjXPf6pk16c29X/ePj/lkfuVYaXCVfF+NrqW4/QU+CZhuRVhcobdBrcosjc7LvB31PpHAR0iCJPW0g=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], diff --git a/package.json b/package.json index 952cabf..7b53f47 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@a2a-js/sdk": "^0.3.12", - "@anthropic-ai/claude-agent-sdk": "^0.2.77", + "@anthropic-ai/claude-agent-sdk": "^0.2.78", "zod": "^4.0.0" }, "devDependencies": { diff --git a/src/com/tjclp/scalagent/hooks/HookCallback.scala b/src/com/tjclp/scalagent/hooks/HookCallback.scala index 5b3b96b..42752ac 100644 --- a/src/com/tjclp/scalagent/hooks/HookCallback.scala +++ b/src/com/tjclp/scalagent/hooks/HookCallback.scala @@ -6,6 +6,7 @@ import scala.scalajs.js.JSConverters.* import zio.* import zio.json.ast.Json import com.tjclp.scalagent.config.PermissionMode +import com.tjclp.scalagent.messages.AssistantMessageError import com.tjclp.scalagent.tools.ToolName import com.tjclp.scalagent.types.{SessionId, SubagentId, ToolUseId} @@ -231,6 +232,19 @@ object HookCallback: permissionMode = permissionMode ) + case "StopFailure" => + HookInput.StopFailure( + sessionId = sessionId, + cwd = cwd, + transcriptPath = transcriptPath, + error = AssistantMessageError.fromString(firstString(raw, "error").getOrElse("unknown")), + errorDetails = firstString(raw, "error_details", "errorDetails"), + lastAssistantMessage = firstString(raw, "last_assistant_message", "lastAssistantMessage"), + hookAgentId = baseAgentId, + hookAgentType = baseAgentType, + permissionMode = permissionMode + ) + case "SubagentStart" => HookInput.SubagentStart( sessionId = sessionId, diff --git a/src/com/tjclp/scalagent/hooks/HookEvent.scala b/src/com/tjclp/scalagent/hooks/HookEvent.scala index f0f7628..0bdec1f 100644 --- a/src/com/tjclp/scalagent/hooks/HookEvent.scala +++ b/src/com/tjclp/scalagent/hooks/HookEvent.scala @@ -35,6 +35,9 @@ enum HookEvent: /** Stop hook triggered */ case Stop + /** Stop failure hook - triggered when agent fails to stop cleanly */ + case StopFailure + /** Subagent initiated */ case SubagentStart @@ -85,6 +88,7 @@ enum HookEvent: case SessionStart => "SessionStart" case SessionEnd => "SessionEnd" case Stop => "Stop" + case StopFailure => "StopFailure" case SubagentStart => "SubagentStart" case SubagentStop => "SubagentStop" case PreCompact => "PreCompact" @@ -111,6 +115,7 @@ object HookEvent: case "SessionStart" => Right(SessionStart) case "SessionEnd" => Right(SessionEnd) case "Stop" => Right(Stop) + case "StopFailure" => Right(StopFailure) case "SubagentStart" => Right(SubagentStart) case "SubagentStop" => Right(SubagentStop) case "PreCompact" => Right(PreCompact) @@ -137,6 +142,7 @@ object HookEvent: case "SessionStart" => SessionStart case "SessionEnd" => SessionEnd case "Stop" => Stop + case "StopFailure" => StopFailure case "SubagentStart" => SubagentStart case "SubagentStop" => SubagentStop case "PreCompact" => PreCompact diff --git a/src/com/tjclp/scalagent/hooks/HookInput.scala b/src/com/tjclp/scalagent/hooks/HookInput.scala index 47ac4b2..2046ec2 100644 --- a/src/com/tjclp/scalagent/hooks/HookInput.scala +++ b/src/com/tjclp/scalagent/hooks/HookInput.scala @@ -2,6 +2,7 @@ package com.tjclp.scalagent.hooks import com.tjclp.scalagent.config.PermissionMode import com.tjclp.scalagent.json.StringEnumJsonCodec +import com.tjclp.scalagent.messages.AssistantMessageError import com.tjclp.scalagent.tools.ToolName import com.tjclp.scalagent.types.{SessionId, SubagentId, ToolUseId} import zio.json.* @@ -160,6 +161,19 @@ object HookInput: permissionMode: Option[PermissionMode] = None ) extends HookInput + /** Input for StopFailure hook - triggered when agent fails to stop cleanly */ + final case class StopFailure( + sessionId: SessionId, + cwd: String, + transcriptPath: String, + error: AssistantMessageError, + errorDetails: Option[String] = None, + lastAssistantMessage: Option[String] = None, + hookAgentId: Option[SubagentId] = None, + hookAgentType: Option[String] = None, + permissionMode: Option[PermissionMode] = None + ) extends HookInput + /** Input for SubagentStart hook */ final case class SubagentStart( sessionId: SessionId, diff --git a/src/com/tjclp/scalagent/messages/AgentMessage.scala b/src/com/tjclp/scalagent/messages/AgentMessage.scala index 67b2b50..3375ca7 100644 --- a/src/com/tjclp/scalagent/messages/AgentMessage.scala +++ b/src/com/tjclp/scalagent/messages/AgentMessage.scala @@ -166,6 +166,13 @@ enum AgentMessage: sessionId: SessionId ) + /** Bridge metadata message carrying slash commands */ + case BridgeMetadata( + @jsonField("slash_commands") slashCommands: List[String], + uuid: MessageUuid, + sessionId: SessionId + ) + /** Forward-compatible fallback for unknown top-level SDK messages */ case Unknown( envelope: UnknownEnvelope diff --git a/src/com/tjclp/scalagent/streaming/MessageConverter.scala b/src/com/tjclp/scalagent/streaming/MessageConverter.scala index b0bcca5..fb8edf2 100644 --- a/src/com/tjclp/scalagent/streaming/MessageConverter.scala +++ b/src/com/tjclp/scalagent/streaming/MessageConverter.scala @@ -203,6 +203,14 @@ object MessageConverter: guardTopLevelUnknown(raw, "system", Some("api_retry"), context) { parseApiRetryMessage(obj, raw) } + case Some("bridge_metadata") => + guardTopLevelUnknown(raw, "system", Some("bridge_metadata"), context) { + AgentMessage.BridgeMetadata( + slashCommands = stringArrayField(obj, "slash_commands"), + uuid = requiredUuid(obj, raw), + sessionId = requiredSessionId(obj, raw) + ) + } case other => AgentMessage.System( event = parseSystemEvent(obj, raw, other, context), diff --git a/test/src/com/tjclp/scalagent/hooks/HookCallbackSpec.scala b/test/src/com/tjclp/scalagent/hooks/HookCallbackSpec.scala index 3f1e446..2a16629 100644 --- a/test/src/com/tjclp/scalagent/hooks/HookCallbackSpec.scala +++ b/test/src/com/tjclp/scalagent/hooks/HookCallbackSpec.scala @@ -7,6 +7,7 @@ import scala.scalajs.js import scala.scalajs.js.JSConverters.* import zio.* import com.tjclp.scalagent.config.PermissionMode +import com.tjclp.scalagent.messages.AssistantMessageError import com.tjclp.scalagent.types.SubagentId class HookCallbackSpec extends FunSuite: @@ -166,6 +167,32 @@ class HookCallbackSpec extends FunSuite: case other => fail(s"Expected PermissionRequest, got: $other") } + test("parseHookInput handles StopFailure"): + val raw = baseInput("StopFailure") + raw.error = "rate_limit" + raw.error_details = "Rate limit exceeded" + raw.last_assistant_message = "I was trying to help" + + parseInput(raw).map { + case input: HookInput.StopFailure => + assertEquals(input.error, AssistantMessageError.RateLimit) + assertEquals(input.errorDetails, Some("Rate limit exceeded")) + assertEquals(input.lastAssistantMessage, Some("I was trying to help")) + assertEquals(input.permissionMode, Some(PermissionMode.Custom("delegate"))) + case other => fail(s"Expected StopFailure, got: $other") + } + + test("parseHookInput handles StopFailure with missing error defaults to Unknown"): + val raw = baseInput("StopFailure") + + parseInput(raw).map { + case input: HookInput.StopFailure => + assertEquals(input.error, AssistantMessageError.Unknown) + assertEquals(input.errorDetails, None) + assertEquals(input.lastAssistantMessage, None) + case other => fail(s"Expected StopFailure, got: $other") + } + test("parseHookInput handles InstructionsLoaded with agent metadata"): val raw = baseInput("InstructionsLoaded") raw.file_path = "/tmp/project/CLAUDE.md" diff --git a/test/src/com/tjclp/scalagent/streaming/MessageConverterSpec.scala b/test/src/com/tjclp/scalagent/streaming/MessageConverterSpec.scala index a33e36c..a5cda90 100644 --- a/test/src/com/tjclp/scalagent/streaming/MessageConverterSpec.scala +++ b/test/src/com/tjclp/scalagent/streaming/MessageConverterSpec.scala @@ -388,6 +388,36 @@ class MessageConverterSpec extends FunSuite: case other => fail(s"Expected ApiRetry, got: $other") + test("parses bridge_metadata message"): + val raw = js.Dynamic.literal( + `type` = "system", + subtype = "bridge_metadata", + slash_commands = js.Array("/help", "/commit", "/review-pr"), + uuid = "msg-bridge-1", + session_id = "session-1" + ) + + MessageConverter.fromRaw(raw) match + case AgentMessage.BridgeMetadata(slashCommands, _, _) => + assertEquals(slashCommands, List("/help", "/commit", "/review-pr")) + case other => + fail(s"Expected BridgeMetadata, got: $other") + + test("parses bridge_metadata with empty slash_commands"): + val raw = js.Dynamic.literal( + `type` = "system", + subtype = "bridge_metadata", + slash_commands = js.Array[String](), + uuid = "msg-bridge-2", + session_id = "session-1" + ) + + MessageConverter.fromRaw(raw) match + case AgentMessage.BridgeMetadata(slashCommands, _, _) => + assertEquals(slashCommands, Nil) + case other => + fail(s"Expected BridgeMetadata, got: $other") + test("parses task_progress with summary"): val raw = js.Dynamic.literal( `type` = "system",