Skip to content

Commit 9af27d7

Browse files
authored
feat: add typed TaskStarted/TaskProgress/TaskNotification message subclasses (#621)
Adds typed dataclass subclasses of `SystemMessage` for task-related messages, matching the TypeScript SDK's `SDKTaskStartedMessage`/`SDKTaskProgressMessage`/`SDKTaskNotificationMessage`. ## Approach The three new classes **subclass `SystemMessage`** so existing code is unaffected: - `isinstance(msg, SystemMessage)` → `True` for all typed task messages - `msg.subtype` and `msg.data` remain populated - `case SystemMessage():` pattern matching still works - Unknown system subtypes still yield generic `SystemMessage` (not accidentally subclassed) The parser dispatches on `subtype` to emit the specific subclass with typed fields; falls back to generic `SystemMessage` for anything else. ## New types - `TaskStartedMessage(SystemMessage)` — `task_id`, `description`, `uuid`, `session_id`, `tool_use_id?`, `task_type?` - `TaskProgressMessage(SystemMessage)` — `task_id`, `description`, `usage: TaskUsage`, `uuid`, `session_id`, `tool_use_id?`, `last_tool_name?` - `TaskNotificationMessage(SystemMessage)` — `task_id`, `status: 'completed'|'failed'|'stopped'`, `output_file`, `summary`, `uuid`, `session_id`, `tool_use_id?`, `usage?` - `TaskUsage` TypedDict — `{total_tokens, tool_uses, duration_ms}` <!-- CHANGELOG:START --> - Add typed `TaskStartedMessage`, `TaskProgressMessage`, `TaskNotificationMessage` subclasses of `SystemMessage` <!-- CHANGELOG:END --> ## Test plan - Unit: parser dispatch for all three subtypes, optional-field handling, `isinstance(SystemMessage)` and pattern-match backward-compat, generic fallback for unknown subtypes - E2E: real CLI subagent spawned — observed 1x `TaskStartedMessage`, 4x `TaskProgressMessage`, 1x `TaskNotificationMessage`, all fields correctly populated, `init` subtype correctly remained plain `SystemMessage`
1 parent 28f9b4b commit 9af27d7

4 files changed

Lines changed: 316 additions & 4 deletions

File tree

src/claude_agent_sdk/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@
6565
SubagentStartHookSpecificOutput,
6666
SubagentStopHookInput,
6767
SystemMessage,
68+
TaskNotificationMessage,
69+
TaskNotificationStatus,
70+
TaskProgressMessage,
71+
TaskStartedMessage,
72+
TaskUsage,
6873
TextBlock,
6974
ThinkingBlock,
7075
ThinkingConfig,
@@ -347,6 +352,11 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
347352
"UserMessage",
348353
"AssistantMessage",
349354
"SystemMessage",
355+
"TaskStartedMessage",
356+
"TaskProgressMessage",
357+
"TaskNotificationMessage",
358+
"TaskNotificationStatus",
359+
"TaskUsage",
350360
"ResultMessage",
351361
"Message",
352362
"ClaudeAgentOptions",

src/claude_agent_sdk/_internal/message_parser.py

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
ResultMessage,
1212
StreamEvent,
1313
SystemMessage,
14+
TaskNotificationMessage,
15+
TaskProgressMessage,
16+
TaskStartedMessage,
1417
TextBlock,
1518
ThinkingBlock,
1619
ToolResultBlock,
@@ -135,10 +138,49 @@ def parse_message(data: dict[str, Any]) -> Message | None:
135138

136139
case "system":
137140
try:
138-
return SystemMessage(
139-
subtype=data["subtype"],
140-
data=data,
141-
)
141+
subtype = data["subtype"]
142+
match subtype:
143+
case "task_started":
144+
return TaskStartedMessage(
145+
subtype=subtype,
146+
data=data,
147+
task_id=data["task_id"],
148+
description=data["description"],
149+
uuid=data["uuid"],
150+
session_id=data["session_id"],
151+
tool_use_id=data.get("tool_use_id"),
152+
task_type=data.get("task_type"),
153+
)
154+
case "task_progress":
155+
return TaskProgressMessage(
156+
subtype=subtype,
157+
data=data,
158+
task_id=data["task_id"],
159+
description=data["description"],
160+
usage=data["usage"],
161+
uuid=data["uuid"],
162+
session_id=data["session_id"],
163+
tool_use_id=data.get("tool_use_id"),
164+
last_tool_name=data.get("last_tool_name"),
165+
)
166+
case "task_notification":
167+
return TaskNotificationMessage(
168+
subtype=subtype,
169+
data=data,
170+
task_id=data["task_id"],
171+
status=data["status"],
172+
output_file=data["output_file"],
173+
summary=data["summary"],
174+
uuid=data["uuid"],
175+
session_id=data["session_id"],
176+
tool_use_id=data.get("tool_use_id"),
177+
usage=data.get("usage"),
178+
)
179+
case _:
180+
return SystemMessage(
181+
subtype=subtype,
182+
data=data,
183+
)
142184
except KeyError as e:
143185
raise MessageParseError(
144186
f"Missing required field in system message: {e}", data

src/claude_agent_sdk/types.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -777,6 +777,72 @@ class SystemMessage:
777777
data: dict[str, Any]
778778

779779

780+
class TaskUsage(TypedDict):
781+
"""Usage statistics reported in task_progress and task_notification messages."""
782+
783+
total_tokens: int
784+
tool_uses: int
785+
duration_ms: int
786+
787+
788+
# Possible status values for a task_notification message.
789+
TaskNotificationStatus = Literal["completed", "failed", "stopped"]
790+
791+
792+
@dataclass
793+
class TaskStartedMessage(SystemMessage):
794+
"""System message emitted when a task starts.
795+
796+
Subclass of SystemMessage: existing ``isinstance(msg, SystemMessage)`` and
797+
``case SystemMessage()`` checks continue to match. The base ``subtype``
798+
and ``data`` fields remain populated with the raw payload.
799+
"""
800+
801+
task_id: str
802+
description: str
803+
uuid: str
804+
session_id: str
805+
tool_use_id: str | None = None
806+
task_type: str | None = None
807+
808+
809+
@dataclass
810+
class TaskProgressMessage(SystemMessage):
811+
"""System message emitted while a task is in progress.
812+
813+
Subclass of SystemMessage: existing ``isinstance(msg, SystemMessage)`` and
814+
``case SystemMessage()`` checks continue to match. The base ``subtype``
815+
and ``data`` fields remain populated with the raw payload.
816+
"""
817+
818+
task_id: str
819+
description: str
820+
usage: TaskUsage
821+
uuid: str
822+
session_id: str
823+
tool_use_id: str | None = None
824+
last_tool_name: str | None = None
825+
826+
827+
@dataclass
828+
class TaskNotificationMessage(SystemMessage):
829+
"""System message emitted when a task completes, fails, or is stopped.
830+
831+
Subclass of SystemMessage: existing ``isinstance(msg, SystemMessage)`` and
832+
``case SystemMessage()`` checks continue to match. The base ``subtype``
833+
and ``data`` fields remain populated with the raw payload.
834+
"""
835+
836+
task_id: str
837+
status: TaskNotificationStatus
838+
output_file: str
839+
summary: str
840+
uuid: str
841+
session_id: str
842+
tool_use_id: str | None = None
843+
usage: TaskUsage | None = None
844+
845+
780846
@dataclass
781847
class ResultMessage:
782848
"""Result message with cost and usage information."""

tests/test_message_parser.py

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
AssistantMessage,
99
ResultMessage,
1010
SystemMessage,
11+
TaskNotificationMessage,
12+
TaskProgressMessage,
13+
TaskStartedMessage,
1114
TextBlock,
1215
ThinkingBlock,
1316
ToolResultBlock,
@@ -277,6 +280,197 @@ def test_parse_valid_system_message(self):
277280
assert isinstance(message, SystemMessage)
278281
assert message.subtype == "start"
279282

283+
def test_parse_task_started_message(self):
284+
"""Test parsing a task_started system message yields a TaskStartedMessage."""
285+
data = {
286+
"type": "system",
287+
"subtype": "task_started",
288+
"task_id": "task-abc",
289+
"tool_use_id": "toolu_01",
290+
"description": "Reticulating splines",
291+
"task_type": "background",
292+
"uuid": "uuid-1",
293+
"session_id": "session-1",
294+
}
295+
message = parse_message(data)
296+
assert isinstance(message, TaskStartedMessage)
297+
assert message.task_id == "task-abc"
298+
assert message.description == "Reticulating splines"
299+
assert message.uuid == "uuid-1"
300+
assert message.session_id == "session-1"
301+
assert message.tool_use_id == "toolu_01"
302+
assert message.task_type == "background"
303+
304+
def test_parse_task_started_message_optional_fields_absent(self):
305+
"""task_started with no optional fields should still parse, optionals set to None."""
306+
data = {
307+
"type": "system",
308+
"subtype": "task_started",
309+
"task_id": "task-abc",
310+
"description": "Working",
311+
"uuid": "uuid-1",
312+
"session_id": "session-1",
313+
}
314+
message = parse_message(data)
315+
assert isinstance(message, TaskStartedMessage)
316+
assert message.tool_use_id is None
317+
assert message.task_type is None
318+
319+
def test_parse_task_progress_message(self):
320+
"""Test parsing a task_progress system message yields a TaskProgressMessage."""
321+
data = {
322+
"type": "system",
323+
"subtype": "task_progress",
324+
"task_id": "task-abc",
325+
"tool_use_id": "toolu_01",
326+
"description": "Halfway there",
327+
"usage": {
328+
"total_tokens": 1234,
329+
"tool_uses": 5,
330+
"duration_ms": 9876,
331+
},
332+
"last_tool_name": "Read",
333+
"uuid": "uuid-2",
334+
"session_id": "session-1",
335+
}
336+
message = parse_message(data)
337+
assert isinstance(message, TaskProgressMessage)
338+
assert message.task_id == "task-abc"
339+
assert message.description == "Halfway there"
340+
assert message.usage == {
341+
"total_tokens": 1234,
342+
"tool_uses": 5,
343+
"duration_ms": 9876,
344+
}
345+
assert message.last_tool_name == "Read"
346+
assert message.tool_use_id == "toolu_01"
347+
assert message.uuid == "uuid-2"
348+
assert message.session_id == "session-1"
349+
350+
def test_parse_task_notification_message(self):
351+
"""Test parsing a task_notification system message yields a TaskNotificationMessage."""
352+
data = {
353+
"type": "system",
354+
"subtype": "task_notification",
355+
"task_id": "task-abc",
356+
"tool_use_id": "toolu_01",
357+
"status": "completed",
358+
"output_file": "/tmp/out.md",
359+
"summary": "All done",
360+
"usage": {
361+
"total_tokens": 2000,
362+
"tool_uses": 7,
363+
"duration_ms": 12345,
364+
},
365+
"uuid": "uuid-3",
366+
"session_id": "session-1",
367+
}
368+
message = parse_message(data)
369+
assert isinstance(message, TaskNotificationMessage)
370+
assert message.task_id == "task-abc"
371+
assert message.status == "completed"
372+
assert message.output_file == "/tmp/out.md"
373+
assert message.summary == "All done"
374+
assert message.usage == {
375+
"total_tokens": 2000,
376+
"tool_uses": 7,
377+
"duration_ms": 12345,
378+
}
379+
assert message.tool_use_id == "toolu_01"
380+
assert message.uuid == "uuid-3"
381+
assert message.session_id == "session-1"
382+
383+
def test_parse_task_notification_message_optional_fields_absent(self):
384+
"""task_notification with no optional fields (usage, tool_use_id) still parses."""
385+
data = {
386+
"type": "system",
387+
"subtype": "task_notification",
388+
"task_id": "task-abc",
389+
"status": "failed",
390+
"output_file": "/tmp/out.md",
391+
"summary": "Boom",
392+
"uuid": "uuid-3",
393+
"session_id": "session-1",
394+
}
395+
message = parse_message(data)
396+
assert isinstance(message, TaskNotificationMessage)
397+
assert message.status == "failed"
398+
assert message.usage is None
399+
assert message.tool_use_id is None
400+
401+
def test_task_message_backward_compat_isinstance(self):
402+
"""Backward-compat: typed task messages are still SystemMessage instances."""
403+
started_data = {
404+
"type": "system",
405+
"subtype": "task_started",
406+
"task_id": "t1",
407+
"description": "desc",
408+
"uuid": "u1",
409+
"session_id": "s1",
410+
}
411+
progress_data = {
412+
"type": "system",
413+
"subtype": "task_progress",
414+
"task_id": "t1",
415+
"description": "desc",
416+
"usage": {"total_tokens": 1, "tool_uses": 0, "duration_ms": 10},
417+
"uuid": "u2",
418+
"session_id": "s1",
419+
}
420+
notif_data = {
421+
"type": "system",
422+
"subtype": "task_notification",
423+
"task_id": "t1",
424+
"status": "stopped",
425+
"output_file": "/o",
426+
"summary": "s",
427+
"uuid": "u3",
428+
"session_id": "s1",
429+
}
430+
started = parse_message(started_data)
431+
progress = parse_message(progress_data)
432+
notif = parse_message(notif_data)
433+
# isinstance checks against the base class still work
434+
assert isinstance(started, SystemMessage)
435+
assert isinstance(progress, SystemMessage)
436+
assert isinstance(notif, SystemMessage)
437+
# match-case against SystemMessage still works
438+
matched = False
439+
match started:
440+
case SystemMessage():
441+
matched = True
442+
assert matched
443+
444+
def test_task_message_backward_compat_base_fields(self):
445+
"""Backward-compat: subtype and data fields on typed task messages are populated."""
446+
data = {
447+
"type": "system",
448+
"subtype": "task_started",
449+
"task_id": "t1",
450+
"description": "desc",
451+
"uuid": "u1",
452+
"session_id": "s1",
453+
}
454+
message = parse_message(data)
455+
assert isinstance(message, TaskStartedMessage)
456+
# Base class fields still populated for legacy code paths
457+
assert message.subtype == "task_started"
458+
assert message.data == data
459+
assert message.data["task_id"] == "t1"
460+
461+
def test_unknown_system_subtype_yields_generic(self):
462+
"""Unknown system subtypes fall through to generic SystemMessage (not a subclass)."""
463+
data = {"type": "system", "subtype": "some_future_subtype", "foo": "bar"}
464+
message = parse_message(data)
465+
assert isinstance(message, SystemMessage)
466+
# Ensure it's exactly SystemMessage, not one of the typed subclasses
467+
assert type(message) is SystemMessage
468+
assert not isinstance(message, TaskStartedMessage)
469+
assert not isinstance(message, TaskProgressMessage)
470+
assert not isinstance(message, TaskNotificationMessage)
471+
assert message.subtype == "some_future_subtype"
472+
assert message.data == data
473+
280474
def test_parse_assistant_message_inside_subagent(self):
281475
"""Test parsing a valid assistant message."""
282476
data = {

0 commit comments

Comments
 (0)