Repository navigation
Add regression test for invisible provider stream-error retries #21
Description
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
parentObservationIdresolved to no exported observation (run 1: observationf77fe33a072e3752referencing missing parentfe24446b528fc605; run 2: observation4d6d355d58d0c92ereferencing missing parenta688633ecfb6096e). - 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, sostartActiveGenerationStepupdates the existing message-keyed step in place instead of starting a second span (src/langfuse.ts). traceGenerationsettles andend()s that single span once (deduped viatracedGenerationIds/generationSpansByMessageId), stamped with the successful attempt's usage. (The aborted attempt's usage never reaches the message record — opencode only applies usage atstep-finish, which the aborted attempt never emits — so it is not observable by any plugin.)- Every span reachable through
withObservationParent(generationSpansByMessageId,activeGenerationSteps,generationParentSpans) isend()ed either at settlement or byendActiveGenerationStepsonsession.idle, which runs beforeforceFlush— 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:
- user message; assistant
message.updatedat creation (notime.completed); attempt-1 partial text part session.status{type: "retry"}— the only signal the plugin ever gets for the aborted attempt- re-delivered
message.updatedfor the same message; attempt-2 parts re-streamed - the first tool after the retry window (part events +
tool.execute.before/after) — the observation that orphaned on 0.1.0 - a step-finish-shaped
message.updated(usage/finish present,time.completedabsent), then the completedmessage.updateddelivered twice (dedupe path) - a tool reported only through the execution hooks after the duplicate completion (session-keyed parent fallback)
- a follow-up generation, then
session.idleflush
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
parentSpanIdresolves 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 intraceGeneration(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:checkclean.
Activity
bezbac commentedon Aug 18, 2026
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.
DavidTraina commentedon Aug 18, 2026
@bezbac here you go! #30