Repository navigation
Description
Description
A streamed response that is still in progress reports itself as complete: the aggregated response loses its continuation_token on the next event.
ContinuationToken is documented as "Presence on a response indicates the operation is still in progress; None means the operation is complete" (core/agent_framework/_types.py:2439). The OpenAI Responses client only stamps the token on the response.created (in_progress/queued) and response.in_progress events; every other event builds its ChatResponseUpdate with continuation_token=None (_chat_client.py:3527, :3738-3742, :4133).
_process_update, the single aggregation point behind ChatResponse.from_updates / AgentResponse.from_updates, copies the field unconditionally (core/agent_framework/_types.py:2238):
if update.response_id:
response.response_id = update.response_id
if update.created_at is not None:
response.created_at = update.created_at
...
response.continuation_token = update.continuation_token # the only unguarded oneThe eight sibling fields are all guarded, so a token the provider has not withdrawn is cleared by the next event that does not repeat it.
What happened: the caller gets continuation_token is None while the provider is still generating. OpenAIChatClient acts on that reading and drops the token from the caller's options (_chat_client.py:964, :4420), so the next function-loop iteration POSTs instead of retrieving the background response, and a polling loop stops early.
What I expected: the aggregated response keeps the token until the provider stops sending one, which is what _chat_client.py:964 already assumes ("Once the background response completes, drop the continuation_token").
Suggested fix: guard that assignment with is not None, like the fields above it. Providers that mean to end the operation stop sending a token, and the existing None check then does its job.
Code Sample
from agent_framework import ChatResponse, ChatResponseUpdate, Content
events = [
ChatResponseUpdate(
contents=[Content.from_text(text="working")],
response_id="resp_1",
model="gpt-5",
continuation_token={"response_id": "resp_1"},
),
ChatResponseUpdate(contents=[Content.from_text(text="...still working")]),
]
aggregated = ChatResponse.from_updates(events)
print(aggregated.response_id, aggregated.model, aggregated.continuation_token)Output on current main:
resp_1 gpt-5 None
response_id and model survive the second event; the token does not.
Package Versions
agent-framework-core: 1.19.0 (main @ 06e9b489)
Python Version
3.11 (reproduced against a source checkout of python/packages/core)
Metadata
Metadata
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity
github-actions commentedon Sep 29, 2026
🤖 Automated triage reproduction notes (agent-authored — trust but verify)
Agent analysis
The defect reproduces in python/packages/core/agent_framework/_types.py::_process_update at line 2238 when a token-bearing update is followed by an update whose continuation_token defaults to None. Minimal repro: aggregate those two ChatResponseUpdate objects with ChatResponse.from_updates and assert that the first token remains; the assertion receives None.
python/packages/core/tests/core/test_types.py1.19.01.19.0feiiiiii5 commentedon Sep 29, 2026
Reproduced this end to end as well, not only through
from_updates: anAgent.run(..., stream=True)against a provider that stamps the token on its first event only ends withget_final_response().continuation_token is None, whileresponse_idandmodelfrom those same events survive — the same two-updates repro as in the description, one layer down.The change I have ready is the one-line guard in
_process_update(if update.continuation_token is not None:before the assignment, matching the eight fields above it) plus two tests intest_types.py.pytestovertest_types.py,test_agents.pyandtest_workflow_agent.py(588 tests) passes, and the new test fails on main withassert None == {'response_id': 'resp_1'}.Please assign the issue to me if you would like me to open the PR. If someone else is already on it, that is fine — I will leave it alone.
eavanvalkenburg commentedon Sep 30, 2026
The core team will take care of this one.
liwenjie200543 commentedon Sep 30, 2026
I'd like to work on this. The diagnosis matches current main:
_process_update(core/agent_framework/_types.py) is the only unguarded field copy in the aggregation, and the OpenAI Responses consumers (packages/openai/agent_framework_openai/_chat_client.py:964,:4420) read aNonetoken on the aggregate as "operation complete", so a streaming background run gets its token wiped by the next event and the caller re-POSTs instead of polling.One addition to the suggested fix, found while checking the consumers: a bare
is not Noneguard is not enough on its own. After a terminal event (response.completed/incomplete/failed, which carry no token) the stale token would survive on the aggregate and those same consumers would never drop it from the caller's options. My branch pairs the guard with a withdrawal on terminal updates — an update that carriesfinish_reasonclears the token — which keeps the documented "presence means in progress,Nonemeans complete" contract intact in both directions.Reproduction from the issue fails on current main and passes with the fix; regression tests added to
tests/core/test_types.py(367 passed, plus ruff check/format clean). PR coming right after this comment.feiiiiii5 commentedon Sep 30, 2026
Thanks — standing down on my side.
For whoever picks it up, the two-updates repro is the whole thing: a
ChatResponseUpdatethat carriescontinuation_token, followed by one whosecontinuation_tokendefaults toNone, aggregated withChatResponse.from_updatesdrops the token. The triage notes point at_process_updateinpython/packages/core/agent_framework/_types.py, which agrees with where I landed.The line I had ready was the same shape as the existing
response_idhandling a few lines up — keep the last non-Nonetoken rather than overwriting it with the incoming default. I also hit it one layer down through a streamingAgent.run(..., stream=True), whereget_final_response().continuation_tokencomes backNonewhileresponse_idandmodelfrom those same events survive, so it is reachable through the public API and not only through the constructor.No PR from me and nothing in flight, so there is no branch to rebase or close. If the fix stalls or you want the failing test, say the word and I will open one.