mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-19 17:11:42 -06:00
496c84bf61
* fix: gate diagnostics command to owners (cherry picked from commit170bf72e64) * fix(agent): replace self-wait with deferred release in retained-lock abort cleanup (#96100) * fix(agent): wait for retained session write before releasing held lock on abort * fix(agent): replace self-wait with deferred release in retained-lock abort cleanup * fix(test): reject fallback acquire with SessionWriteLockTimeoutError in active-scope cleanup test * fix(agent): trim retained-lock comments Signed-off-by: sallyom <somalley@redhat.com> --------- Signed-off-by: sallyom <somalley@redhat.com> Co-authored-by: sallyom <somalley@redhat.com> (cherry picked from commit0a042f68df) * fix(gateway): resume channel after pending task recovery (cherry picked from commit6039da3ed6) * fix(gateway): resume channel after pending task recovery (cherry picked from commitecd29fe572) * fix(outbound): ignore empty delivery receipts (#79811) (cherry picked from commit9a735bea03) * fix(agents): guard delivery-evidence attachment recursion against cycles (#97041) * fix(agents): guard delivery-evidence attachment recursion against cycles * fix(agents): guard delivery-evidence attachment recursion against cycles * fix(agents): guard delivery-evidence attachment recursion against cycles --------- Co-authored-by: Pick-cat <266665499+Pick-cat@users.noreply.github.com> Co-authored-by: Vincent Koc <vincentkoc@ieee.org> (cherry picked from commit498567190d) * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> Co-authored-by: Vincent Koc <vincentkoc@ieee.org> (cherry picked from commit552ec2b49d) * fix(model-fallback): don't rethrow provider-side AbortErrors as user cancellations (#90908) * fix(model-fallback): don't rethrow provider-side AbortErrors as user cancellations When the LLM API closes the connection mid-stream, the fetch layer surfaces AbortError("This operation was aborted") with no external abort signal triggered. The old guard `shouldRethrowAbort()` returned false for these errors (because isTimeoutError matched the message), so they fell through to the fallback loop but were never retried — the error propagated up and produced SILENT_REPLY_TOKEN in group sessions, permanently silencing the topic. Replace the guard with a direct check: only rethrow AbortError when the external abort signal is actually set (user/gateway cancellation). Provider-side AbortErrors without an external signal now fall through to the next fallback candidate, giving the system a chance to recover. * fix(cron): forward abort signal into runWithModelFallback Thread the cron executor's abort signal into the shared runWithModelFallback call so that cron timeouts and cancellations stop the fallback chain instead of retrying with the next candidate. Previously, the run callback checked params.abortSignal?.aborted and threw, but runWithModelFallback itself had no signal — so the new guard in model-fallback.ts could not distinguish a caller abort from a provider-side AbortError and would retry silently. Also adds a focused regression test verifying the signal is forwarded. --------- Co-authored-by: Shengting Xie <shengting@openclaw.ai> Co-authored-by: yayu <yayu@yayuMacStudio.local> (cherry picked from commit98ed83f848) * fix(browser): block node routes when sandbox host control is disabled (#97958) (cherry picked from commit2cf765f732) * fix(exec): bind Windows allowlist execution path (#98260) * fix(exec): bind windows allowlist execution path * fix(exec): add windows shadow execution proof * fix(exec): preserve wildcard allowlist behavior * fix(exec): correct blocked plan test fixture (cherry picked from commit3811001d27) * fix(mcp): suppress unhandled error on stderr pipe in stdio transport (#99803) * fix(mcp): suppress unhandled error on stderr pipe in stdio transport When child.stderr is piped to stderrStream without an error handler, a stream-level error (EPIPE, I/O failure) crashes the process. Add a noop error handler before the pipe, consistent with the error handlers already present on stdin and stdout. Co-Authored-By: Claude <noreply@anthropic.com> * test(mcp): add regression test for stderr pipe error suppression Co-Authored-By: Claude <noreply@anthropic.com> * fix(mcp): report stderr stream errors * fix(mcp): report stderr stream errors --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Vincent Koc <vincentkoc@ieee.org> (cherry picked from commit1b84316a91) * Harden macOS SQLite WAL checkpoints (#99067) (cherry picked from commitf7f1be276a) * fix(secrets): suppress unhandled stdout/stderr stream errors in exec resolver (#100521) * fix(secrets): suppress unhandled stdout/stderr stream errors in exec resolver * proof(secrets): add real behavior proof script for exec resolver stream error catch * proof(secrets): replace wrapper with real exec resolver stream error proof * style: apply oxfmt to changed files (cherry picked from commitc9a0783922) * fix(agents): retry transient filesystem races when reading workspace bootstrap files (#100910) * fix(agents): retry transient filesystem races when reading workspace bootstrap files * fix(agents): retry transient boundary resolution --------- Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com> (cherry picked from commitf36d170bc6) * fix(gateway): finish plugin HTTP responses after post-header failures (#102125) * fix(gateway): finish plugin HTTP responses after post-header failures * test(gateway): satisfy plugin HTTP regression lint * fix(gateway): skip ending destroyed plugin responses --------- Co-authored-by: Peter Steinberger <steipete@gmail.com> (cherry picked from commit240d350c7f) * fix(gateway): validate exact custom browser origins (#38290) Co-authored-by: Peter Steinberger <steipete@gmail.com> (cherry picked from commitfa0349aa44) * fix: block unspecified trusted DNS targets (#103075) (cherry picked from commitc70f3d0dae) * fix(channels): make nack callbacks idempotent (#104919) * fix(channels): make nack callbacks idempotent * fix(channels): coalesce overlapping nack callbacks --------- Co-authored-by: Peter Steinberger <steipete@gmail.com> (cherry picked from commit02d307e1b8) * fix(channels): prevent base URL credentials in status output (#107754) * fix(channels): redact credentials in account URLs * fix(channels): sanitize final status summaries (cherry picked from commit210340fe93) * fix(channels): prevent lifecycle listener buildup (#109108) (cherry picked from commit0e1fad711c) * fix(sandbox): use Buffer.byteLength for env var value size limit (#105017) * fix(sandbox): use Buffer.byteLength for env var value size limit validateEnvVarValue checked value.length (UTF-16 code units) against the 32768-byte limit, so multi-byte CJK values like "值".repeat(11000) passed the check despite exceeding 33 KB in UTF-8. Switch to Buffer.byteLength(value, "utf8") so the limit matches the actual byte count the OS and child processes see. * test(sandbox): simplify env byte-limit coverage Co-authored-by: 唐梓夷0668001293 <tang.ziyi@xydigit.com> --------- Co-authored-by: Peter Steinberger <steipete@gmail.com> (cherry picked from commit84fb48c3be) * fix(gateway): guard process.kill ESRCH race in signalVerifiedGatewayPidSync (#109590) * fix(gateway): guard process.kill ESRCH race in signalVerifiedGatewayPidSync A verified gateway process can exit between the argv validation check and the process.kill call, causing an unhandled ESRCH error. Wrap the kill in try-catch and silently swallow ESRCH (process already gone = signal already delivered). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(gateway): explain ESRCH signal race Co-authored-by: 丁宇婷0668001435 <ding.yuting@xydigit.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Peter Steinberger <steipete@gmail.com> (cherry picked from commit853b1a8d11) * fix(litellm): guard loopback hostname auto-allow with isIP to prevent DNS SSRF bypass (#110693) * fix(litellm): guard loopback hostname auto-allow with isIP to prevent DNS bypass The isAutoAllowedLitellmHostname helper auto-enables private-network access for loopback-style hosts. Before this fix, lowered.startsWith("127.") matched DNS hostnames like 127.evil.com, letting remote endpoints bypass the explicit allowPrivateNetwork opt-in — a SSRF risk. Add isIP(host)===4 guard so only literal IPv4 loopback addresses qualify. Same canonical pattern as extensions/slack/src/monitor/relay-source.ts:271 and the codex loopback fix. Co-Authored-By: Claude <noreply@anthropic.com> * test(litellm): cover loopback endpoint policy --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Peter Steinberger <steipete@gmail.com> (cherry picked from commit3d03b60da9) * fix(discord): sustained gateway bursts stop growing memory (#110954) * fix(discord): sustained gateway bursts stop growing memory * fix(discord): contain gateway queue overflow * fix(discord): drop oldest saturated gateway sends Co-authored-by: 张贵萍0668001030 <zhang.guiping@xydigit.com> * fix(discord): surface gateway overflow warnings Co-authored-by: 张贵萍0668001030 <zhang.guiping@xydigit.com> --------- Co-authored-by: Peter Steinberger <steipete@gmail.com> (cherry picked from commit69aeba9d86) * fix(gateway): bound busy channel health by real run age (#103793) * fix(gateway): bound busy channel health by real run age The channel health policy treats a channel as healthy-busy even while disconnected, bounded only by a 25 minute stale ceiling measured from lastRunActivityAt. The run-state heartbeat refreshes lastRunActivityAt every 60 seconds for as long as any run is active, so a run that hangs forever (for example a send blocking on a dead socket after the transport already reported connected:false) keeps that timestamp fresh and the stuck ceiling is never reached. The account is then reported healthy forever by the health monitor, readiness probe, and health CLI, and no restart ever fires. createRunStateMachine now tracks each in-flight run's start time keyed by an opaque run handle and publishes the oldest still-active run's start as activeRunStartedAt. The health policy busy override keys its ceiling off the real run age, so a run stuck longer than the threshold reports stuck and the monitor can restart it. Because the reported start is the oldest active run and advances to the next-oldest as runs complete, a channel churning through many short overlapping runs (activeRuns above 1 across concurrent queue keys) stays healthy; only a genuinely hung run breaches the ceiling. Short and active runs stay healthy and the existing lastRunActivityAt fallback is preserved for snapshots without a start time. * fix(channels): retain run-state callback compatibility Keep the released zero-argument onRunEnd callback source-compatible while allowing internal queue callers to pass a run handle for exact concurrent-run accounting. The compatibility path closes the oldest active run, preserving existing lifecycle behavior for consumers that do not use handles. * fix(channels): keep anonymous runs out of age tracking The zero-argument lifecycle callbacks cannot identify which concurrent run completed, so they must not update the identity-sensitive run start used by channel health. Keep their busy count separately and reserve exact start tracking for the shared queue's handle-aware lifecycle path. * fix(channels): keep tracked runs internal Keep the public run-state lifecycle callbacks unchanged. The channel queue now owns opaque run identity and augments its status updates with the oldest active queue run, so implementation details do not expand the SDK surface. * fix(channels): type queue run start status Keep activeRunStartedAt in the internal status patch type so the queue can publish its private tracked-run age through the existing status sink. * fix(channels): wrap isActive to satisfy unbound-method lint * fix(gateway): gate busy run-age ceiling on disconnected transport (cherry picked from commit18b79d99ab) * fix(deps): update fast-uri past advisory (cherry picked from commit1be9db038f) * fix(release): adapt maintenance-line hardening Backport/adapt18ec9ce8f7,dea1fe1f11,7f32b6c984,1da345e9d3,931ac3e2b5,89780d5a60, andc0d99ed26efor the 2026.6 extended-stable maintenance line. * fix(deps): bump protobufjs to 7.6.5 Backport-adapted froma230f742f2. * test(gateway): cover bounded macOS process probe * chore(release): prepare 2026.6.34 * test(dotenv): share path override environment assertions * fix(release): resolve 2026.6.34 CI blockers --------- Signed-off-by: sallyom <somalley@redhat.com> Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com> Co-authored-by: Peter Lee <li.xialong@xydigit.com> Co-authored-by: sallyom <somalley@redhat.com> Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com> Co-authored-by: Liu Wenyu <117838866+indulgeback@users.noreply.github.com> Co-authored-by: pick-cat <huang.ting3@xydigit.com> Co-authored-by: Pick-cat <266665499+Pick-cat@users.noreply.github.com> Co-authored-by: Vincent Koc <vincentkoc@ieee.org> Co-authored-by: weiqinl <liu.weiqin@xydigit.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: shengting <xieyayu@163.com> Co-authored-by: Shengting Xie <shengting@openclaw.ai> Co-authored-by: yayu <yayu@yayuMacStudio.local> Co-authored-by: Agustin Rivera <31522568+eleqtrizit@users.noreply.github.com> Co-authored-by: cxbAsDev <chen.xianbiao@xydigit.com> Co-authored-by: ooiuuii <al3060388206@gmail.com> Co-authored-by: Masato Hoshino <g515hoshino@gmail.com> Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Co-authored-by: mushuiyu886 <yang.haoyu@xydigit.com> Co-authored-by: Peter Steinberger <steipete@gmail.com> Co-authored-by: Bruno Wowk (Volky) <bruno.wowk@gmail.com> Co-authored-by: Pavan Kumar Gondhi <pavangondhi@gmail.com> Co-authored-by: Glucksberg <80581902+Glucksberg@users.noreply.github.com> Co-authored-by: xingzhou <zhang.guiping@xydigit.com> Co-authored-by: tzy-17 <tang.ziyi@xydigit.com> Co-authored-by: krissding <ding.yuting@xydigit.com> Co-authored-by: lsr911 <liao.shirong@xydigit.com> Co-authored-by: Yuval Dinodia <102706514+yetval@users.noreply.github.com>
813 lines
24 KiB
TypeScript
813 lines
24 KiB
TypeScript
// Opencode Go stream termination wrapper tests cover provider-owned raw SSE
|
|
// boundary behavior for stalled OpenAI-compatible streams.
|
|
import type {
|
|
AssistantMessageEvent,
|
|
AssistantMessageEventStreamContract,
|
|
} from "openclaw/plugin-sdk/llm";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { createOpencodeGoStalledStreamWrapper } from "./stream-termination.js";
|
|
|
|
type AnyEvent = AssistantMessageEvent;
|
|
type StreamLike = AssistantMessageEventStreamContract;
|
|
|
|
interface FakeStreamController {
|
|
emit(event: AnyEvent): void;
|
|
end(): void;
|
|
}
|
|
|
|
function createFakeBaseStream(): {
|
|
stream: StreamLike;
|
|
controller: FakeStreamController;
|
|
getReturnCalls: () => number;
|
|
} {
|
|
const queued: IteratorResult<AnyEvent>[] = [];
|
|
const waiters: ((result: IteratorResult<AnyEvent>) => void)[] = [];
|
|
let finished = false;
|
|
let returnCalls = 0;
|
|
|
|
const iterator: AsyncIterator<AnyEvent> = {
|
|
next(): Promise<IteratorResult<AnyEvent>> {
|
|
if (queued.length > 0) {
|
|
return Promise.resolve(queued.shift()!);
|
|
}
|
|
if (finished) {
|
|
return Promise.resolve({ value: undefined, done: true });
|
|
}
|
|
return new Promise((resolve) => {
|
|
waiters.push(resolve);
|
|
});
|
|
},
|
|
return(): Promise<IteratorResult<AnyEvent>> {
|
|
returnCalls += 1;
|
|
finished = true;
|
|
while (waiters.length > 0) {
|
|
waiters.shift()!({ value: undefined, done: true });
|
|
}
|
|
return Promise.resolve({ value: undefined, done: true });
|
|
},
|
|
};
|
|
|
|
const stream: StreamLike = {
|
|
[Symbol.asyncIterator]() {
|
|
return iterator;
|
|
},
|
|
push() {
|
|
// unused: the wrapper pushes its own events into a separate stream.
|
|
},
|
|
end() {
|
|
// unused: the wrapper ends its own stream.
|
|
},
|
|
result() {
|
|
return Promise.reject(new Error("fake base stream result not used"));
|
|
},
|
|
};
|
|
|
|
const controller: FakeStreamController = {
|
|
emit(event: AnyEvent) {
|
|
const waiter = waiters.shift();
|
|
if (waiter) {
|
|
waiter({ value: event, done: false });
|
|
} else {
|
|
queued.push({ value: event, done: false });
|
|
}
|
|
},
|
|
end() {
|
|
finished = true;
|
|
while (waiters.length > 0) {
|
|
waiters.shift()!({ value: undefined, done: true });
|
|
}
|
|
},
|
|
};
|
|
|
|
return { stream, controller, getReturnCalls: () => returnCalls };
|
|
}
|
|
|
|
function disableAbortSignalAny(): PropertyDescriptor | undefined {
|
|
const descriptor = Object.getOwnPropertyDescriptor(AbortSignal, "any");
|
|
Object.defineProperty(AbortSignal, "any", {
|
|
configurable: true,
|
|
value: undefined,
|
|
});
|
|
return descriptor;
|
|
}
|
|
|
|
function restoreAbortSignalAny(descriptor: PropertyDescriptor | undefined): void {
|
|
if (descriptor) {
|
|
Object.defineProperty(AbortSignal, "any", descriptor);
|
|
} else {
|
|
Reflect.deleteProperty(AbortSignal, "any");
|
|
}
|
|
}
|
|
|
|
describe("createOpencodeGoStalledStreamWrapper", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("aborts underlying stream when progress stalls after first delta (raw SSE boundary)", async () => {
|
|
// Arrange: a fake base stream that emits a start + one text_delta, then stalls.
|
|
const { stream: baseStream, controller } = createFakeBaseStream();
|
|
void baseStream;
|
|
let abortCalled = false;
|
|
const capturedSignals: AbortSignal[] = [];
|
|
|
|
const underlying = vi.fn((_model, _context, options) => {
|
|
if (options?.signal) {
|
|
capturedSignals.push(options.signal);
|
|
options.signal.addEventListener("abort", () => {
|
|
abortCalled = true;
|
|
});
|
|
}
|
|
return baseStream;
|
|
});
|
|
|
|
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
|
|
provider: "opencode-go",
|
|
idleTimeoutMs: 5_000,
|
|
});
|
|
|
|
const downstream = await Promise.resolve(
|
|
wrapper({ provider: "opencode-go", id: "deepseek-v4-flash" } as any, {} as any, {} as any),
|
|
);
|
|
expect(downstream).toBeDefined();
|
|
if (!downstream) {
|
|
return;
|
|
}
|
|
|
|
// Drain wrapper events in the background.
|
|
const received: AnyEvent[] = [];
|
|
const consumer = (async () => {
|
|
for await (const event of downstream) {
|
|
received.push(event);
|
|
}
|
|
})();
|
|
|
|
// Emit a start + one text delta — that proves the provider side has produced tokens.
|
|
const partial = {
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "hi" }],
|
|
stopReason: undefined,
|
|
};
|
|
controller.emit({ type: "start", partial } as any);
|
|
controller.emit({
|
|
type: "text_delta",
|
|
contentIndex: 0,
|
|
delta: "hi",
|
|
partial,
|
|
} as any);
|
|
|
|
// Advance wall clock beyond idleTimeoutMs without any new progress.
|
|
await vi.advanceTimersByTimeAsync(6_000);
|
|
|
|
// Assert: wrapper called abort on its injected AbortController (forwarded as options.signal).
|
|
expect(capturedSignals).toHaveLength(1);
|
|
expect(abortCalled).toBe(true);
|
|
|
|
// And it pushed a terminal error event to the downstream consumer.
|
|
const terminal = received.find(
|
|
(event) => event.type === "error" && (event as any).reason === "error",
|
|
);
|
|
expect(terminal).toBeDefined();
|
|
expect((terminal as any)?.error).toMatchObject({
|
|
stopReason: "error",
|
|
errorMessage: "opencode-go stream timed out after provider-owned SSE boundary stalled",
|
|
});
|
|
|
|
// Cleanup: end base stream so consumer promise resolves.
|
|
controller.end();
|
|
await consumer;
|
|
});
|
|
|
|
it("uses a longer first-event timeout than the inter-event idle timeout", async () => {
|
|
const { stream: baseStream } = createFakeBaseStream();
|
|
let abortCalled = false;
|
|
|
|
const underlying = vi.fn((_model, _context, options) => {
|
|
if (options?.signal) {
|
|
options.signal.addEventListener("abort", () => {
|
|
abortCalled = true;
|
|
});
|
|
}
|
|
return baseStream;
|
|
});
|
|
|
|
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
|
|
provider: "opencode-go",
|
|
idleTimeoutMs: 5_000,
|
|
firstEventTimeoutMs: 10_000,
|
|
});
|
|
|
|
const downstream = await Promise.resolve(
|
|
wrapper({ provider: "opencode-go", id: "deepseek-v4-flash" } as any, {} as any, {} as any),
|
|
);
|
|
expect(downstream).toBeDefined();
|
|
if (!downstream) {
|
|
return;
|
|
}
|
|
|
|
const consumer = (async () => {
|
|
for await (const event of downstream) {
|
|
void event;
|
|
}
|
|
})();
|
|
|
|
await vi.advanceTimersByTimeAsync(6_000);
|
|
expect(abortCalled).toBe(false);
|
|
|
|
await vi.advanceTimersByTimeAsync(5_000);
|
|
expect(abortCalled).toBe(true);
|
|
await consumer;
|
|
});
|
|
|
|
it("keeps the first-event window after an openai-completions synthetic start", async () => {
|
|
const { stream: baseStream, controller } = createFakeBaseStream();
|
|
let abortCalled = false;
|
|
|
|
const underlying = vi.fn((_model, _context, options) => {
|
|
if (options?.signal) {
|
|
options.signal.addEventListener("abort", () => {
|
|
abortCalled = true;
|
|
});
|
|
}
|
|
return baseStream;
|
|
});
|
|
|
|
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
|
|
provider: "opencode-go",
|
|
idleTimeoutMs: 5_000,
|
|
firstEventTimeoutMs: 10_000,
|
|
});
|
|
|
|
const downstream = await Promise.resolve(
|
|
wrapper({ provider: "opencode-go", id: "deepseek-v4-flash" } as any, {} as any, {} as any),
|
|
);
|
|
expect(downstream).toBeDefined();
|
|
if (!downstream) {
|
|
return;
|
|
}
|
|
|
|
const received: AnyEvent[] = [];
|
|
const consumer = (async () => {
|
|
for await (const event of downstream) {
|
|
received.push(event);
|
|
}
|
|
})();
|
|
|
|
const partial = {
|
|
role: "assistant",
|
|
content: [],
|
|
stopReason: undefined,
|
|
};
|
|
controller.emit({ type: "start", partial } as any);
|
|
|
|
await vi.advanceTimersByTimeAsync(6_000);
|
|
expect(abortCalled).toBe(false);
|
|
|
|
controller.emit({
|
|
type: "text_delta",
|
|
contentIndex: 0,
|
|
delta: "hello",
|
|
partial: {
|
|
...partial,
|
|
content: [{ type: "text", text: "hello" }],
|
|
},
|
|
} as any);
|
|
controller.emit({
|
|
type: "done",
|
|
reason: "stop",
|
|
message: {
|
|
...partial,
|
|
content: [{ type: "text", text: "hello" }],
|
|
stopReason: "stop",
|
|
},
|
|
} as any);
|
|
await consumer;
|
|
|
|
expect(abortCalled).toBe(false);
|
|
expect(received.some((event) => event.type === "text_delta")).toBe(true);
|
|
expect(received.some((event) => event.type === "done")).toBe(true);
|
|
});
|
|
|
|
it("keeps the first-event window after synthetic block-start events until a provider delta", async () => {
|
|
const { stream: baseStream, controller } = createFakeBaseStream();
|
|
let abortCalled = false;
|
|
|
|
const underlying = vi.fn((_model, _context, options) => {
|
|
if (options?.signal) {
|
|
options.signal.addEventListener("abort", () => {
|
|
abortCalled = true;
|
|
});
|
|
}
|
|
return baseStream;
|
|
});
|
|
|
|
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
|
|
provider: "opencode-go",
|
|
idleTimeoutMs: 5_000,
|
|
firstEventTimeoutMs: 10_000,
|
|
});
|
|
|
|
const downstream = await Promise.resolve(
|
|
wrapper({ provider: "opencode-go", id: "deepseek-v4-flash" } as any, {} as any, {} as any),
|
|
);
|
|
expect(downstream).toBeDefined();
|
|
if (!downstream) {
|
|
return;
|
|
}
|
|
|
|
const received: AnyEvent[] = [];
|
|
const consumer = (async () => {
|
|
for await (const event of downstream) {
|
|
received.push(event);
|
|
}
|
|
})();
|
|
|
|
const partial = {
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "" }],
|
|
stopReason: undefined,
|
|
};
|
|
controller.emit({ type: "start", partial } as any);
|
|
controller.emit({ type: "text_start", contentIndex: 0, partial } as any);
|
|
|
|
await vi.advanceTimersByTimeAsync(6_000);
|
|
expect(abortCalled).toBe(false);
|
|
|
|
const message = {
|
|
...partial,
|
|
content: [{ type: "text", text: "hello" }],
|
|
stopReason: "stop",
|
|
};
|
|
controller.emit({
|
|
type: "text_delta",
|
|
contentIndex: 0,
|
|
delta: "hello",
|
|
partial: message,
|
|
} as any);
|
|
controller.emit({ type: "done", reason: "stop", message } as any);
|
|
await consumer;
|
|
|
|
expect(abortCalled).toBe(false);
|
|
expect(received.some((event) => event.type === "text_delta")).toBe(true);
|
|
expect(received.some((event) => event.type === "done")).toBe(true);
|
|
});
|
|
|
|
it("honors explicit opencode-go provider request timeout above the wrapper idle default", async () => {
|
|
const { stream: baseStream, controller } = createFakeBaseStream();
|
|
let abortCalled = false;
|
|
|
|
const underlying = vi.fn((_model, _context, options) => {
|
|
if (options?.signal) {
|
|
options.signal.addEventListener("abort", () => {
|
|
abortCalled = true;
|
|
});
|
|
}
|
|
return baseStream;
|
|
});
|
|
|
|
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
|
|
provider: "opencode-go",
|
|
idleTimeoutMs: 5_000,
|
|
firstEventTimeoutMs: 5_000,
|
|
});
|
|
|
|
const downstream = await Promise.resolve(
|
|
wrapper(
|
|
{ provider: "opencode-go", id: "deepseek-v4-flash", requestTimeoutMs: 10_000 } as any,
|
|
{} as any,
|
|
{} as any,
|
|
),
|
|
);
|
|
expect(downstream).toBeDefined();
|
|
if (!downstream) {
|
|
return;
|
|
}
|
|
|
|
const consumer = (async () => {
|
|
for await (const event of downstream) {
|
|
void event;
|
|
}
|
|
})();
|
|
|
|
const partial = {
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "slow" }],
|
|
stopReason: undefined,
|
|
};
|
|
controller.emit({ type: "start", partial } as any);
|
|
|
|
await vi.advanceTimersByTimeAsync(6_000);
|
|
expect(abortCalled).toBe(false);
|
|
|
|
await vi.advanceTimersByTimeAsync(5_000);
|
|
expect(abortCalled).toBe(true);
|
|
await consumer;
|
|
});
|
|
|
|
it("honors explicit opencode-go provider request timeout below wrapper defaults", async () => {
|
|
const { stream: baseStream } = createFakeBaseStream();
|
|
let abortCalled = false;
|
|
|
|
const underlying = vi.fn((_model, _context, options) => {
|
|
if (options?.signal) {
|
|
options.signal.addEventListener("abort", () => {
|
|
abortCalled = true;
|
|
});
|
|
}
|
|
return baseStream;
|
|
});
|
|
|
|
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
|
|
provider: "opencode-go",
|
|
idleTimeoutMs: 5_000,
|
|
firstEventTimeoutMs: 10_000,
|
|
});
|
|
|
|
const downstream = await Promise.resolve(
|
|
wrapper(
|
|
{ provider: "opencode-go", id: "deepseek-v4-flash", requestTimeoutMs: 2_000 } as any,
|
|
{} as any,
|
|
{} as any,
|
|
),
|
|
);
|
|
expect(downstream).toBeDefined();
|
|
if (!downstream) {
|
|
return;
|
|
}
|
|
|
|
const consumer = (async () => {
|
|
for await (const event of downstream) {
|
|
void event;
|
|
}
|
|
})();
|
|
|
|
await vi.advanceTimersByTimeAsync(2_500);
|
|
expect(abortCalled).toBe(true);
|
|
await consumer;
|
|
});
|
|
|
|
it("aborts and releases the underlying stream when no first event arrives", async () => {
|
|
const { stream: baseStream, getReturnCalls } = createFakeBaseStream();
|
|
let abortCalled = false;
|
|
const capturedSignals: AbortSignal[] = [];
|
|
|
|
const underlying = vi.fn((_model, _context, options) => {
|
|
if (options?.signal) {
|
|
capturedSignals.push(options.signal);
|
|
options.signal.addEventListener("abort", () => {
|
|
abortCalled = true;
|
|
});
|
|
}
|
|
return baseStream;
|
|
});
|
|
|
|
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
|
|
provider: "opencode-go",
|
|
idleTimeoutMs: 5_000,
|
|
});
|
|
|
|
const downstream = await Promise.resolve(
|
|
wrapper({ provider: "opencode-go", id: "deepseek-v4-flash" } as any, {} as any, {} as any),
|
|
);
|
|
expect(downstream).toBeDefined();
|
|
if (!downstream) {
|
|
return;
|
|
}
|
|
|
|
const received: AnyEvent[] = [];
|
|
const consumer = (async () => {
|
|
for await (const event of downstream) {
|
|
received.push(event);
|
|
}
|
|
})();
|
|
|
|
await vi.advanceTimersByTimeAsync(6_000);
|
|
|
|
expect(capturedSignals).toHaveLength(1);
|
|
expect(abortCalled).toBe(true);
|
|
expect(getReturnCalls()).toBe(1);
|
|
expect(
|
|
received.some((event) => event.type === "error" && (event as any).reason === "error"),
|
|
).toBe(true);
|
|
|
|
await consumer;
|
|
});
|
|
|
|
it("aborts stream creation when the upstream stream promise never resolves", async () => {
|
|
let abortCalled = false;
|
|
|
|
const underlying = vi.fn((_model, _context, options) => {
|
|
if (options?.signal) {
|
|
options.signal.addEventListener("abort", () => {
|
|
abortCalled = true;
|
|
});
|
|
}
|
|
return new Promise<StreamLike>(() => {
|
|
// keep pending
|
|
});
|
|
});
|
|
|
|
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
|
|
provider: "opencode-go",
|
|
idleTimeoutMs: 5_000,
|
|
});
|
|
|
|
const downstream = await Promise.resolve(
|
|
wrapper({ provider: "opencode-go", id: "deepseek-v4-flash" } as any, {} as any, {} as any),
|
|
);
|
|
expect(downstream).toBeDefined();
|
|
if (!downstream) {
|
|
return;
|
|
}
|
|
|
|
const received: AnyEvent[] = [];
|
|
const consumer = (async () => {
|
|
for await (const event of downstream) {
|
|
received.push(event);
|
|
}
|
|
})();
|
|
|
|
await vi.advanceTimersByTimeAsync(6_000);
|
|
|
|
expect(abortCalled).toBe(true);
|
|
expect(
|
|
received.some((event) => event.type === "error" && (event as any).reason === "error"),
|
|
).toBe(true);
|
|
await consumer;
|
|
});
|
|
|
|
it("aborts through the fallback combined signal when no first event arrives", async () => {
|
|
const abortSignalAnyDescriptor = disableAbortSignalAny();
|
|
const { stream: baseStream } = createFakeBaseStream();
|
|
let abortCalled = false;
|
|
|
|
try {
|
|
const underlying = vi.fn((_model, _context, options) => {
|
|
if (options?.signal) {
|
|
options.signal.addEventListener("abort", () => {
|
|
abortCalled = true;
|
|
});
|
|
}
|
|
return baseStream;
|
|
});
|
|
|
|
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
|
|
provider: "opencode-go",
|
|
idleTimeoutMs: 5_000,
|
|
});
|
|
|
|
const downstream = await Promise.resolve(
|
|
wrapper(
|
|
{ provider: "opencode-go", id: "deepseek-v4-flash" } as any,
|
|
{} as any,
|
|
{ signal: new AbortController().signal } as any,
|
|
),
|
|
);
|
|
expect(downstream).toBeDefined();
|
|
if (!downstream) {
|
|
return;
|
|
}
|
|
|
|
const consumer = (async () => {
|
|
for await (const event of downstream) {
|
|
void event;
|
|
}
|
|
})();
|
|
|
|
await vi.advanceTimersByTimeAsync(6_000);
|
|
|
|
expect(abortCalled).toBe(true);
|
|
await consumer;
|
|
} finally {
|
|
restoreAbortSignalAny(abortSignalAnyDescriptor);
|
|
}
|
|
});
|
|
|
|
it("cleans up fallback AbortSignal listeners after natural completion", async () => {
|
|
const abortSignalAnyDescriptor = disableAbortSignalAny();
|
|
const sourceController = new AbortController();
|
|
const addEventListener = vi.spyOn(sourceController.signal, "addEventListener");
|
|
const removeEventListener = vi.spyOn(sourceController.signal, "removeEventListener");
|
|
const { stream: baseStream, controller } = createFakeBaseStream();
|
|
|
|
try {
|
|
const wrapper = createOpencodeGoStalledStreamWrapper(vi.fn(() => baseStream) as any, {
|
|
provider: "opencode-go",
|
|
idleTimeoutMs: 5_000,
|
|
});
|
|
|
|
const downstream = await Promise.resolve(
|
|
wrapper(
|
|
{ provider: "opencode-go", id: "deepseek-v4-flash" } as any,
|
|
{} as any,
|
|
{ signal: sourceController.signal } as any,
|
|
),
|
|
);
|
|
expect(downstream).toBeDefined();
|
|
if (!downstream) {
|
|
return;
|
|
}
|
|
|
|
const received: AnyEvent[] = [];
|
|
const consumer = (async () => {
|
|
for await (const event of downstream) {
|
|
received.push(event);
|
|
}
|
|
})();
|
|
|
|
const partial = {
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "done" }],
|
|
stopReason: "stop",
|
|
};
|
|
controller.emit({ type: "start", partial } as any);
|
|
controller.emit({ type: "done", reason: "stop", message: partial } as any);
|
|
await consumer;
|
|
|
|
expect(received.some((event) => event.type === "done")).toBe(true);
|
|
expect(addEventListener).toHaveBeenCalledWith("abort", expect.any(Function), { once: true });
|
|
expect(removeEventListener).toHaveBeenCalledWith("abort", expect.any(Function));
|
|
} finally {
|
|
restoreAbortSignalAny(abortSignalAnyDescriptor);
|
|
addEventListener.mockRestore();
|
|
removeEventListener.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("preserves normal delayed usage-only completion without aborting", async () => {
|
|
// Arrange: a fake base stream that streams a normal completion, including
|
|
// a long quiet gap before the final usage-only delta — but well within the
|
|
// idle timeout. The wrapper must not abort.
|
|
const { stream: baseStream, controller } = createFakeBaseStream();
|
|
void baseStream;
|
|
let abortCalled = false;
|
|
const capturedSignals: AbortSignal[] = [];
|
|
|
|
const underlying = vi.fn((_model, _context, options) => {
|
|
if (options?.signal) {
|
|
capturedSignals.push(options.signal);
|
|
options.signal.addEventListener("abort", () => {
|
|
abortCalled = true;
|
|
});
|
|
}
|
|
return baseStream;
|
|
});
|
|
|
|
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
|
|
provider: "opencode-go",
|
|
idleTimeoutMs: 5_000,
|
|
});
|
|
|
|
const downstream = await Promise.resolve(
|
|
wrapper({ provider: "opencode-go", id: "deepseek-v4-flash" } as any, {} as any, {} as any),
|
|
);
|
|
expect(downstream).toBeDefined();
|
|
if (!downstream) {
|
|
return;
|
|
}
|
|
|
|
const received: AnyEvent[] = [];
|
|
const consumer = (async () => {
|
|
for await (const event of downstream) {
|
|
received.push(event);
|
|
}
|
|
})();
|
|
|
|
const partial = {
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "hello" }],
|
|
stopReason: "stop",
|
|
};
|
|
controller.emit({ type: "start", partial } as any);
|
|
controller.emit({
|
|
type: "text_delta",
|
|
contentIndex: 0,
|
|
delta: "hello",
|
|
partial,
|
|
} as any);
|
|
|
|
// Simulate a delayed final chunk after a short (sub-timeout) quiet gap.
|
|
await vi.advanceTimersByTimeAsync(2_000);
|
|
|
|
// Final completion event arrives before idle timeout fires.
|
|
controller.emit({
|
|
type: "done",
|
|
reason: "stop",
|
|
message: partial,
|
|
} as any);
|
|
|
|
// Advance well past the idle timeout — wrapper should NOT have fired.
|
|
await vi.advanceTimersByTimeAsync(10_000);
|
|
|
|
expect(abortCalled).toBe(false);
|
|
|
|
// Downstream must contain all forwarded events including the done event.
|
|
const doneEvent = received.find((event) => event.type === "done");
|
|
expect(doneEvent).toBeDefined();
|
|
|
|
// Cleanup
|
|
controller.end();
|
|
await consumer;
|
|
});
|
|
|
|
it("must NOT abort a live stream that keeps emitting block-boundary events between deltas", async () => {
|
|
// Regression for https://github.com/openclaw/openclaw/issues/96518:
|
|
// the idle timer must re-arm on block-boundary events (text_end,
|
|
// thinking_end, toolcall_start, toolcall_end), not only on token
|
|
// deltas. A stream that keeps producing boundary events between
|
|
// deltas is demonstrably alive and must not be aborted.
|
|
const { stream: baseStream, controller } = createFakeBaseStream();
|
|
let abortCalled = false;
|
|
const underlying = vi.fn((_model, _context, options) => {
|
|
if (options?.signal) {
|
|
options.signal.addEventListener("abort", () => {
|
|
abortCalled = true;
|
|
});
|
|
}
|
|
return baseStream;
|
|
});
|
|
|
|
const idleTimeoutMs = 5_000;
|
|
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
|
|
provider: "opencode-go",
|
|
idleTimeoutMs,
|
|
});
|
|
|
|
const downstream = await Promise.resolve(
|
|
wrapper({ provider: "opencode-go", id: "glm-4.6" } as any, {} as any, {} as any),
|
|
);
|
|
expect(downstream).toBeDefined();
|
|
if (!downstream) {
|
|
return;
|
|
}
|
|
|
|
const received: AnyEvent[] = [];
|
|
const consumer = (async () => {
|
|
for await (const event of downstream) {
|
|
received.push(event);
|
|
}
|
|
})();
|
|
|
|
const partial = { role: "assistant", content: [{ type: "text", text: "x" }] };
|
|
|
|
// Provider starts producing a tool-call turn. The last *delta* arms the idle timer.
|
|
controller.emit({ type: "start", partial } as any);
|
|
controller.emit({
|
|
type: "toolcall_delta",
|
|
contentIndex: 0,
|
|
delta: "{",
|
|
partial,
|
|
} as any);
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
|
|
// The model finalizes the tool call and deliberates on the next one,
|
|
// emitting real block-boundary events that prove the SSE socket is alive.
|
|
// Each gap is < idleTimeoutMs, so a liveness-aware watchdog must stay armed.
|
|
await vi.advanceTimersByTimeAsync(3_000);
|
|
controller.emit({
|
|
type: "toolcall_end",
|
|
contentIndex: 0,
|
|
toolCall: { name: "f", arguments: "{}" },
|
|
partial,
|
|
} as any);
|
|
await vi.advanceTimersByTimeAsync(3_000);
|
|
controller.emit({
|
|
type: "toolcall_start",
|
|
contentIndex: 1,
|
|
partial,
|
|
} as any);
|
|
|
|
// Advance to 5s after the last delta, but only 2s after the last
|
|
// boundary event. The idle timer should have been re-armed by the
|
|
// boundary events, so it must NOT fire yet.
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
|
|
// The provider's completed answer arrives right after.
|
|
controller.emit({
|
|
type: "done",
|
|
reason: "stop",
|
|
message: {
|
|
...partial,
|
|
content: [{ type: "text", text: "final answer" }],
|
|
stopReason: "stop",
|
|
},
|
|
} as any);
|
|
controller.end();
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
await consumer;
|
|
|
|
const hasDone = received.some((e) => e.type === "done");
|
|
const hasStalledError = received.some(
|
|
(e) => e.type === "error" && (e as any).error?.stopReason === "error",
|
|
);
|
|
|
|
expect(abortCalled).toBe(false);
|
|
expect(hasDone).toBe(true);
|
|
expect(hasStalledError).toBe(false);
|
|
});
|
|
});
|