Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ All notable changes to the **OpenCode Go BYOK Provider** extension are documente

### Fixed

- **`[Streaming]` Idle-stalled streams before first content are retried too (#179).** The pre-content one-shot stream retry from #178 now also covers the 2-minute idle-guard abort (`stalled-retry` summary; both failure modes share a single retry budget via the renamed `isStreamFailureRetry` flag — worst case exactly one extra attempt, never two). The stall error now points users at `streamIdleTimeoutSeconds` for models with long silent reasoning pauses. Documented in `docs/issues/77-20260822-stream-stall-resilience.md`.

- **`[Streaming]` Truncated streams before first content are retried; byte-only dead streams no longer succeed silently (#177).** `isStreamTruncated` required extracted content, so a `[DONE]`-transport stream that received bytes but ended with no `[DONE]`, no `finish_reason`, and zero extractable parts was treated as success — an empty reply with no error. The gate is dropped (bytes + missing terminator = abnormal), and the engine now transparently retries such a request once (`truncated-retry` summary, internal flag — safe because nothing was reported to VS Code, so no duplicated chat text). Content-emitting truncations still fail with the clear error from #170, which now also carries the `x-opencode-request` id for support correlation. Documented in `docs/issues/76-20260822-truncated-stream-resilience.md`.

- **`[Responses]` Muse Spark context window now updates correctly.** The Responses API nests usage under `response.usage` on the `response.completed` event, but the usage parser only checked top-level `usage` — so prompt, completion, total, and cached tokens were silently dropped for Muse Spark, keeping the Copilot Chat context window at 0%. The parser now falls back to `response.usage` and also reads `input_tokens_details.cached_tokens` alongside the existing OpenAI `prompt_tokens_details.cached_tokens` field. Three unit tests added covering the Responses nested shape, top-level precedence, and the existing OpenAI shape.

- **`[Chat]` Streams no longer end silently when the gateway drops the connection mid-response.** A chat could appear to "stop working" with no error: the engine only ended a stream on connection close or user cancellation, so (a) a gateway that kept the connection alive after its `data: [DONE]` terminator left the request hanging until the 2-minute idle timeout (user manually cancelled → silent), and (b) a connection that closed _without_ `[DONE]` (proxy reset, upstream crash, truncated payload) was treated as a successful empty response. `parseServerSentEvent` (`src/transports/sse.ts`) now fires an `onDone` callback on `data: [DONE]` and the engine breaks the read loop immediately on it (prompt completion instead of waiting on a lingering socket); a new `isStreamTruncated` check throws a clear `OpenCodeRequestError` ("stopped sending data before the response was complete… try again / check your connection, VPN, or firewall") when the stream closed with neither `[DONE]` nor a captured `finish_reason` after content was already received. Unit tests added in `src/test/sse.test.ts`.
Expand Down
2 changes: 2 additions & 0 deletions docs/devlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -1973,6 +1973,8 @@ rg -n "sk-[A-Za-z0-9]|apiKey.*[A-Za-z0-9]{20,}|Authorization: Bearer [A-Za-z0-9]

| Date | Version | Summary |
| ---------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2026-08-22 | fix/stream-stall-resilience | Stream-stall resilience (#179): pre-content one-shot retry generalized to idle stalls (`isStreamFailureRetry`, shared budget with truncation retry), stall error points at `streamIdleTimeoutSeconds`. Issue #179, PR #180, doc `docs/issues/77-20260822-stream-stall-resilience.md`. |
| 2026-08-22 | fix/truncated-stream-resilience | Truncated-stream resilience (#177): `isStreamTruncated` no longer requires extracted content (byte-only dead streams were silent empty replies); engine retries once when truncation happens before any content is emitted (`truncated-retry` summary, internal flag — no duplicated chat parts); truncation error carries `x-opencode-request` id. Issue #177, PR #178, doc `docs/issues/76-20260822-truncated-stream-resilience.md`. |
| 2026-08-21 | docs | Docs sync for PR wave #157–#172: created issues 72 (PR #164 think-tag force-strip) + 73 (#162 GLM 5.3) + 74 (PR #157 bug-hunt) + features 18 (Muse Spark 1.2); features/02 + issues/36 updated; 3 missing CHANGELOG [Unreleased] entries added (PR #157, #160, #164); devlog refreshed. Open PRs #161, #170 tracked. |
| 2026-08-13 | refactor/thinking-request-modules | Thinking refactor (per-provider strategy classes + single VS Code per-model config authority + removed globalState shadow + `effectiveModelId`) + request module split (`src/request/`) + Windows lint fixes (`.cmd` shims + `.gitattributes` LF). 6 commits. CHANGELOG [Unreleased] updated. |
| 2026-06-13 | docs | Deep audit — all 4 🟢 Active docs verified against codebase + git history + CHANGELOG. All marked ✅ Solved: issue #19 (PR #15 merged), references #01 (research complete), architecture #01 (living ref complete), issue #01 (all code fixed v0.1.9/v0.1.10, remaining tool-call loop is model behavior not code bug). 0 Active docs remain. |
Expand Down
59 changes: 59 additions & 0 deletions docs/issues/76-20260822-truncated-stream-resilience.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
**Status:** 🟢 Fix PR open
**Fix PR:** [#178](https://github.com/ltmoerdani/opencode-copilot-chat/pull/178)

# Truncated streams: silent empty replies + no retry before first content

**Topic:** chat / transport / streaming / resilience
**Updated:** 2026-08-22
**Tags:** #chat #transport #streaming #bug #resilience

---

## Problem

Field report of the #170 truncation detector firing in the wild:

````text
OpenCode Go response stream ended before completion (no [DONE] or finish_reason
after 6349 bytes / 30 events).
```text

Detection works, but two sibling gaps remained:

1. **Silent empty reply.** `isStreamTruncated()` required `extractedPartCount > 0`,
so a `[DONE]`-transport stream that received bytes but ended with no
`[DONE]`, no `finish_reason`, and zero extractable parts counted as
*success* — the user saw an empty answer with no error.
2. **No recovery before first content.** Transient fetch errors and transient
5xx are retried by the engine, but a gateway drop before the first
extractable part failed the whole turn with no retry.

## Root cause

The `extractedPartCount > 0` gate conflated "nothing usable arrived" with
"nothing abnormal happened". For a `[DONE]` transport the terminator's absence
is itself the abnormality signal: a healthy OpenCode stream always ends with
`data: [DONE]`, so bytes-received + no `[DONE]` + no `finish_reason` is a cut
stream whether or not any part had been extracted yet.

## Fix

- **`src/transports/sse.ts`** — drop the `extractedPartCount > 0` gate from
`isStreamTruncated`.
- **`src/transports/engine.ts`** — when truncation is detected with
`extractedPartCount === 0` (not cancelled, not already a retry), log
`[retry] stream truncated before any content (…); retrying once…`, record a
`truncated-retry` summary for the dead attempt, and re-run the request once
via an internal `isTruncationRetry` options flag (`src/transports/
streamParts.ts`). Zero parts were reported to VS Code, so no chat content can
be duplicated. Content-emitting truncations still throw (retry would
duplicate visible text) — the user message now adds that a single resend
usually succeeds and carries the `x-opencode-request` id.
- **`src/core/transport.ts`** — `abortedReason` union gains `"truncated-retry"`.

## Verification

- `npm run lint` all 7 checks green; `sse.test.ts` updated (byte-only dead
stream flags; no-bytes stream still doesn't).
- `npm run test-retry` mock-server E2E 7/7.
````
48 changes: 48 additions & 0 deletions docs/issues/77-20260822-stream-stall-resilience.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
**Status:** 🟢 Fix PR open
**Fix PR:** [#180](https://github.com/ltmoerdani/opencode-copilot-chat/pull/180)
**Related:** #178 (truncated-stream resilience — same retry mechanism, generalized here)

# Idle-stalled streams: no recovery before first content, no guidance on the timeout

**Topic:** chat / transport / streaming / resilience
**Updated:** 2026-08-22
**Tags:** #chat #transport #streaming #bug #resilience

---

## Problem

Field report of the #170 idle guard firing in the wild:

```text
OpenCode Go stream stalled for 2m 0s without new data.
```

The guard itself is correct (stops Copilot hanging forever), but:

1. **No retry before first content.** A half-dead connection that stops
delivering frames before the first extractable part failed the whole turn
immediately. When nothing was reported to VS Code yet, a one-shot retry is
safe (no duplicated chat content) and recovers transient stalls.
2. **No guidance for legitimate long pauses.** Reasoning models that pause
silently server-side can exceed 2 minutes with zero SSE frames; the error
said nothing about `streamIdleTimeoutSeconds`.

## Fix

- **`src/transports/streamParts.ts`** — `isTruncationRetry` renamed to
`isStreamFailureRetry`: both failure modes (truncation, idle stall) share a
single one-shot retry budget, so worst case is exactly one extra attempt.
- **`src/transports/engine.ts`** — when the idle guard fires with
`extractedPartCount === 0` (not cancelled, not already retried), log
`[retry] stream stalled before any content (…); retrying once…`, record a
`stalled-retry` summary for the dead attempt, and re-run once. A model that
legitimately pauses longer than the timeout stalls again and fails with the
same error — bounded extra latency, no loops. The stall error message now
points at `streamIdleTimeoutSeconds`. `extractedPartCount` hoisted to
function scope so the catch block can consult it.
- **`src/core/transport.ts`** — `abortedReason` union gains `"stalled-retry"`.

## Verification

- `npm run lint` all 7 checks green; `npm run test-retry` E2E passed.
2 changes: 1 addition & 1 deletion src/core/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,6 @@ export interface TransportRequestSummary {
/** Credits for VS Code session cost (1 credit = $0.01). */
copilotCredits?: number;
rateLimitSummary?: string;
abortedReason?: "request-timeout" | "stream-idle-timeout" | "cancelled";
abortedReason?: "request-timeout" | "stream-idle-timeout" | "cancelled" | "truncated-retry" | "stalled-retry";
errorMessage?: string;
}
7 changes: 5 additions & 2 deletions src/test/sse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,13 @@ describe("isStreamTruncated", () => {
);
});

it("does not flag an empty stream (no content extracted)", () => {
it("flags a stream that received bytes but extracted no parts and saw no [DONE]/finish_reason", () => {
// Bytes arrived, so the connection worked — a healthy OpenCode stream
// always ends with [DONE]. Bytes-without-parts + no terminator is an
// abnormal cut, not a silent success (empty-reply bug).
assert.equal(
isStreamTruncated({ usesDoneSentinel: true, sawDone: false, finishReason: undefined, extractedPartCount: 0, totalBytes: 100 }),
false,
true,
);
});

Expand Down
41 changes: 37 additions & 4 deletions src/transports/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ export async function streamOpenCodeResponse(options: StreamOpenCodeResponseOpti
let firstByteAt: number | undefined;
const usageSummary: RequestUsageSummary = {};
let abortReason: "request-timeout" | "stream-idle-timeout" | "cancelled" | undefined;
// Parts reported to VS Code so far — shared between the stream loop and the
// catch block so the one-shot failure retries know whether anything
// user-visible was already emitted (retrying after that would duplicate it).
let extractedPartCount = 0;
let responseStatus: number | undefined;
let responseContentType: string | undefined;
let emittedSummary = false;
Expand Down Expand Up @@ -348,7 +352,6 @@ export async function streamOpenCodeResponse(options: StreamOpenCodeResponseOpti
// Diagnostic: collect raw SSE data when response is empty to identify
// format mismatches between gateway output and our extractor (issue #93).
const rawSseData: unknown[] = [];
let extractedPartCount = 0;
// Whether we received OpenCode's `data: [DONE]` stream-terminator. A
// successful stream always sends it; its absence at connection close
// signals a truncated/aborted response (see isStreamTruncated below).
Expand Down Expand Up @@ -453,9 +456,23 @@ export async function streamOpenCodeResponse(options: StreamOpenCodeResponseOpti
totalBytes,
})
) {
// Nothing user-visible was emitted yet — a transparent one-shot retry is
// safe (no duplicated chat content). Recovers transient gateway drops
// that kill the stream before the first extractable part.
if (extractedPartCount === 0 && !options.isStreamFailureRetry && !options.token.isCancellationRequested) {
options.output?.appendLine(
`[retry] stream truncated before any content (${String(totalBytes)} bytes / ${String(totalEvents)} events); retrying once…`,
);
emitSummary(totalBytes, totalEvents, {
abortedReason: "truncated-retry",
errorMessage: "stream truncated before any content — retried once",
});
await streamOpenCodeResponse({ ...options, isStreamFailureRetry: true });
return;
}
const requestError = new OpenCodeRequestError(
`${options.providerDisplayName} response stream ended before completion (no [DONE] or finish_reason after ${String(totalBytes)} bytes / ${String(totalEvents)} events).`,
`${options.providerDisplayName} stopped sending data before the response was complete (the connection closed unexpectedly). Your message may be cut off — try sending it again. If this keeps happening, check your connection, VPN, or firewall.`,
`${options.providerDisplayName} response stream ended before completion (no [DONE] or finish_reason after ${String(totalBytes)} bytes / ${String(totalEvents)} events${localRequestId ? `, request ${localRequestId}` : ""}).`,
`${options.providerDisplayName} stopped sending data before the response was complete (the connection closed unexpectedly). Your message may be cut off — try sending it again; a single resend usually succeeds. If this keeps happening, check your connection, VPN, or firewall.`,
);
emitSummary(totalBytes, totalEvents, {
errorMessage: requestError.message,
Expand Down Expand Up @@ -485,9 +502,25 @@ export async function streamOpenCodeResponse(options: StreamOpenCodeResponseOpti
throw requestError;
}
if (abortReason === "stream-idle-timeout") {
// Nothing user-visible was emitted yet — a transparent one-shot retry is
// safe (no duplicated chat content). Covers half-dead connections that
// stop delivering frames before the first extractable part. A model that
// legitimately pauses longer than the idle timeout will stall again on
// the retry and fail with the same error — bounded extra latency.
if (extractedPartCount === 0 && !options.isStreamFailureRetry && !options.token.isCancellationRequested) {
options.output?.appendLine(
`[retry] stream stalled before any content (${formatDuration(options.streamIdleTimeoutMs)} without data); retrying once…`,
);
emitSummary(0, 0, {
abortedReason: "stalled-retry",
errorMessage: "stream stalled before any content — retried once",
});
await streamOpenCodeResponse({ ...options, isStreamFailureRetry: true });
return;
}
const requestError = new OpenCodeRequestError(
`${options.providerDisplayName} stream stalled for ${formatDuration(options.streamIdleTimeoutMs)} without new data.`,
`${options.providerDisplayName} stopped sending stream data for ${formatDuration(options.streamIdleTimeoutMs)}, so the request was cancelled to avoid leaving Copilot stuck.`,
`${options.providerDisplayName} stopped sending stream data for ${formatDuration(options.streamIdleTimeoutMs)}, so the request was cancelled to avoid leaving Copilot stuck. Try sending your message again; if you use a model with long silent reasoning pauses, raise the streamIdleTimeoutSeconds setting for this provider.`,
);
emitSummary(0, 0, {
abortedReason: "stream-idle-timeout",
Expand Down
12 changes: 8 additions & 4 deletions src/transports/sse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,13 @@ export function parseServerSentEvent(
* `null` must not be reported as truncated).
*
* For a `[DONE]` transport, if the connection closed (`done`) without `[DONE]`
* AND without a captured `finish_reason` while we had already extracted content,
* the stream was cut off mid-response (gateway dropped the connection, proxy
* reset, upstream crash) and must not be treated as a silent success.
* AND without a captured `finish_reason` while bytes were received, the stream
* was cut off mid-response (gateway dropped the connection, proxy reset,
* upstream crash) and must not be treated as a silent success. This includes
* streams whose bytes never extracted into parts (keep-alives / unrecognized
* frames only): bytes arrived, so the connection worked — a healthy OpenCode
* stream always ends with `[DONE]`, so its absence here is abnormal regardless
* of how much content was extracted.
*/
export function isStreamTruncated(params: {
usesDoneSentinel: boolean;
Expand All @@ -62,5 +66,5 @@ export function isStreamTruncated(params: {
if (!params.usesDoneSentinel) {
return false;
}
return !params.sawDone && params.finishReason === undefined && params.extractedPartCount > 0 && params.totalBytes > 0;
return !params.sawDone && params.finishReason === undefined && params.totalBytes > 0;
}
6 changes: 6 additions & 0 deletions src/transports/streamParts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ interface StreamOpenCodeResponseOptions extends StreamRequestOptions {
* `finishReason`, so gating there would cause false-positive errors.
*/
usesDoneSentinel: boolean;
/**
* Internal: this invocation is already the one-shot retry for a stream that
* failed before any content was emitted (truncated connection or idle
* stall). Never set by transport adapters.
*/
isStreamFailureRetry?: boolean;
}

interface RequestUsageSummary {
Expand Down
Loading