























@@ -713,4 +713,100 @@ describe("createOpencodeGoStalledStreamWrapper", () => {
713713controller.end();
714714await 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});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。