Commit c03a97a
feat: proper token streaming via standard llm:stream_* contract (#70)
* feat(streaming): implement proper token streaming per provider-streaming-contract
Implements the five-event streaming contract defined in docs/provider-streaming-contract.md.
BREAKING CHANGE: llm:content_block is removed from the production streaming path.
It is replaced by:
- llm:stream_block_start {request_id, block_index, block_type}
- llm:stream_block_delta {request_id, block_index, sequence, text}
- llm:stream_thinking_delta {request_id, block_index, sequence, text}
- llm:stream_block_end {request_id, block_index, block_type}
- llm:stream_aborted {request_id, error:{type, msg}} (partial stream only)
## Ordering Approach
The core challenge: the SDK fires synchronous callbacks but emit() is async.
Fire-and-forget create_task() would allow tasks to run out of order.
Chosen solution: ordered asyncio.Queue consumer
- Per-call _StreamingContext holds an asyncio.Queue
- EventRouter calls stream_ctx.handle_delta() synchronously (FIFO enqueue)
- A single _run_stream_consumer coroutine drains the queue, awaiting each emit
- FIFO queue + single consumer = strict start→deltas→end ordering
- No locks needed (asyncio is single-threaded)
## Changes
### provider.py
- Add _StreamingContext dataclass: per-call state (NOT on self, concurrent-safe)
- handle_delta(): emits block_start on block-type transition, delta event
- close_current_block(): emits block_end for open block
- signal_done(): puts None sentinel to stop consumer
- Add _run_stream_consumer(): ordered async drain, awaits each hooks.emit
- complete(): generate request_id once; compute _use_streaming with
request.metadata['stream'] is False override (identity check per contract)
- _execute_sdk_completion(): create _StreamingContext + consumer task when
use_streaming=True; pass stream_ctx to EventRouter; after idle_event:
error path → emit stream_aborted if partial_emitted, signal_done, await;
success path → close_current_block, signal_done, await consumer
### event_router.py
- Add optional stream_ctx parameter; when present, _emit_progressive_content
routes to stream_ctx.handle_delta() instead of legacy emit_streaming_content
- _extract_delta_text(): also handles dict-style SDK events (tests + robustness)
### config/data/events.yaml
- Enable thinking_content_types: add assistant.reasoning_delta
Required so EventRouter routes reasoning deltas to stream_ctx.handle_delta(text, 'thinking')
## Tests (TDD — written before implementation)
- tests/test_provider_streaming_contract.py (new, 32 tests):
- _StreamingContext state machine (initial state, block tracking, transitions,
sequence numbering, empty-text guard, partial_emitted flag)
- _run_stream_consumer (ordering, graceful degradation, error handling)
- EventRouter→_StreamingContext integration (text deltas, thinking deltas,
transitions, ordering, per-block sequence, single request_id)
- stream_aborted (after partial emit; NOT before any emit)
- Non-streaming path (stream_ctx=None → no stream events)
- use_streaming config + metadata override logic
### Updated tests (intentional behavior change)
- tests/test_contract_streaming.py: Updated 2 tests that tested old behavior:
- test_thinking_content_types_empty_in_events_yaml → now asserts
assistant.reasoning_delta IS in thinking_content_types (enabled for contract)
- test_thinking_delta_does_not_trigger_emit_callback → now verifies that
with stream_ctx provided, thinking deltas route to stream_ctx NOT legacy callback
## Test results
- 32 new contract tests: all pass
- 1305 existing tests: all pass (0 regressions)
- 10 pre-existing failures (SDK not installed, pyrightconfig.json missing): unchanged
🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)
Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
* refactor(streaming): collapse thinking_delta into block_delta carrying block_type
Per provider-streaming-contract.md revision: ONE delta event (llm:stream_block_delta)
for ALL content; llm:stream_thinking_delta is REMOVED. block_type ("text"|"thinking")
is carried on EVERY delta payload so consumers route on block_type, not event name.
Changes:
- provider.py (_StreamingContext.handle_delta): remove conditional event_name;
always emit llm:stream_block_delta with block_type captured by value from the
function argument (NOT self.current_block_type — mutable shared field that could
change before the FIFO consumer task processes the queue item).
- events.yaml: update comments referencing old llm:stream_thinking_delta name.
- tests/test_provider_streaming_contract.py: update all assertions for new contract:
* test_delta_payload: expect block_type in payload
* test_sequence_resets_on_block_transition: filter deltas by block_type
* test_thinking_delta_event_name: check block_delta + block_type=thinking
* test_transition_emits_block_end_then_new_block_start: names[1] is block_delta
* test_thinking_delta_queues_thinking_events: assert block_delta + block_type
* test_thinking_to_text_transition_full_sequence: both deltas are block_delta;
payload assertions verify thinking vs text by block_type field
* TestEventsYamlThinkingTypes: update docstrings
- tests/test_contract_streaming.py: update thinking_delta filter in
test_thinking_delta_routes_to_stream_ctx_not_legacy_callback to match
block_delta with block_type=thinking instead of the removed thinking_delta name.
Result: grep for llm:stream_thinking_delta is clean across all source and test
files. Non-streaming path and block_start/block_end/aborted events unchanged.
🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)
Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
---------
Co-authored-by: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>1 parent 6dbe844 commit c03a97a
5 files changed
Lines changed: 1037 additions & 49 deletions
File tree
- amplifier_module_provider_github_copilot
- config/data
- tests
Lines changed: 7 additions & 5 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
18 | 18 | | |
19 | 19 | | |
20 | 20 | | |
21 | | - | |
22 | | - | |
23 | | - | |
24 | | - | |
25 | | - | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
26 | 28 | | |
27 | 29 | | |
28 | 30 | | |
| |||
Lines changed: 52 additions & 18 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
31 | 31 | | |
32 | 32 | | |
33 | 33 | | |
| 34 | + | |
34 | 35 | | |
35 | 36 | | |
36 | 37 | | |
| |||
42 | 43 | | |
43 | 44 | | |
44 | 45 | | |
45 | | - | |
| 46 | + | |
| 47 | + | |
46 | 48 | | |
47 | 49 | | |
48 | 50 | | |
49 | | - | |
| 51 | + | |
50 | 52 | | |
51 | 53 | | |
52 | 54 | | |
53 | 55 | | |
54 | | - | |
| 56 | + | |
55 | 57 | | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
56 | 61 | | |
| 62 | + | |
57 | 63 | | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
58 | 67 | | |
59 | 68 | | |
60 | 69 | | |
61 | 70 | | |
62 | 71 | | |
| 72 | + | |
| 73 | + | |
63 | 74 | | |
64 | 75 | | |
65 | 76 | | |
| |||
88 | 99 | | |
89 | 100 | | |
90 | 101 | | |
91 | | - | |
| 102 | + | |
| 103 | + | |
92 | 104 | | |
93 | 105 | | |
94 | 106 | | |
| |||
101 | 113 | | |
102 | 114 | | |
103 | 115 | | |
104 | | - | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
105 | 120 | | |
106 | 121 | | |
107 | 122 | | |
| |||
112 | 127 | | |
113 | 128 | | |
114 | 129 | | |
| 130 | + | |
115 | 131 | | |
116 | 132 | | |
117 | 133 | | |
| |||
269 | 285 | | |
270 | 286 | | |
271 | 287 | | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
272 | 294 | | |
273 | | - | |
274 | | - | |
275 | | - | |
276 | | - | |
277 | | - | |
278 | | - | |
279 | | - | |
280 | | - | |
281 | | - | |
282 | | - | |
283 | | - | |
284 | | - | |
285 | | - | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
0 commit comments