Skip to content

Add regression test for invisible provider stream-error retries #21

Closed

Description

@DavidTraina

Fix/test branch with the change described below: https://github.com/DavidTraina/opencode-observability-plugin/tree/fix/stream-error-retry-generation-drop

Summary

Test-only PR. It pins that the current lifecycle keeps every generation exported and every tool observation's parent resolvable when a provider stream error is retried invisibly — i.e. the way opencode 1.18.3 actually does it: the session processor retries internally (Effect.retry(SessionRetry.policy(...)) in packages/opencode/src/session/processor.ts) and re-streams the same assistant message. The plugin receives no session.error and no lifecycle event for the aborted attempt — only a session.status event with {type: "retry"} (which the plugin does not handle) — so the retry must be absorbed entirely by the message.updated / message.part.updated / tool.execute.* flow.

Background: what 0.1.0 did on this path

Observed on plugin 0.1.0 (self-hosted Langfuse v4.10, opencode 1.18.3 non-interactive opencode run --format json, experimental.openTelemetry: true), in 2 of 2 independent runs that hit a provider retry:

  • Exactly one orphaned TOOL observation per trace whose parentObservationId resolved to no exported observation (run 1: observation f77fe33a072e3752 referencing missing parent fe24446b528fc605; run 2: observation 4d6d355d58d0c92e referencing missing parent a688633ecfb6096e).
  • Each orphan sat immediately after the trace's only multi-second gap (6.5 s / 9.2 s) in an otherwise ~5 ms-spaced generation chain — exactly where the stream error + backoff + retry occurred.
  • The session's terminal aggregate usage exceeded the summed usage of the exported generations by almost exactly two context-sized calls.
  • Not a flush race: the orphans occurred 43–88 s before trace end and later observations exported fine.

Mechanism on 0.1.0: generation lifecycle state was keyed by sessionID only, and tools parented via activeGenerationSteps.get(s) ?? generationParentSpans.get(s). A generation span that is started but never end()ed is never exported by the OTel span processor, yet could still be handed out as the next tool's parent — producing a dangling parentObservationId. Related history: #9 / #16 (tool parenting), #3 (turn-interruption settlement), #15 (tool timeout ending), #18 (release gap).

Analysis of main

I traced the current lifecycle against the 1.18.3-shaped event stream and could not reproduce a leak, mis-parent, or double emission — the 0.2.0 rework covers the internal retry path:

  • The re-streamed message reuses its assistantMessageID, so startActiveGenerationStep updates the existing message-keyed step in place instead of starting a second span (src/langfuse.ts).
  • traceGeneration settles and end()s that single span once (deduped via tracedGenerationIds / generationSpansByMessageId), stamped with the successful attempt's usage. (The aborted attempt's usage never reaches the message record — opencode only applies usage at step-finish, which the aborted attempt never emits — so it is not observable by any plugin.)
  • Every span reachable through withObservationParent (generationSpansByMessageId, activeGenerationSteps, generationParentSpans) is end()ed either at settlement or by endActiveGenerationSteps on session.idle, which runs before forceFlush — so a tool observation can no longer reference a never-exported span.

Since the defect is fixed at main, this PR adds only a regression test so the invariant stays pinned.

The test

exports one resolvable generation when a provider stream error is retried invisibly simulates the full 1.18.3-shaped stream:

  1. user message; assistant message.updated at creation (no time.completed); attempt-1 partial text part
  2. session.status {type: "retry"} — the only signal the plugin ever gets for the aborted attempt
  3. re-delivered message.updated for the same message; attempt-2 parts re-streamed
  4. the first tool after the retry window (part events + tool.execute.before/after) — the observation that orphaned on 0.1.0
  5. a step-finish-shaped message.updated (usage/finish present, time.completed absent), then the completed message.updated delivered twice (dedupe path)
  6. a tool reported only through the execution hooks after the duplicate completion (session-keyed parent fallback)
  7. a follow-up generation, then session.idle flush

and asserts:

  • the exact exported span set (no dropped and no duplicated generations),
  • exactly one generation for the retried message, spanning message creation → completion (i.e. covering the error + backoff + retry window), non-error status, usage from the successful attempt,
  • every exported span's parentSpanId resolves to an exported span, and both tool observations parent to the retried generation.

Verification

  • pnpm run test:integration: 14/14 pass.
  • Mutation check: removing the step.span.end(...) call in traceGeneration (recreating the 0.1.0-shaped never-ended span) makes the new test fail on the parent-resolution and span-set assertions.
  • pnpm run format:check clean.

Activity

  1. bezbac commented on Aug 18, 2026

    @bezbac
    Member

    Hi @DavidTraina, happy to see you contributing here! I just enabled PRs on this repository. Since you already have a fork with a patch, could you open a PR for it? I think having a regression test here wouldn’t hurt.

  2. DavidTraina commented on Aug 18, 2026

    @DavidTraina
    ContributorAuthor

    Hi @DavidTraina, happy to see you contributing here! I just enabled PRs on this repository. Since you already have a fork with a patch, could you open a PR for it? I think having a regression test here wouldn’t hurt.

    @bezbac here you go! #30

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions