Skip to content

Commit 552ec2b

Browse files
LiuwqGitclaudevincentkoc
authored
fix(opencode-go): re-arm idle timer on block-boundary events to prevent false stalled-stream abort (#97128)
* fix(opencode-go): re-arm idle timer on block-boundary events to prevent false stalled-stream abort When the opencode-go model finalizes a tool call and deliberates before the next one, the provider emits real block-boundary SSE events (text_end, thinking_end, toolcall_start, toolcall_end) that prove the socket is alive, but the watchdog's isProviderProgressEvent only returned true for token deltas (text_delta, thinking_delta, toolcall_delta). This caused the idle timer to fire and falsely abort a live stream, replacing a completed answer with a stalled error and dropping the provider's real done event. Fix: include block-boundary events in isProviderProgressEvent so the idle timer is re-armed on any forward-progress provider event. text_start and thinking_start are intentionally excluded because they are synthetic preamble events that should not shorten the first-event window. Closes #96518 Co-Authored-By: Claude Opus 4.8 <[email protected]> * test(opencode-go): satisfy lint in stream regression * test(opencode-go): satisfy lint in stream regression * test(opencode-go): satisfy lint in stream regression --------- Co-authored-by: Claude Opus 4.8 <[email protected]> Co-authored-by: Vincent Koc <[email protected]>
1 parent 4d0f19a commit 552ec2b

2 files changed

Lines changed: 101 additions & 1 deletion

File tree

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

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -713,4 +713,100 @@ describe("createOpencodeGoStalledStreamWrapper", () => {
713713
controller.end();
714714
await consumer;
715715
});
716+
717+
it("must NOT abort a live stream that keeps emitting block-boundary events between deltas", async () => {
718+
// Regression for https://github.com/openclaw/openclaw/issues/96518:
719+
// the idle timer must re-arm on block-boundary events (text_end,
720+
// thinking_end, toolcall_start, toolcall_end), not only on token
721+
// deltas. A stream that keeps producing boundary events between
722+
// deltas is demonstrably alive and must not be aborted.
723+
const { stream: baseStream, controller } = createFakeBaseStream();
724+
let abortCalled = false;
725+
const underlying = vi.fn((_model, _context, options) => {
726+
if (options?.signal) {
727+
options.signal.addEventListener("abort", () => {
728+
abortCalled = true;
729+
});
730+
}
731+
return baseStream;
732+
});
733+
734+
const idleTimeoutMs = 5_000;
735+
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
736+
provider: "opencode-go",
737+
idleTimeoutMs,
738+
});
739+
740+
const downstream = await Promise.resolve(
741+
wrapper({ provider: "opencode-go", id: "glm-4.6" } as any, {} as any, {} as any),
742+
);
743+
expect(downstream).toBeDefined();
744+
if (!downstream) {
745+
return;
746+
}
747+
748+
const received: AnyEvent[] = [];
749+
const consumer = (async () => {
750+
for await (const event of downstream) {
751+
received.push(event);
752+
}
753+
})();
754+
755+
const partial = { role: "assistant", content: [{ type: "text", text: "x" }] };
756+
757+
// Provider starts producing a tool-call turn. The last *delta* arms the idle timer.
758+
controller.emit({ type: "start", partial } as any);
759+
controller.emit({
760+
type: "toolcall_delta",
761+
contentIndex: 0,
762+
delta: "{",
763+
partial,
764+
} as any);
765+
await vi.advanceTimersByTimeAsync(0);
766+
767+
// The model finalizes the tool call and deliberates on the next one,
768+
// emitting real block-boundary events that prove the SSE socket is alive.
769+
// Each gap is < idleTimeoutMs, so a liveness-aware watchdog must stay armed.
770+
await vi.advanceTimersByTimeAsync(3_000);
771+
controller.emit({
772+
type: "toolcall_end",
773+
contentIndex: 0,
774+
toolCall: { name: "f", arguments: "{}" },
775+
partial,
776+
} as any);
777+
await vi.advanceTimersByTimeAsync(3_000);
778+
controller.emit({
779+
type: "toolcall_start",
780+
contentIndex: 1,
781+
partial,
782+
} as any);
783+
784+
// Advance to 5s after the last delta, but only 2s after the last
785+
// boundary event. The idle timer should have been re-armed by the
786+
// boundary events, so it must NOT fire yet.
787+
await vi.advanceTimersByTimeAsync(1_000);
788+
789+
// The provider's completed answer arrives right after.
790+
controller.emit({
791+
type: "done",
792+
reason: "stop",
793+
message: {
794+
...partial,
795+
content: [{ type: "text", text: "final answer" }],
796+
stopReason: "stop",
797+
},
798+
} as any);
799+
controller.end();
800+
await vi.advanceTimersByTimeAsync(0);
801+
await consumer;
802+
803+
const hasDone = received.some((e) => e.type === "done");
804+
const hasStalledError = received.some(
805+
(e) => e.type === "error" && (e as any).error?.stopReason === "error",
806+
);
807+
808+
expect(abortCalled).toBe(false);
809+
expect(hasDone).toBe(true);
810+
expect(hasStalledError).toBe(false);
811+
});
716812
});

extensions/opencode-go/stream-termination.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,11 @@ function isProviderProgressEvent(event: AssistantMessageEvent): boolean {
5555
return (
5656
event.type === "text_delta" ||
5757
event.type === "thinking_delta" ||
58-
event.type === "toolcall_delta"
58+
event.type === "toolcall_delta" ||
59+
event.type === "text_end" ||
60+
event.type === "thinking_end" ||
61+
event.type === "toolcall_start" ||
62+
event.type === "toolcall_end"
5963
);
6064
}
6165

0 commit comments

Comments
 (0)