diff --git a/bun.lock b/bun.lock index 62915f3..6bc6922 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.75", + "@anthropic-ai/claude-agent-sdk": "^0.2.77", "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.75", "", { "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-BSF5m0z7mwP5zB0G9JOEhCBovPpRBTe69oMSdUOYvEX6rLcHM0lp38MC2mr81vqSFo91a21/hVxOeifZyn/qkw=="], + "@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=="], "@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 13cbb7c..952cabf 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.75", + "@anthropic-ai/claude-agent-sdk": "^0.2.77", "zod": "^4.0.0" }, "devDependencies": { diff --git a/src/com/tjclp/scalagent/Claude.scala b/src/com/tjclp/scalagent/Claude.scala index 47aefda..c877d99 100644 --- a/src/com/tjclp/scalagent/Claude.scala +++ b/src/com/tjclp/scalagent/Claude.scala @@ -7,7 +7,7 @@ import com.tjclp.scalagent.config.* import com.tjclp.scalagent.errors.* import com.tjclp.scalagent.messages.* import com.tjclp.scalagent.session.* -import com.tjclp.scalagent.types.SessionId +import com.tjclp.scalagent.types.{MessageUuid, SessionId} /** Simplified entry point for the Claude Agent SDK. * @@ -269,6 +269,47 @@ object Claude: .map(_.toOption.map(dyn => SessionInfo.fromRaw(dyn.asInstanceOf[js.Dynamic]))) .mapError(AgentError.fromThrowable) + /** Fork a session into a new branch with fresh UUIDs. + * + * The forked session can be resumed with [[ClaudeSession.resume]] or via the `resume` session mode. + * + * @param sessionId + * UUID of the source session to fork + * @param upToMessageId + * Slice the transcript up to this message UUID (inclusive). If omitted, the full transcript is copied. + * @param title + * Custom title for the fork. If omitted, derives from the original title + " (fork)". + * @param dir + * Optional project directory path + * @return + * The session ID of the newly forked session + */ + def forkSession( + sessionId: SessionId, + upToMessageId: Option[MessageUuid] = None, + title: Option[String] = None, + dir: Option[String] = None + ): IO[AgentError, SessionId] = + val hasOptions = upToMessageId.isDefined || title.isDefined || dir.isDefined + val jsOpts: js.UndefOr[js.Dynamic] = + if hasOptions then + val opts = js.Dynamic.literal() + upToMessageId.foreach(id => opts.upToMessageId = id.value) + title.foreach(t => opts.title = t) + dir.foreach(d => opts.dir = d) + opts + else js.undefined + ZIO + .fromPromiseJS(SdkModule.forkSession(sessionId.value, jsOpts)) + .flatMap { result => + val sid = result.sessionId.asInstanceOf[js.UndefOr[String]].toOption + ZIO.fromOption(sid).mapError(_ => + new RuntimeException("forkSession: missing sessionId in response") + ) + } + .map(SessionId(_)) + .mapError(AgentError.fromThrowable) + def getSessionMessages(sessionId: SessionId, dir: String): IO[AgentError, List[SessionMessage]] = ZIO .fromPromiseJS( diff --git a/src/com/tjclp/scalagent/ClaudeAgent.scala b/src/com/tjclp/scalagent/ClaudeAgent.scala index 1e623ef..ec87429 100644 --- a/src/com/tjclp/scalagent/ClaudeAgent.scala +++ b/src/com/tjclp/scalagent/ClaudeAgent.scala @@ -238,3 +238,4 @@ private[scalagent] object SdkModule extends js.Object: def renameSession(sessionId: String, title: String, options: js.UndefOr[js.Dynamic] = js.undefined): js.Promise[Unit] = js.native def tagSession(sessionId: String, tag: String | Null, options: js.UndefOr[js.Dynamic] = js.undefined): js.Promise[Unit] = js.native def getSessionInfo(sessionId: String, options: js.UndefOr[js.Dynamic] = js.undefined): js.Promise[js.UndefOr[js.Dynamic]] = js.native + def forkSession(sessionId: String, options: js.UndefOr[js.Dynamic] = js.undefined): js.Promise[js.Dynamic] = js.native diff --git a/src/com/tjclp/scalagent/a2a/A2AServer.scala b/src/com/tjclp/scalagent/a2a/A2AServer.scala index 86a13b1..2845729 100644 --- a/src/com/tjclp/scalagent/a2a/A2AServer.scala +++ b/src/com/tjclp/scalagent/a2a/A2AServer.scala @@ -295,14 +295,17 @@ private final class A2AServerLive(config: A2AServer.Config, runtime: Runtime[Any // Log completion SessionLogger.logEvent(taskId.value, "completed", responseText.take(500)) - // Publish response - bus.publish(jsMsg) + // Publish completed status BEFORE the message event. + // The A2A SDK's event queue breaks on whichever comes first + // ("message" or final "status-update"), so the status-update + // must come first to ensure the task store transitions to "completed". publishStatusUpdate( ctx, bus, com.tjclp.scalagent.a2a.TaskStatus.completed(responseMsg), finalUpdate = true ) + bus.publish(jsMsg) bus.finished() } .catchAll { error => @@ -315,13 +318,14 @@ private final class A2AServerLive(config: A2AServer.Config, runtime: Runtime[Any // Log failure SessionLogger.logEvent(taskId.value, "failed", errorText) - bus.publish(A2AConverters.toJs(errorMsg)) + // Publish failed status BEFORE the message event (same ordering rationale). publishStatusUpdate( ctx, bus, com.tjclp.scalagent.a2a.TaskStatus.failed(errorMsg), finalUpdate = true ) + bus.publish(A2AConverters.toJs(errorMsg)) bus.finished() } } diff --git a/src/com/tjclp/scalagent/config/SandboxSettings.scala b/src/com/tjclp/scalagent/config/SandboxSettings.scala index b6fc286..9062f64 100644 --- a/src/com/tjclp/scalagent/config/SandboxSettings.scala +++ b/src/com/tjclp/scalagent/config/SandboxSettings.scala @@ -189,17 +189,25 @@ object SandboxNetworkConfig: * Paths/patterns to deny write access * @param denyRead * Paths/patterns to deny read access + * @param allowRead + * Paths to re-allow reading within denyRead regions (takes precedence over denyRead) + * @param allowManagedReadPathsOnly + * When true, only allowRead paths from policySettings are used */ final case class SandboxFilesystemConfig( allowWrite: List[String] = Nil, denyWrite: List[String] = Nil, - denyRead: List[String] = Nil + denyRead: List[String] = Nil, + allowRead: List[String] = Nil, + allowManagedReadPathsOnly: Boolean = false ): def toRaw: js.Object = val obj = js.Dynamic.literal() if allowWrite.nonEmpty then obj.allowWrite = allowWrite.toJSArray if denyWrite.nonEmpty then obj.denyWrite = denyWrite.toJSArray if denyRead.nonEmpty then obj.denyRead = denyRead.toJSArray + if allowRead.nonEmpty then obj.allowRead = allowRead.toJSArray + if allowManagedReadPathsOnly then obj.allowManagedReadPathsOnly = true obj.asInstanceOf[js.Object] object SandboxFilesystemConfig: diff --git a/src/com/tjclp/scalagent/hooks/HookCallback.scala b/src/com/tjclp/scalagent/hooks/HookCallback.scala index 139dc52..5b3b96b 100644 --- a/src/com/tjclp/scalagent/hooks/HookCallback.scala +++ b/src/com/tjclp/scalagent/hooks/HookCallback.scala @@ -164,7 +164,10 @@ object HookCallback: permissionMode = permissionMode, toolUseId = firstString(raw, "tool_use_id", "toolUseId").map(ToolUseId.apply), agentId = baseAgentId, - hookAgentType = baseAgentType + hookAgentType = baseAgentType, + title = firstString(raw, "title"), + displayName = firstString(raw, "display_name", "displayName"), + description = firstString(raw, "description") ) case "Notification" => @@ -250,6 +253,20 @@ object HookCallback: permissionMode = permissionMode ) + case "PostCompact" => + HookInput.PostCompact( + sessionId = sessionId, + cwd = cwd, + transcriptPath = transcriptPath, + trigger = CompactTrigger.fromString( + firstString(raw, "trigger").getOrElse("auto") + ), + compactSummary = firstString(raw, "compact_summary", "compactSummary").getOrElse(""), + hookAgentId = baseAgentId, + hookAgentType = baseAgentType, + permissionMode = permissionMode + ) + case "PreCompact" => HookInput.PreCompact( sessionId = sessionId, diff --git a/src/com/tjclp/scalagent/hooks/HookEvent.scala b/src/com/tjclp/scalagent/hooks/HookEvent.scala index 1888f4e..f0f7628 100644 --- a/src/com/tjclp/scalagent/hooks/HookEvent.scala +++ b/src/com/tjclp/scalagent/hooks/HookEvent.scala @@ -44,6 +44,9 @@ enum HookEvent: /** Before context compaction */ case PreCompact + /** After context compaction completes */ + case PostCompact + /** Setup hook - triggered during initialization or maintenance */ case Setup @@ -85,6 +88,7 @@ enum HookEvent: case SubagentStart => "SubagentStart" case SubagentStop => "SubagentStop" case PreCompact => "PreCompact" + case PostCompact => "PostCompact" case Setup => "Setup" case TeammateIdle => "TeammateIdle" case TaskCompleted => "TaskCompleted" @@ -110,6 +114,7 @@ object HookEvent: case "SubagentStart" => Right(SubagentStart) case "SubagentStop" => Right(SubagentStop) case "PreCompact" => Right(PreCompact) + case "PostCompact" => Right(PostCompact) case "Setup" => Right(Setup) case "TeammateIdle" => Right(TeammateIdle) case "TaskCompleted" => Right(TaskCompleted) @@ -135,6 +140,7 @@ object HookEvent: case "SubagentStart" => SubagentStart case "SubagentStop" => SubagentStop case "PreCompact" => PreCompact + case "PostCompact" => PostCompact case "Setup" => Setup case "TeammateIdle" => TeammateIdle case "TaskCompleted" => TaskCompleted diff --git a/src/com/tjclp/scalagent/hooks/HookInput.scala b/src/com/tjclp/scalagent/hooks/HookInput.scala index fe3cbcc..47ac4b2 100644 --- a/src/com/tjclp/scalagent/hooks/HookInput.scala +++ b/src/com/tjclp/scalagent/hooks/HookInput.scala @@ -93,7 +93,10 @@ object HookInput: permissionMode: Option[PermissionMode] = None, toolUseId: Option[ToolUseId] = None, agentId: Option[SubagentId] = None, - hookAgentType: Option[String] = None + hookAgentType: Option[String] = None, + title: Option[String] = None, + displayName: Option[String] = None, + description: Option[String] = None ) extends HookInput: def hookAgentId: Option[SubagentId] = agentId @@ -195,6 +198,18 @@ object HookInput: @deprecated("Use agentType", "0.2.63") def subagentType: String = agentType + /** Input for PostCompact hook - after context compaction completes */ + final case class PostCompact( + sessionId: SessionId, + cwd: String, + transcriptPath: String, + trigger: CompactTrigger, + compactSummary: String, + hookAgentId: Option[SubagentId] = None, + hookAgentType: Option[String] = None, + permissionMode: Option[PermissionMode] = None + ) extends HookInput + /** Input for PreCompact hook - before context compaction */ final case class PreCompact( sessionId: SessionId, @@ -506,7 +521,7 @@ object MemoryType: /** Load reason for instructions loaded hook */ enum InstructionsLoadReason: - case SessionStart, NestedTraversal, PathGlobMatch, Include + case SessionStart, NestedTraversal, PathGlobMatch, Include, Compact case Custom(value: String) def toRaw: String = this match @@ -514,6 +529,7 @@ enum InstructionsLoadReason: case NestedTraversal => "nested_traversal" case PathGlobMatch => "path_glob_match" case Include => "include" + case Compact => "compact" case Custom(v) => v object InstructionsLoadReason: @@ -525,6 +541,7 @@ object InstructionsLoadReason: case "nested_traversal" => NestedTraversal case "path_glob_match" => PathGlobMatch case "include" => Include + case "compact" => Compact case other => Custom(other) /** Source of a configuration change */ diff --git a/src/com/tjclp/scalagent/messages/AgentMessage.scala b/src/com/tjclp/scalagent/messages/AgentMessage.scala index 38a7ec3..67b2b50 100644 --- a/src/com/tjclp/scalagent/messages/AgentMessage.scala +++ b/src/com/tjclp/scalagent/messages/AgentMessage.scala @@ -155,6 +155,17 @@ enum AgentMessage: summary: Option[String] = None ) + /** API retry notification - emitted when a retryable error occurs and the request will be retried */ + case ApiRetry( + attempt: Int, + maxRetries: Int, + retryDelayMs: Long, + errorStatus: Option[Int], + error: AssistantMessageError, + 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 499393e..b0bcca5 100644 --- a/src/com/tjclp/scalagent/streaming/MessageConverter.scala +++ b/src/com/tjclp/scalagent/streaming/MessageConverter.scala @@ -199,6 +199,10 @@ object MessageConverter: case Some("task_started") => parseTaskStarted(obj, raw) case Some("local_command_output") => parseLocalCommandOutput(obj, raw) case Some("elicitation_complete") => parseElicitationComplete(obj, raw) + case Some("api_retry") => + guardTopLevelUnknown(raw, "system", Some("api_retry"), context) { + parseApiRetryMessage(obj, raw) + } case other => AgentMessage.System( event = parseSystemEvent(obj, raw, other, context), @@ -409,6 +413,17 @@ object MessageConverter: prompt = stringField(obj, "prompt") ) + private def parseApiRetryMessage(obj: js.Dynamic, raw: Json): AgentMessage.ApiRetry = + AgentMessage.ApiRetry( + attempt = intField(obj, "attempt").getOrElse(0), + maxRetries = intField(obj, "max_retries").getOrElse(0), + retryDelayMs = longField(obj, "retry_delay_ms").getOrElse(0L), + errorStatus = intField(obj, "error_status"), + error = stringField(obj, "error").map(AssistantMessageError.fromString).getOrElse(AssistantMessageError.Unknown), + uuid = requiredUuid(obj, raw), + sessionId = requiredSessionId(obj, raw) + ) + private def parseTaskProgress(obj: js.Dynamic, raw: Json): AgentMessage.TaskProgress = AgentMessage.TaskProgress( taskId = requiredString(obj, "task_id", raw), diff --git a/src/com/tjclp/scalagent/streaming/QueryStream.scala b/src/com/tjclp/scalagent/streaming/QueryStream.scala index 2816c4a..a651d0a 100644 --- a/src/com/tjclp/scalagent/streaming/QueryStream.scala +++ b/src/com/tjclp/scalagent/streaming/QueryStream.scala @@ -71,6 +71,9 @@ trait RawQuery extends AsyncGenerator[js.Any, Unit, Unit]: /** Get the full initialization result including commands, models, account info */ def initializationResult(): js.Promise[js.Dynamic] = js.native + /** Apply settings mid-session (only available in streaming input mode) */ + def applyFlagSettings(settings: js.Dynamic): js.Promise[Unit] = js.native + /** Wrapper for SDK Query that provides ZIO/ZStream interface. * * This class wraps the raw JavaScript Query object and provides: @@ -355,6 +358,17 @@ final class QueryStream private (rawQuery: RawQuery): .fromPromiseJS(rawQuery.initializationResult()) .map(InitializationResult.fromRaw) + /** Apply settings mid-session, dynamically updating the active configuration. + * + * Equivalent to passing a `settings` object to `query()` but applies during an ongoing session. + * Only available in streaming input mode. + * + * @param settings + * A raw JS settings object to merge into the flag settings layer + */ + def applyFlagSettings(settings: js.Dynamic): Task[Unit] = + ZIO.fromPromiseJS(rawQuery.applyFlagSettings(settings)) + object QueryStream: /** Create a QueryStream from a raw SDK Query object. @@ -486,7 +500,8 @@ final case class AccountInfo( organization: Option[String], subscriptionType: Option[String], tokenSource: Option[String], - apiKeySource: Option[String] + apiKeySource: Option[String], + apiProvider: Option[String] = None ) object AccountInfo: @@ -496,7 +511,8 @@ object AccountInfo: organization = obj.organization.asInstanceOf[js.UndefOr[String]].toOption, subscriptionType = obj.subscriptionType.asInstanceOf[js.UndefOr[String]].toOption, tokenSource = obj.tokenSource.asInstanceOf[js.UndefOr[String]].toOption, - apiKeySource = obj.apiKeySource.asInstanceOf[js.UndefOr[String]].toOption + apiKeySource = obj.apiKeySource.asInstanceOf[js.UndefOr[String]].toOption, + apiProvider = obj.apiProvider.asInstanceOf[js.UndefOr[String]].toOption ) /** Result of a rewindFiles operation */ diff --git a/test/src/com/tjclp/scalagent/hooks/HookCallbackSpec.scala b/test/src/com/tjclp/scalagent/hooks/HookCallbackSpec.scala index cc0385c..3f1e446 100644 --- a/test/src/com/tjclp/scalagent/hooks/HookCallbackSpec.scala +++ b/test/src/com/tjclp/scalagent/hooks/HookCallbackSpec.scala @@ -138,6 +138,34 @@ class HookCallbackSpec extends FunSuite: case other => fail(s"Expected Setup, got: $other") } + test("parseHookInput handles PostCompact"): + val raw = baseInput("PostCompact") + raw.trigger = "auto" + raw.compact_summary = "Conversation compacted: 3 turns summarized" + + parseInput(raw).map { + case input: HookInput.PostCompact => + assertEquals(input.trigger, CompactTrigger.Auto) + assertEquals(input.compactSummary, "Conversation compacted: 3 turns summarized") + case other => fail(s"Expected PostCompact, got: $other") + } + + test("parseHookInput handles PermissionRequest with title/displayName/description"): + val raw = baseInput("PermissionRequest") + raw.tool_name = "Bash" + raw.tool_input = js.Dynamic.literal(command = "ls") + raw.title = "Claude wants to run a shell command" + raw.display_name = "Run command" + raw.description = "Claude will execute: ls" + + parseInput(raw).map { + case input: HookInput.PermissionRequest => + assertEquals(input.title, Some("Claude wants to run a shell command")) + assertEquals(input.displayName, Some("Run command")) + assertEquals(input.description, Some("Claude will execute: ls")) + case other => fail(s"Expected PermissionRequest, 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/hooks/HookInputEnumSpec.scala b/test/src/com/tjclp/scalagent/hooks/HookInputEnumSpec.scala index 67a38b5..c03afdf 100644 --- a/test/src/com/tjclp/scalagent/hooks/HookInputEnumSpec.scala +++ b/test/src/com/tjclp/scalagent/hooks/HookInputEnumSpec.scala @@ -36,3 +36,5 @@ class HookInputEnumSpec extends FunSuite: assertStringEnumRoundTrip(ElicitationAction.Custom("future_elicitation_action"), "future_elicitation_action") assertStringEnumRoundTrip(ConfigChangeSource.Skills, "skills") assertStringEnumRoundTrip(ConfigChangeSource.Custom("future_config_source"), "future_config_source") + assertStringEnumRoundTrip(InstructionsLoadReason.Compact, "compact") + assertStringEnumRoundTrip(InstructionsLoadReason.Custom("future_load_reason"), "future_load_reason") diff --git a/test/src/com/tjclp/scalagent/streaming/MessageConverterSpec.scala b/test/src/com/tjclp/scalagent/streaming/MessageConverterSpec.scala index a1e4fb8..a33e36c 100644 --- a/test/src/com/tjclp/scalagent/streaming/MessageConverterSpec.scala +++ b/test/src/com/tjclp/scalagent/streaming/MessageConverterSpec.scala @@ -345,6 +345,49 @@ class MessageConverterSpec extends FunSuite: case other => fail(s"Expected TaskProgress, got: $other") + test("parses api_retry message"): + val raw = js.Dynamic.literal( + `type` = "system", + subtype = "api_retry", + attempt = 2, + max_retries = 5, + retry_delay_ms = 3000, + error_status = 529, + error = "rate_limit", + uuid = "msg-retry-1", + session_id = "session-1" + ) + + MessageConverter.fromRaw(raw) match + case ar: AgentMessage.ApiRetry => + assertEquals(ar.attempt, 2) + assertEquals(ar.maxRetries, 5) + assertEquals(ar.retryDelayMs, 3000L) + assertEquals(ar.errorStatus, Some(529)) + assertEquals(ar.error, AssistantMessageError.RateLimit) + case other => + fail(s"Expected ApiRetry, got: $other") + + test("parses api_retry with null error_status"): + val raw = js.Dynamic.literal( + `type` = "system", + subtype = "api_retry", + attempt = 1, + max_retries = 3, + retry_delay_ms = 1000, + error_status = null, + error = "server_error", + uuid = "msg-retry-2", + session_id = "session-1" + ) + + MessageConverter.fromRaw(raw) match + case ar: AgentMessage.ApiRetry => + assertEquals(ar.errorStatus, None) + assertEquals(ar.error, AssistantMessageError.ServerError) + case other => + fail(s"Expected ApiRetry, got: $other") + test("parses task_progress with summary"): val raw = js.Dynamic.literal( `type` = "system",