Skip to content

Store a streamed background suspension's exchange exactly once - #89

Merged
shibayan merged 2 commits into
masterfrom
fix-streamed-continuation-double-save
Aug 24, 2026
Merged

Store a streamed background suspension's exchange exactly once#89
shibayan merged 2 commits into
masterfrom
fix-streamed-continuation-double-save

Conversation

@shibayan

Copy link
Copy Markdown
Member

Problem

Interrupting a streamed run with allowBackgroundResponses and resuming it with the continuationToken double-saved the exchange:

  1. The suspended run itself persisted the input and the partial updates through its afterRun hook (a suspended stream still finalizes).
  2. The resumed run's context carried the token's replayed input, and its folded response included the carried updates, so it persisted the input and the partial a second time alongside the tail.

The next turn then replayed the question and the partial answer twice. This violated the documented HistoryProvider contract: saveMessages is handed only the messages new to that turn, so implementations append without de-duplicating.

Change

A streaming run that ends suspended (its merged response still carries a continuation token) skips history persistence. Its token replays the caller's input and every update already produced, so the run that finally completes stores the whole exchange in one append — with partial messages merged by the fold rather than split across store entries. This matches the resumed-run semantics the .NET test suite locks in (RunStreamingAsync_WhenResumingStreaming_UsesUpdatesFromInitialRunForContextProviderAndChatHistoryProviderAsync: the resumed run notifies the history provider with the carried-plus-new exchange, exactly once).

The invariant: a run whose outgoing token replays everything it saw stores nothing.

  • Streaming tokens carry the input and the cumulative updates → the suspended run stores nothing; chained suspensions keep deferring until completion.
  • Awaited tokens carry nothing → an awaited suspension keeps storing its own half and the resumed run appends only its tail (existing behavior, unchanged and covered by an existing test).
  • Mixed awaited/streaming chains store each fragment exactly once under the same rule.

The fold takes the continuation token from the latest update (matching Go Response.Update), so a completed background response reliably clears it — a background stream that runs to completion persists normally.

Context providers other than history are still notified of a suspended run, unchanged. A discarded token now leaves nothing in history rather than an unanswered question — the shape the provider contract already documents as desired. The HistoryProvider contract docs now state the suspension behavior explicitly.

Verification

  • Two reproduction tests (single suspension, and a twice-suspended chain) were written first and observed failing — the replayed transcript contained the input twice / three times.
  • After the fix, the implementation was temporarily reverted to confirm both tests fail again, then restored.
  • The existing awaited-suspension test (input stored exactly once) passes unchanged.
  • pnpm check passes (exit code verified).

🤖 Generated with Claude Code

A streamed run interrupted by a background suspension persisted the
input and the partial updates itself, and the resumed run — whose
continuation token replays the input and every update already
produced — persisted the whole exchange again, so the next turn
replayed the question and the partial answer twice. This broke the
HistoryProvider contract that saveMessages only ever receives messages
new to the turn.

A streaming run that ends suspended now skips history persistence: its
token carries the whole exchange, so the run that finally completes
stores it in one append, with partial messages merged by the fold
rather than split across store entries. The invariant is that a run
whose outgoing token replays everything it saw stores nothing. An
awaited run's token carries nothing, so an awaited suspension keeps
storing its own half and the resumed run appends only its tail; mixed
awaited/streaming chains store each fragment exactly once under the
same rule. The fold takes the continuation token from the latest
update, so a completed background response reliably clears it.

Context providers other than history are still notified of a suspended
run, unchanged. A discarded token now leaves nothing in history rather
than an unanswered question, which is what the provider contract
already documented as the desired shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 24, 2026 08:07
@shibayan shibayan added the bug Usage: [PRs], Target: bug fixes and regressions; issues use the Bug issue type label Aug 24, 2026
@github-actions github-actions Bot added core Usage: [Issues, PRs], Target: packages/core agents Usage: [Issues, PRs], Target: single-agent runtime and APIs labels Aug 24, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR prevents duplicate history persistence when streamed background runs are suspended and resumed.

Changes:

  • Defers history persistence for suspended streamed runs.
  • Adds regression tests for single and chained suspensions.
  • Documents suspension persistence behavior.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Summary
packages/core/src/context/context-provider.ts Documents history behavior across suspensions.
packages/core/src/agent/continuation.test.ts Adds streamed suspension persistence regression tests.
packages/core/src/agent/agent.ts Suppresses history persistence for suspended streams. Moderate issue (3 votes): injected context messages may be skipped and cannot be saved by the completing run.
Suppressed comments (1)

packages/core/src/agent/agent.ts:710

  • This condition also matches an early break from a stream after a provider has emitted a continuation token. In that path ResponseStream still invokes onResult with the partial response, and the existing AgentRunStream contract says an abandoned stream persists the exchange as far as it got; this now skips the history append and loses the input/partial response. Pass the stream result's abandoned state through the teardown path and apply this suppression only when the stream ran to the suspension, not when the caller abandoned it.
    const suspendedStream = streamed && response?.continuationToken !== undefined;

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/core/src/agent/agent.ts
A suspended streaming run defers persistence to the run that completes
the exchange, but that run re-enters after context was applied and
injects nothing itself — so with storeContextMessages enabled, the
suspended run's injected context could never be stored. The streaming
token now carries the injected messages, minus replayed history (the
store already holds it, and a long transcript would dwarf the token),
and a resumed run hands them to its providers as the run's context
messages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 24, 2026 08:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Suppressed comments (2)

packages/core/src/agent/agent.ts:715

  • This gate conflates a stream that naturally suspended with one the caller abandoned. If a caller breaks after consuming a token-bearing update, response.continuationToken is still set, so history is skipped even though the AgentRunStream contract says a break persists the exchange as far as it got (agent.ts:60-67). The onResult hook already receives resultCtx.abandoned; carry that state into this decision, or explicitly revise the abandonment/resume contract and its tests.
    // A streaming run that ended suspended does not persist: its continuation token replays the
    // caller's input and every update already produced, so the run that finally completes stores
    // the whole exchange in one append — storing the suspended half here too would hand the next
    // turn the question and the partial answer twice. An awaited run's token carries nothing, so
    // an awaited suspension still stores its own half and the resumed run appends only its tail.
    const suspendedStream = streamed && response?.continuationToken !== undefined;

packages/core/src/context/context-provider.ts:102

  • The new suspension rule conflicts with the existing contract immediately below: breaking from a stream after receiving a continuation-token update is both an abandoned run and a suspended stream, but the latter now skips history while the docs still say every abandoned response is stored. Clarify this exception so callers know that discarding a streamed token intentionally leaves the partial exchange out of history.
 *   A background turn split across a suspension and a resume still appends once: a streaming run
 *   that ends suspended stores nothing (its continuation token replays the whole exchange, and
 *   the run that completes stores it), while an awaited suspension stores its own half and the
 *   resumed run appends only its tail.

@shibayan
shibayan merged commit c72a7de into master Aug 24, 2026
9 checks passed
@shibayan
shibayan deleted the fix-streamed-continuation-double-save branch August 24, 2026 16:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Usage: [Issues, PRs], Target: single-agent runtime and APIs bug Usage: [PRs], Target: bug fixes and regressions; issues use the Bug issue type core Usage: [Issues, PRs], Target: packages/core

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants