Synthetic fixtures; deterministic local model. No production system is contacted.
Experiment inputs
Complete-run results
Decision trace
What this experiment demonstrates
Jenya asks where her Belgavi's Book Store order BBS-1042 is. The order-status agent's executor enqueues the Task (SUBMITTED), a WORKING status, one artifact chunk per courier step and then an ending. The handling rules follow what a2a-sdk 1.3.0 did when the same cases were run in-process with Python 3.13 on 2 November 2026: an executor that returns without a final status closes the stream and leaves the task WORKING; an artifact added after complete() (immediately or 0.3 seconds later) was delivered and stored; a cancel stops code at its next await but not a blocking call in asyncio.to_thread; and a cancel() that raises turns into FAILED.
Restart behaviour was not measured. The lab assumes the in-memory store loses the task and a database store keeps it WORKING with nothing resuming it. The terminal-state guard is a wrapper you write yourself, not an SDK option.
Run controlled comparisons
Start with the defaults: 3 chunks, complete(), a streaming client. 6 events are delivered and the task ends COMPLETED, the same sequence the real server streamed.
Now change one thing at a time. Set the ending to returns without a final status: the stream closes and the task is left WORKING, which counts as 1 task left non-terminal but no hang. Set it to never returns: 1 hung stream, and with a blocking client 1 hung call. Then set cancel to arrive after chunk 3: the cancel writes CANCELED and frees it. Turn on the late artifact: 7 events, 1 of them after the terminal state. With a blocking client the result lists 1 artifact while GetTask lists 2. Choose your own guard and it is dropped, back to 6. Set cancel after chunk 1: CANCELED after 4 events, with 0 orphaned steps for awaiting code, 2 for one blocking call in asyncio.to_thread and 1 for a thread that checks a flag. Make cancel() raise NotImplementedError: the task ends FAILED. Set a restart after chunk 2: the in-memory store loses the task, and a database store keeps it WORKING.
Inspect the trace and keep evidence
The table lists each event with its time, source (executor, cancel() or handler), whether it was delivered, dropped by your guard or arrived after the terminal state, and the task state after it. Start trace, Next step, Show all and the slider control how much of the decision trace is visible. Reset restores the defaults and Export JSON downloads the inputs, metrics and trace.
Limits and production translation
Timing is idealised at 200 ms per lookup step, there is one task and one client, and push notifications, multiple subscribers and re-subscription are left out. The measured behaviours are those of a2a-sdk 1.3.0 and can change between releases, so re-run your own tests on upgrade. What carries over: enqueue the Task first and end every path with exactly one terminal or interrupted status, put a timeout on every client call, never emit after the end, make cancel() write CANCELED and make blocking work check a cancel flag, and use a persistent task store plus recovery code if tasks must survive restarts.