



















@@ -17,6 +17,7 @@ type StreamingSessionState = {
1717messageId: string;
1818sequence: number;
1919currentText: string;
20+sentText: string;
2021hasNote: boolean;
2122};
2223@@ -26,7 +27,7 @@ function setStreamingSessionInternals(
2627state: StreamingSessionState;
2728lastUpdateTime?: number;
2829},
29-) {
30+): void {
3031const internals = session as unknown as {
3132state: StreamingSessionState;
3233lastUpdateTime: number;
@@ -52,10 +53,18 @@ describe("FeishuStreamingSession", () => {
5253vi.useRealTimers();
5354});
545555-function mockFetches(updateBodies: string[]) {
56+function mockFetches(
57+updateBodies: string[],
58+failedContentUpdateIndexes: ReadonlySet<number> = new Set<number>(),
59+replaceBodies: string[] = [],
60+failedContentUpdateStatuses: ReadonlyMap<number, number> = new Map<number, number>(),
61+failedReplaceStatuses: ReadonlyMap<number, number> = new Map<number, number>(),
62+): void {
5663fetchWithSsrFGuardMock.mockImplementation(
5764async ({ url, init }: { url: string; init?: { body?: string } }) => {
5865const release = vi.fn(async () => {});
66+let ok = true;
67+let status = 200;
5968if (url.includes("/auth/")) {
6069return {
6170response: {
@@ -71,11 +80,29 @@ describe("FeishuStreamingSession", () => {
7180};
7281}
7382if (url.includes("/elements/content/content")) {
83+const updateIndex = updateBodies.length;
7484updateBodies.push(init?.body ?? "");
85+if (failedContentUpdateIndexes.has(updateIndex)) {
86+throw new Error(`content update ${updateIndex} failed`);
87+}
88+const failedStatus = failedContentUpdateStatuses.get(updateIndex);
89+if (failedStatus !== undefined) {
90+ok = false;
91+status = failedStatus;
92+}
93+} else if (url.includes("/elements/content")) {
94+const replaceIndex = replaceBodies.length;
95+replaceBodies.push(init?.body ?? "");
96+const failedStatus = failedReplaceStatuses.get(replaceIndex);
97+if (failedStatus !== undefined) {
98+ok = false;
99+status = failedStatus;
100+}
75101}
76102return {
77103response: {
78-ok: true,
104+ ok,
105+ status,
79106json: async () => ({ code: 0, msg: "ok" }),
80107},
81108 release,
@@ -100,6 +127,7 @@ describe("FeishuStreamingSession", () => {
100127messageId: "om_1",
101128sequence: 1,
102129currentText: "hello",
130+sentText: "hello",
103131hasNote: false,
104132},
105133lastUpdateTime: 1_000,
@@ -112,7 +140,7 @@ describe("FeishuStreamingSession", () => {
112140113141expect(updateBodies).toHaveLength(1);
114142expect(JSON.parse(updateBodies[0] ?? "{}")).toEqual({
115-content: "hello small",
143+content: " small",
116144sequence: 2,
117145uuid: "s_card_1_2",
118146});
@@ -134,6 +162,7 @@ describe("FeishuStreamingSession", () => {
134162messageId: "om_2",
135163sequence: 1,
136164currentText: "hello",
165+sentText: "hello",
137166hasNote: false,
138167},
139168lastUpdateTime: 2_000,
@@ -143,11 +172,176 @@ describe("FeishuStreamingSession", () => {
143172144173expect(updateBodies).toHaveLength(1);
145174expect(JSON.parse(updateBodies[0] ?? "{}")).toEqual({
146-content: "hello!",
175+content: "!",
147176sequence: 2,
148177uuid: "s_card_2_2",
149178});
150179});
180+181+it("retries unsent suffix content after a failed delta update", async () => {
182+vi.useFakeTimers();
183+vi.setSystemTime(3_000);
184+const updateBodies: string[] = [];
185+mockFetches(updateBodies, new Set([0]));
186+187+const session = new FeishuStreamingSession({} as never, {
188+appId: "app_failed_delta_retry",
189+appSecret: "secret",
190+});
191+setStreamingSessionInternals(session, {
192+state: {
193+cardId: "card_3",
194+messageId: "om_3",
195+sequence: 1,
196+currentText: "hello",
197+sentText: "hello",
198+hasNote: false,
199+},
200+lastUpdateTime: 2_000,
201+});
202+203+await session.update("hello world");
204+await session.update("hello world!");
205+206+expect(updateBodies).toHaveLength(2);
207+expect(JSON.parse(updateBodies[0] ?? "{}")).toEqual({
208+content: " world",
209+sequence: 2,
210+uuid: "s_card_3_2",
211+});
212+expect(JSON.parse(updateBodies[1] ?? "{}")).toEqual({
213+content: " world!",
214+sequence: 3,
215+uuid: "s_card_3_3",
216+});
217+});
218+219+it("retries unsent suffix content after a non-OK delta update", async () => {
220+vi.useFakeTimers();
221+vi.setSystemTime(3_500);
222+const updateBodies: string[] = [];
223+mockFetches(updateBodies, new Set<number>(), [], new Map([[0, 429]]));
224+225+const session = new FeishuStreamingSession({} as never, {
226+appId: "app_non_ok_delta_retry",
227+appSecret: "secret",
228+});
229+setStreamingSessionInternals(session, {
230+state: {
231+cardId: "card_5",
232+messageId: "om_5",
233+sequence: 1,
234+currentText: "hello",
235+sentText: "hello",
236+hasNote: false,
237+},
238+lastUpdateTime: 2_000,
239+});
240+241+await session.update("hello world");
242+await session.update("hello world!");
243+244+expect(updateBodies).toHaveLength(2);
245+expect(JSON.parse(updateBodies[0] ?? "{}")).toEqual({
246+content: " world",
247+sequence: 2,
248+uuid: "s_card_5_2",
249+});
250+expect(JSON.parse(updateBodies[1] ?? "{}")).toEqual({
251+content: " world!",
252+sequence: 3,
253+uuid: "s_card_5_3",
254+});
255+});
256+257+it("replaces content when final text removes transient streamed status", async () => {
258+vi.useFakeTimers();
259+vi.setSystemTime(4_000);
260+const updateBodies: string[] = [];
261+const replaceBodies: string[] = [];
262+mockFetches(updateBodies, new Set<number>(), replaceBodies);
263+264+const session = new FeishuStreamingSession({} as never, {
265+appId: "app_final_rewrite",
266+appSecret: "secret",
267+});
268+setStreamingSessionInternals(session, {
269+state: {
270+cardId: "card_4",
271+messageId: "om_4",
272+sequence: 1,
273+currentText: "🔎 Web Search\n\nfinal answer",
274+sentText: "🔎 Web Search\n\nfinal answer",
275+hasNote: false,
276+},
277+lastUpdateTime: 3_000,
278+});
279+280+await session.close("final answer");
281+282+expect(updateBodies).toHaveLength(0);
283+expect(replaceBodies).toHaveLength(1);
284+const replacePayload = JSON.parse(replaceBodies[0] ?? "{}") as {
285+element?: string;
286+sequence?: number;
287+uuid?: string;
288+};
289+expect({
290+ ...replacePayload,
291+element: JSON.parse(replacePayload.element ?? "{}"),
292+}).toEqual({
293+element: {
294+tag: "markdown",
295+content: "final answer",
296+element_id: "content",
297+},
298+sequence: 2,
299+uuid: "r_card_4_2",
300+});
301+});
302+303+it("logs a final replacement failure when CardKit returns non-OK", async () => {
304+vi.useFakeTimers();
305+vi.setSystemTime(4_500);
306+const updateBodies: string[] = [];
307+const replaceBodies: string[] = [];
308+mockFetches(
309+updateBodies,
310+new Set<number>(),
311+replaceBodies,
312+new Map<number, number>(),
313+new Map([[0, 500]]),
314+);
315+const log = vi.fn();
316+317+const session = new FeishuStreamingSession(
318+{} as never,
319+{
320+appId: "app_final_rewrite_non_ok",
321+appSecret: "secret",
322+},
323+log,
324+);
325+setStreamingSessionInternals(session, {
326+state: {
327+cardId: "card_6",
328+messageId: "om_6",
329+sequence: 1,
330+currentText: "working\n\nfinal answer",
331+sentText: "working\n\nfinal answer",
332+hasNote: false,
333+},
334+lastUpdateTime: 3_000,
335+});
336+337+await session.close("final answer");
338+339+expect(updateBodies).toHaveLength(0);
340+expect(replaceBodies).toHaveLength(1);
341+expect(log).toHaveBeenCalledWith(
342+"Final replace failed: Error: Replace card content failed with HTTP 500",
343+);
344+});
151345});
152346153347describe("mergeStreamingText", () => {
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。