Skip to content
Open
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
21 changes: 14 additions & 7 deletions src/claude_agent_sdk/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, TypeAlias
from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, TypeAlias, cast

if sys.version_info >= (3, 11):
from typing import NotRequired, Required, TypedDict
Expand Down Expand Up @@ -128,7 +128,7 @@ class AgentDefinition:

# Permission Update types (matching TypeScript SDK)
PermissionUpdateDestination = Literal[
"userSettings", "projectSettings", "localSettings", "session"
"userSettings", "projectSettings", "localSettings", "session", "cliArg"
]

PermissionBehavior = Literal["allow", "deny", "ask"]
Expand Down Expand Up @@ -158,17 +158,24 @@ class PermissionUpdate:
behavior: PermissionBehavior | None = None
mode: PermissionMode | None = None
directories: list[str] | None = None
destination: PermissionUpdateDestination | None = None
# Required on every variant of the TypeScript PermissionUpdate, and the CLI
# drops the whole updatedPermissions array when it is missing, with only a
# warn-level log. "session" is the conservative default: it holds for this
# run and writes nothing to a settings file the caller did not name.
destination: PermissionUpdateDestination = "session"

def to_dict(self) -> dict[str, Any]:
"""Convert PermissionUpdate to dictionary format matching TypeScript control protocol."""
result: dict[str, Any] = {
"type": self.type,
}

# Add destination for all variants
if self.destination is not None:
result["destination"] = self.destination
# Add destination for all variants. The annotation no longer admits None,
# but a caller written against the old optional field can still pass it,
# and a null on the wire fails the CLI's schema the same way a missing
# key does, so fall back to the default here too.
destination = cast("PermissionUpdateDestination | None", self.destination)
result["destination"] = destination or "session"

# Handle different type variants
if self.type in ["addRules", "replaceRules", "removeRules"]:
Expand Down Expand Up @@ -214,7 +221,7 @@ def from_dict(cls, data: dict[str, Any]) -> "PermissionUpdate":
behavior=data.get("behavior"),
mode=data.get("mode"),
directories=data.get("directories"),
destination=data.get("destination"),
destination=data.get("destination") or "session",
)


Expand Down
39 changes: 39 additions & 0 deletions tests/test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from claude_agent_sdk.types import (
PermissionRuleValue,
PermissionUpdate,
PermissionUpdateDestination,
PostToolUseHookSpecificOutput,
PreToolUseHookSpecificOutput,
TextBlock,
Expand Down Expand Up @@ -62,6 +63,44 @@ def test_from_dict_set_mode(self):
assert update.rules is None
assert update.to_dict() == wire

def test_destination_is_always_on_the_wire(self):
"""The CLI's schema requires destination on every variant.

An entry without it makes the CLI drop the whole updatedPermissions array
and log a warning, so the update never lands and the callback is asked
again. The TypeScript PermissionUpdate declares the field non-optional.
"""
for update in (
PermissionUpdate(type="setMode", mode="plan"),
PermissionUpdate(type="addDirectories", directories=["/tmp/a"]),
PermissionUpdate(
type="addRules",
rules=[PermissionRuleValue(tool_name="Bash", rule_content="ls:*")],
behavior="allow",
),
):
assert update.to_dict()["destination"] == "session"

def test_from_dict_without_destination_defaults_to_session(self):
update = PermissionUpdate.from_dict({"type": "setMode", "mode": "plan"})
assert update.destination == "session"
assert update.to_dict()["destination"] == "session"

def test_explicit_none_destination_still_sends_session(self):
"""destination used to be Optional, so callers may still pass None.

Writing null into the control request fails the CLI's schema exactly
like leaving the key out, so it has to coalesce rather than pass through.
"""
update = PermissionUpdate(type="setMode", mode="plan", destination=None) # type: ignore[arg-type]
assert update.to_dict()["destination"] == "session"

def test_cli_arg_is_a_destination(self):
"""The CLI and the TypeScript SDK both accept cliArg; this one did not."""
assert "cliArg" in get_args(PermissionUpdateDestination)
update = PermissionUpdate(type="setMode", mode="plan", destination="cliArg")
assert update.to_dict()["destination"] == "cliArg"

def test_from_dict_directories(self):
wire = {
"type": "addDirectories",
Expand Down