Skip to content

Commit b9b88e6

Browse files
committed
fix(opencode-go): treat stream boundaries as liveness
1 parent f163d77 commit b9b88e6

2 files changed

Lines changed: 95 additions & 5 deletions

File tree

extensions/opencode-go/stream-termination.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,82 @@ describe("createOpencodeGoStalledStreamWrapper", () => {
182182
await consumer;
183183
});
184184

185+
it("keeps a live stream open when block boundaries arrive between deltas", async () => {
186+
const { stream: baseStream, controller } = createFakeBaseStream();
187+
let abortCalled = false;
188+
189+
const underlying = vi.fn((_model, _context, options) => {
190+
if (options?.signal) {
191+
options.signal.addEventListener("abort", () => {
192+
abortCalled = true;
193+
});
194+
}
195+
return baseStream;
196+
});
197+
198+
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
199+
provider: "opencode-go",
200+
idleTimeoutMs: 5_000,
201+
});
202+
203+
const downstream = await Promise.resolve(
204+
wrapper({ provider: "opencode-go", id: "glm-4.6" } as any, {} as any, {} as any),
205+
);
206+
expect(downstream).toBeDefined();
207+
if (!downstream) {
208+
return;
209+
}
210+
211+
const received: AnyEvent[] = [];
212+
const consumer = (async () => {
213+
for await (const event of downstream) {
214+
received.push(event);
215+
}
216+
})();
217+
218+
const partial = {
219+
role: "assistant",
220+
content: [{ type: "toolCall", partialJson: "{", arguments: {}, name: "gateway" }],
221+
stopReason: undefined,
222+
};
223+
controller.emit({ type: "start", partial } as any);
224+
controller.emit({ type: "toolcall_delta", contentIndex: 0, delta: "{", partial } as any);
225+
await vi.advanceTimersByTimeAsync(0);
226+
227+
await vi.advanceTimersByTimeAsync(3_000);
228+
controller.emit({
229+
type: "toolcall_end",
230+
contentIndex: 0,
231+
toolCall: { type: "toolCall", id: "call-1", name: "gateway", arguments: {} },
232+
partial,
233+
} as any);
234+
235+
await vi.advanceTimersByTimeAsync(3_000);
236+
controller.emit({ type: "toolcall_start", contentIndex: 1, partial } as any);
237+
238+
await vi.advanceTimersByTimeAsync(4_000);
239+
expect(abortCalled).toBe(false);
240+
241+
controller.emit({
242+
type: "done",
243+
reason: "stop",
244+
message: {
245+
...partial,
246+
content: [{ type: "text", text: "final answer" }],
247+
stopReason: "stop",
248+
},
249+
} as any);
250+
controller.end();
251+
await consumer;
252+
253+
expect(received.some((event) => event.type === "done")).toBe(true);
254+
expect(
255+
received.some(
256+
(event) => event.type === "error" && (event as any).error?.stopReason === "error",
257+
),
258+
).toBe(false);
259+
});
260+
185261
it("uses a longer first-event timeout than the inter-event idle timeout", async () => {
186262
const { stream: baseStream } = createFakeBaseStream();
187263
let abortCalled = false;

extensions/opencode-go/stream-termination.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,14 +51,23 @@ function resolveTimeoutMs(model: unknown, fallbackMs: number): number {
5151
return validTimeoutMs((model as { requestTimeoutMs?: unknown })?.requestTimeoutMs) ?? fallbackMs;
5252
}
5353

54-
function isProviderProgressEvent(event: AssistantMessageEvent): boolean {
54+
function isProviderDeltaEvent(event: AssistantMessageEvent): boolean {
5555
return (
5656
event.type === "text_delta" ||
5757
event.type === "thinking_delta" ||
5858
event.type === "toolcall_delta"
5959
);
6060
}
6161

62+
function isProviderBoundaryLivenessEvent(event: AssistantMessageEvent): boolean {
63+
return (
64+
event.type === "text_end" ||
65+
event.type === "thinking_end" ||
66+
event.type === "toolcall_start" ||
67+
event.type === "toolcall_end"
68+
);
69+
}
70+
6271
function combineAbortSignals(signals: (AbortSignal | undefined)[]): {
6372
signal: AbortSignal;
6473
cleanup(): void;
@@ -200,8 +209,8 @@ function synthesizeMinimalAssistantMessage(
200209
*
201210
* Behavior:
202211
* - Provider-scoped: only applies when `model.provider === options.provider`.
203-
* - Idle-based: the timer covers stream creation, first event delivery, and
204-
* every gap after provider progress begins; if no event arrives within
212+
* - Idle-based: the timer covers stream creation, first provider delta, and
213+
* every gap after provider progress begins; if no live event arrives within
205214
* `idleTimeoutMs`, the wrapper calls `controller.abort()` on the AbortSignal
206215
* injected into the underlying call (so the OpenAI SDK request is genuinely
207216
* interrupted, not just the iterator) and pushes a terminal `error` event
@@ -210,7 +219,8 @@ function synthesizeMinimalAssistantMessage(
210219
* wrapper forwards the event, clears all timers, and ends the stream.
211220
*
212221
* The wrapper never shortens the natural end of a normal completion, because
213-
* provider progress refreshes the idle timer and a terminal event cancels it entirely.
222+
* provider deltas and later block boundaries refresh the idle timer and a terminal event cancels
223+
* it entirely.
214224
*/
215225
export function createOpencodeGoStalledStreamWrapper(
216226
underlying: ProviderStreamFn,
@@ -246,6 +256,7 @@ export function createOpencodeGoStalledStreamWrapper(
246256
let idleTimer: ReturnType<typeof setTimeout> | undefined;
247257
let lastSeenPartial: AssistantMessage | undefined;
248258
let settled = false;
259+
let sawProviderDelta = false;
249260
let baseIterator: AsyncIterator<AssistantMessageEvent> | undefined;
250261

251262
const clearIdleTimer = () => {
@@ -353,7 +364,10 @@ export function createOpencodeGoStalledStreamWrapper(
353364
}
354365
trackPartial(event);
355366
output.push(event);
356-
if (isProviderProgressEvent(event)) {
367+
if (isProviderDeltaEvent(event)) {
368+
sawProviderDelta = true;
369+
armIdleTimer();
370+
} else if (sawProviderDelta && isProviderBoundaryLivenessEvent(event)) {
357371
armIdleTimer();
358372
}
359373
}

0 commit comments

Comments
 (0)