Summary
The SDK can leave consumers with stale active background-task state because terminal background-task completion is not consistently exposed as a typed task-lifecycle message.
In live testing with claude-agent-sdk==0.2.87 and bundled CLI 2.1.150, background Bash tasks sometimes finish by emitting only:
SystemMessage subtype=task_updated data.patch.status=completed
with no typed:
TaskNotificationMessage status=completed
Applications that track active task IDs from TaskStartedMessage and clear them only on TaskNotificationMessage then believe a finished task is still active.
Why it matters
ClaudeSDKClient.receive_messages() is open-ended for the persistent client — it does not stop at ResultMessage (receive_response() is the bounded helper). For background tasks, consumers must keep draining past the first ResultMessage if a task is still running, since the CLI can later emit task-completion events and an automatic follow-up assistant turn. But they also need to know when the task is actually finished.
If task_started is exposed as a typed TaskStartedMessage but terminal completion sometimes arrives only as a generic SystemMessage(task_updated), the lifecycle contract is inconsistent, and consumers using the active set to bound receive_messages() can hang until an outer timeout.
Reproduction / evidence
A harness ran 10 real SDK cases with this runner logic:
if isinstance(message, TaskStartedMessage):
active.add(message.task_id)
elif isinstance(message, TaskNotificationMessage):
active.discard(message.task_id)
elif isinstance(message, ResultMessage) and not active:
break
Results:
PASS 1 simple_no_background
PASS 2 background_immediate_then_auto_followup
FAIL 3 background_wait_with_taskoutput
PASS 4 background_fail_immediate
PASS 5 two_background_tasks_immediate
FAIL 6 background_read_file_after_completion
PASS 7 background_no_followup_requested
PASS 8 background_longer_immediate
PASS 9 foreground_control
FAIL 10 background_then_explicit_task_output
The failing cases received a final ResultMessage, but the active set was stale because completion arrived as task_updated, not TaskNotificationMessage:
TaskStartedMessage id=bs2r8eew4
SystemMessage subtype=task_updated id=bs2r8eew4 status=completed
AssistantMessage text=BG_WAIT_FINAL
ResultMessage active=['bs2r8eew4']
TIMEOUT active=['bs2r8eew4']
TaskStartedMessage id=b1m21w89v
AssistantMessage tool=TaskOutput input={'task_id': 'b1m21w89v', 'block': True, ...}
SystemMessage subtype=task_updated id=b1m21w89v status=completed
UserMessage tool_result=<retrieval_status>success</retrieval_status>...
AssistantMessage text=EXPLICIT_TASK_OUTPUT_FINAL
ResultMessage active=['b1m21w89v']
TIMEOUT active=['b1m21w89v']
Passing cases emitted both a task_updated and a task_notification, so consumers correctly cleared the active ID.
Root cause
The parser maps:
system/task_started -> TaskStartedMessage
system/task_progress -> TaskProgressMessage
system/task_notification -> TaskNotificationMessage
other system messages -> SystemMessage
So system/task_updated stays a generic SystemMessage, even when its patch carries a terminal status:
{
"type": "system",
"subtype": "task_updated",
"task_id": "b1m21w89v",
"patch": { "status": "completed", "end_time": 1780405729183 }
}
That terminal state is lifecycle data but is not represented by a typed lifecycle message.
Expected behavior
If the SDK emits a typed start event for a task, it should also emit a typed terminal event for the same task_id, regardless of whether completion happened via automatic notification, TaskOutput, or another background-task path.
Preferred approach: expose terminal system/task_updated as a typed message — a TaskUpdatedMessage mirroring the TypeScript SDK's SDKTaskUpdatedMessage — and document the contract so consumers can clear active task IDs on a terminal status from either TaskNotificationMessage or TaskUpdatedMessage.
Environment
claude-agent-sdk==0.2.87
- bundled Claude CLI
2.1.150
Summary
The SDK can leave consumers with stale active background-task state because terminal background-task completion is not consistently exposed as a typed task-lifecycle message.
In live testing with
claude-agent-sdk==0.2.87and bundled CLI2.1.150, backgroundBashtasks sometimes finish by emitting only:with no typed:
Applications that track active task IDs from
TaskStartedMessageand clear them only onTaskNotificationMessagethen believe a finished task is still active.Why it matters
ClaudeSDKClient.receive_messages()is open-ended for the persistent client — it does not stop atResultMessage(receive_response()is the bounded helper). For background tasks, consumers must keep draining past the firstResultMessageif a task is still running, since the CLI can later emit task-completion events and an automatic follow-up assistant turn. But they also need to know when the task is actually finished.If
task_startedis exposed as a typedTaskStartedMessagebut terminal completion sometimes arrives only as a genericSystemMessage(task_updated), the lifecycle contract is inconsistent, and consumers using the active set to boundreceive_messages()can hang until an outer timeout.Reproduction / evidence
A harness ran 10 real SDK cases with this runner logic:
Results:
The failing cases received a final
ResultMessage, but the active set was stale because completion arrived astask_updated, notTaskNotificationMessage:Passing cases emitted both a
task_updatedand atask_notification, so consumers correctly cleared the active ID.Root cause
The parser maps:
So
system/task_updatedstays a genericSystemMessage, even when its patch carries a terminal status:{ "type": "system", "subtype": "task_updated", "task_id": "b1m21w89v", "patch": { "status": "completed", "end_time": 1780405729183 } }That terminal state is lifecycle data but is not represented by a typed lifecycle message.
Expected behavior
If the SDK emits a typed start event for a task, it should also emit a typed terminal event for the same
task_id, regardless of whether completion happened via automatic notification,TaskOutput, or another background-task path.Preferred approach: expose terminal
system/task_updatedas a typed message — aTaskUpdatedMessagemirroring the TypeScript SDK'sSDKTaskUpdatedMessage— and document the contract so consumers can clear active task IDs on a terminal status from eitherTaskNotificationMessageorTaskUpdatedMessage.Environment
claude-agent-sdk==0.2.872.1.150