From c6e32835d4c3b7b51ce9be1832ef9987b0772817 Mon Sep 17 00:00:00 2001 From: Andrew Demczuk Date: Sat, 14 Mar 2026 19:15:49 +0100 Subject: [PATCH] fix(feishu): clear stale streamingStartPromise on card creation failure Fixes #43322 * fix(feishu): clear stale streamingStartPromise on card creation failure When FeishuStreamingSession.start() throws (HTTP 400), the catch block sets streaming = null but leaves streamingStartPromise dangling. The guard in startStreaming() checks streamingStartPromise first, so all future deliver() calls silently skip streaming - the session locks permanently. Clear streamingStartPromise in the catch block so subsequent messages can retry streaming instead of dropping all future replies. Fixes #43322 * test(feishu): wrap push override in try/finally for cleanup safety --- CHANGELOG.md | 1 + .../feishu/src/reply-dispatcher.test.ts | 46 +++++++++++++++++++ extensions/feishu/src/reply-dispatcher.ts | 1 + 3 files changed, 48 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70329bce0ad..029a7179cbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -323,6 +323,7 @@ Docs: https://docs.openclaw.ai - Agents/failover: classify HTTP 422 malformed-request responses as `format` and recognize OpenRouter "requires more credits" billing errors so provider fallback triggers instead of surfacing raw errors. (#43823) thanks @jnMetaCode. - Memory/QMD Windows: fail closed when `qmd.cmd` or `mcporter.cmd` wrappers cannot be resolved to a direct entrypoint, so memory search no longer falls back to shell execution on Windows. - macOS/remote gateway: stop PortGuardian from killing Docker Desktop and other external listeners on the gateway port in remote mode, so containerized and tunneled gateway setups no longer lose their port-forward owner on app startup. (#6755) Thanks @teslamint. +- Feishu/streaming recovery: clear stale `streamingStartPromise` when card creation fails (HTTP 400) so subsequent messages can retry streaming instead of silently dropping all future replies. Fixes #43322. ## 2026.3.8 diff --git a/extensions/feishu/src/reply-dispatcher.test.ts b/extensions/feishu/src/reply-dispatcher.test.ts index 10b829857a1..338953a7d6d 100644 --- a/extensions/feishu/src/reply-dispatcher.test.ts +++ b/extensions/feishu/src/reply-dispatcher.test.ts @@ -510,4 +510,50 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }), ); }); + + it("recovers streaming after start() throws (HTTP 400)", async () => { + const errorMock = vi.fn(); + let shouldFailStart = true; + + // Intercept streaming instance creation to make first start() reject + const origPush = streamingInstances.push; + streamingInstances.push = function (this: any[], ...args: any[]) { + if (shouldFailStart) { + args[0].start = vi + .fn() + .mockRejectedValue(new Error("Create card request failed with HTTP 400")); + shouldFailStart = false; + } + return origPush.apply(this, args); + } as any; + + try { + createFeishuReplyDispatcher({ + cfg: {} as never, + agentId: "agent", + runtime: { log: vi.fn(), error: errorMock } as never, + chatId: "oc_chat", + }); + + const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; + + // First deliver with markdown triggers startStreaming - which will fail + await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "block" }); + + // Wait for the async error to propagate + await vi.waitFor(() => { + expect(errorMock).toHaveBeenCalledWith(expect.stringContaining("streaming start failed")); + }); + + // Second deliver should create a NEW streaming session (not stuck) + await options.deliver({ text: "```ts\nconst y = 2\n```" }, { kind: "final" }); + + // Two instances created: first failed, second succeeded and closed + expect(streamingInstances).toHaveLength(2); + expect(streamingInstances[1].start).toHaveBeenCalled(); + expect(streamingInstances[1].close).toHaveBeenCalled(); + } finally { + streamingInstances.push = origPush; + } + }); }); diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts index 6f66ffffa58..5ebf712ca8b 100644 --- a/extensions/feishu/src/reply-dispatcher.ts +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -202,6 +202,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP } catch (error) { params.runtime.error?.(`feishu: streaming start failed: ${String(error)}`); streaming = null; + streamingStartPromise = null; // allow retry on next deliver } })(); };