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
This commit is contained in:
Andrew Demczuk 2026-03-14 19:15:49 +01:00 committed by GitHub
parent d9bc1920ed
commit c6e32835d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 48 additions and 0 deletions

View File

@ -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

View File

@ -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;
}
});
});

View File

@ -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
}
})();
};