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: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
14 changes: 14 additions & 0 deletions src/com/tjclp/scalagent/hooks/HookCallback.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions src/com/tjclp/scalagent/hooks/HookEvent.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"
Expand All @@ -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)
Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions src/com/tjclp/scalagent/hooks/HookInput.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions src/com/tjclp/scalagent/messages/AgentMessage.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions src/com/tjclp/scalagent/streaming/MessageConverter.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
27 changes: 27 additions & 0 deletions test/src/com/tjclp/scalagent/hooks/HookCallbackSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand Down
30 changes: 30 additions & 0 deletions test/src/com/tjclp/scalagent/streaming/MessageConverterSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading