feat: end a task's stream when its worker is done, and stream the bridge's answer - #62
Conversation
A `message/stream` only ended when the worker published a final status and closed the event bus itself. A worker that just wrote storage — the pydantic-ai bridge among them — left the stream open for good: the task completed in storage, the client received nothing and never returned. Once an operation returns, the worker now reads the task's state back from storage and, if it is final (or waiting on the client), publishes it and closes the stream; the failure path does the same as before. Workers that already publish and close see no duplicate: nothing is subscribed by then. `publish_status` and `publish_artifact` give workers a plain way to report, and the in-memory event bus drops a subscriber whose connection is gone rather than letting it break the worker. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The bridge publishes `working` when it starts, the model's text as chunks of the answer's artifact while it is written (through `Agent.iter`, on every pydantic-ai release the bridge supports), and the whole artifact as the last chunk under the same id — then the worker's end-of-stream follows. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
resubscribe_task currently doesn’t treat input-required/auth-required as stream-ending states, so resubscribe can hang indefinitely now that workers may close streams in those states.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR improves SSE streaming reliability in FastA2A by ensuring task streams end even when workers only write to storage (not the event bus), and by making the pydantic-ai bridge stream model output incrementally as artifact chunks.
Changes:
- Update
Workerto publish a final status (based on storage state) and close the event bus stream after task operations return or fail. - Harden
InMemoryEventBus.emit()to drop disconnected subscribers instead of letting broken SSE connections break publishers. - Add/extend tests and documentation for streaming behavior, including pydantic-ai streaming of artifact chunks.
File summaries
| File | Description |
|---|---|
| tests/test_streaming.py | Adds coverage for streams ending when workers only update storage, and for failure ending behavior. |
| tests/test_pydantic_ai_streaming.py | New test ensuring the pydantic-ai bridge streams answer text as artifact chunks and ends the stream. |
| README.md | Documents expected message/stream behavior and the bridge’s streaming semantics. |
| fasta2a/worker.py | Adds stream-ending logic based on reading final task state from storage; introduces publish helpers for status/artifacts. |
| fasta2a/pydantic_ai/_bridge.py | Emits working, streams model text deltas as artifact chunks, then publishes the final artifact. |
| fasta2a/event_bus.py | Drops dead subscribers on send errors to avoid breaking workers publishing to SSE clients. |
Review details
- Files reviewed: 6/6 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
`tasks/resubscribe` kept waiting on a task at `input-required` or `auth-required` while the worker had already ended that stream. The states a stream ends at are now one definition, `STREAM_ENDING_STATES` in the schema, used by both. A subscriber the event bus drops is closed as well as removed, and the README names every state a stream ends at, on either operation. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A model that does not stream — `FunctionModel` without a `stream_function`, which the bridge tests use — refuses the first streamed request, and pydantic-ai does not let a run continue once a node has tried to stream. The task failed, and the tests polling for `completed` waited for good. The streamed run is attempted first; if the model refuses on entering the first stream, before a request is made, the run is done again without streaming, and that is remembered so the next task does not try. A refusal after streaming has begun is a real error and is raised as one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
The 3.10+ test jobs were hanging in Fixed in ec07a38: the bridge attempts the streamed run and, if the model refuses on entering the first stream (before any request is made), redoes the run without streaming and remembers it for the next tasks. A refusal after streaming has begun is raised as the real error it is. New test Reproduced and verified locally on Python 3.12 with the locked dependencies ( Worth a follow-up: those poll loops in |
…tream Some pydantic-ai releases refuse a streamed request on entering the stream, others on its first read. Keying the fallback on whether the run got anywhere — an event received, or a stream completed — covers both, and still treats a refusal after progress as the real error it is. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Problem
The SSE plumbing from #51 is on
main(event bus,message/stream,tasks/resubscribe), but a stream only ends when the worker publishes a final status and closes the bus itself. A worker that just writes storage never does — and the pydantic-ai bridge'sAgentWorkeris such a worker. Verified againstmainwithagent_to_a2a(Agent(TestModel(...))): the task reachescompletedin storage within a second, while themessage/streamresponse has sent nothing and never ends.Fix
Worker— oncerun_task/cancel_taskreturns, the task's state is read back from storage and, if it is final (completed,canceled,failed,rejected) or waiting on the client (input-required,auth-required), published and the stream closed. The failure path does what it did before. A worker that already published and closed sees no duplicate: nothing is subscribed by then. Newpublish_status/publish_artifacthelpers give workers a plain way to report progress.InMemoryEventBus.emit— drops a subscriber whose connection is gone (BrokenResourceError/ClosedResourceError) instead of letting a disconnected SSE client break the worker.workingwhen it starts, the model's text as chunks of the answer's artifact while it is written (append=True, viaAgent.iter, available on every pydantic-ai release the bridge supports), and the whole artifact as the last chunk under the sameartifact_id. WithTestModel(custom_output_text='hello streaming world')the stream is now: task → working →'hello '→'streaming '→'world'→ whole artifact (last chunk) → completed, and ends.Tests and docs
tests/test_streaming.py: a worker that only writes storage ends its stream withcompleted; one that raises ends it withfailed. The existing echo-worker test is unchanged (still exactly three events).tests/test_pydantic_ai_streaming.py: the bridge streams the answer and ends.Succeeds the closed #43, on top of the event_bus design rather than beside it.
🤖 Generated with Claude Code