chatccc 0.2.169 → 0.2.171
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/cardkit.test.ts +60 -0
- package/src/__tests__/feishu-api.test.ts +60 -0
- package/src/__tests__/session.test.ts +52 -0
- package/src/cardkit.ts +36 -15
- package/src/feishu-api.ts +18 -1
- package/src/session.ts +51 -32
package/package.json
CHANGED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { updateCardKitCard } from "../cardkit.ts";
|
|
4
|
+
|
|
5
|
+
function abortError(): Error {
|
|
6
|
+
const err = new Error("aborted");
|
|
7
|
+
err.name = "AbortError";
|
|
8
|
+
return err;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("CardKit request timeout", () => {
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
vi.useRealTimers();
|
|
14
|
+
vi.restoreAllMocks();
|
|
15
|
+
vi.unstubAllGlobals();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("aborts a hung card update so display.cardBusy can be released", async () => {
|
|
19
|
+
vi.useFakeTimers();
|
|
20
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
21
|
+
|
|
22
|
+
const fetchMock = vi.fn((_url: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => (
|
|
23
|
+
new Promise<Response>((_resolve, reject) => {
|
|
24
|
+
init?.signal?.addEventListener("abort", () => reject(abortError()));
|
|
25
|
+
})
|
|
26
|
+
));
|
|
27
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
28
|
+
|
|
29
|
+
const update = expect(updateCardKitCard("token", "card-1", "{}", 7))
|
|
30
|
+
.rejects.toThrow("updateCard cardId=card-1 seq=7 timeout after 15000ms");
|
|
31
|
+
await vi.advanceTimersByTimeAsync(15_000);
|
|
32
|
+
|
|
33
|
+
await update;
|
|
34
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
35
|
+
expect.stringContaining("/cardkit/v1/cards/card-1"),
|
|
36
|
+
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("also aborts when response body reading hangs", async () => {
|
|
41
|
+
vi.useFakeTimers();
|
|
42
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
43
|
+
|
|
44
|
+
const fetchMock = vi.fn((_url: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => (
|
|
45
|
+
Promise.resolve({
|
|
46
|
+
status: 200,
|
|
47
|
+
text: () => new Promise<string>((_resolve, reject) => {
|
|
48
|
+
init?.signal?.addEventListener("abort", () => reject(abortError()));
|
|
49
|
+
}),
|
|
50
|
+
} as Response)
|
|
51
|
+
));
|
|
52
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
53
|
+
|
|
54
|
+
const update = expect(updateCardKitCard("token", "card-2", "{}", 8))
|
|
55
|
+
.rejects.toThrow("updateCard cardId=card-2 seq=8 timeout after 15000ms");
|
|
56
|
+
await vi.advanceTimersByTimeAsync(15_000);
|
|
57
|
+
|
|
58
|
+
await update;
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { sendTextReply } from "../feishu-api.ts";
|
|
4
|
+
|
|
5
|
+
function abortError(): Error {
|
|
6
|
+
const err = new Error("aborted");
|
|
7
|
+
err.name = "AbortError";
|
|
8
|
+
return err;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("sendTextReply timeout", () => {
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
vi.useRealTimers();
|
|
14
|
+
vi.restoreAllMocks();
|
|
15
|
+
vi.unstubAllGlobals();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("aborts a hung text send request", async () => {
|
|
19
|
+
vi.useFakeTimers();
|
|
20
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
21
|
+
|
|
22
|
+
const fetchMock = vi.fn((_url: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => (
|
|
23
|
+
new Promise<Response>((_resolve, reject) => {
|
|
24
|
+
init?.signal?.addEventListener("abort", () => reject(abortError()));
|
|
25
|
+
})
|
|
26
|
+
));
|
|
27
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
28
|
+
|
|
29
|
+
const send = expect(sendTextReply("token", "chat-1", "hello"))
|
|
30
|
+
.resolves.toBe(false);
|
|
31
|
+
await vi.advanceTimersByTimeAsync(15_000);
|
|
32
|
+
|
|
33
|
+
await send;
|
|
34
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
35
|
+
expect.stringContaining("/im/v1/messages?receive_id_type=chat_id"),
|
|
36
|
+
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("also aborts when response body reading hangs", async () => {
|
|
41
|
+
vi.useFakeTimers();
|
|
42
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
43
|
+
|
|
44
|
+
const fetchMock = vi.fn((_url: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => (
|
|
45
|
+
Promise.resolve({
|
|
46
|
+
status: 200,
|
|
47
|
+
text: () => new Promise<string>((_resolve, reject) => {
|
|
48
|
+
init?.signal?.addEventListener("abort", () => reject(abortError()));
|
|
49
|
+
}),
|
|
50
|
+
} as Response)
|
|
51
|
+
));
|
|
52
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
53
|
+
|
|
54
|
+
const send = expect(sendTextReply("token", "chat-2", "hello"))
|
|
55
|
+
.resolves.toBe(false);
|
|
56
|
+
await vi.advanceTimersByTimeAsync(15_000);
|
|
57
|
+
|
|
58
|
+
await send;
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -569,6 +569,58 @@ describe("unified display loop terminal card update", () => {
|
|
|
569
569
|
);
|
|
570
570
|
expect(displayCards.get("chat-terminal")?.sequence).toBe(110);
|
|
571
571
|
});
|
|
572
|
+
|
|
573
|
+
it("keeps terminal display and retries final text when sending fails", async () => {
|
|
574
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
575
|
+
const platform = mockPlatform("feishu");
|
|
576
|
+
platform.cardUpdate = vi.fn(async () => {});
|
|
577
|
+
platform.sendText = vi.fn()
|
|
578
|
+
.mockResolvedValueOnce(false)
|
|
579
|
+
.mockResolvedValueOnce(true);
|
|
580
|
+
setSessionPlatform(platform);
|
|
581
|
+
|
|
582
|
+
bindChatToSession("sid-terminal-retry", "chat-terminal-retry");
|
|
583
|
+
recordLastActiveChat("sid-terminal-retry", "chat-terminal-retry");
|
|
584
|
+
sessionInfoMap.set("chat-terminal-retry", {
|
|
585
|
+
sessionId: "sid-terminal-retry",
|
|
586
|
+
turnCount: 1,
|
|
587
|
+
lastContextTokens: 0,
|
|
588
|
+
startTime: 0,
|
|
589
|
+
tool: "claude",
|
|
590
|
+
});
|
|
591
|
+
displayCards.set("chat-terminal-retry", {
|
|
592
|
+
cardId: "card-terminal-retry",
|
|
593
|
+
sequence: 4,
|
|
594
|
+
cardBusy: false,
|
|
595
|
+
cardCreatedAt: Date.now(),
|
|
596
|
+
lastSentContent: "",
|
|
597
|
+
streamErrorNotified: false,
|
|
598
|
+
sessionId: "sid-terminal-retry",
|
|
599
|
+
turnCount: 1,
|
|
600
|
+
dotCount: 0,
|
|
601
|
+
});
|
|
602
|
+
mockStreamStates.set("sid-terminal-retry", {
|
|
603
|
+
accumulatedContent: "work log",
|
|
604
|
+
finalReply: "final answer",
|
|
605
|
+
status: "done",
|
|
606
|
+
turnCount: 1,
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
startUnifiedDisplayLoop();
|
|
610
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
611
|
+
|
|
612
|
+
expect(platform.cardUpdate).toHaveBeenCalledTimes(1);
|
|
613
|
+
expect(platform.sendText).toHaveBeenCalledTimes(1);
|
|
614
|
+
expect(displayCards.has("chat-terminal-retry")).toBe(true);
|
|
615
|
+
expect(mockStreamStates.get("sid-terminal-retry")?.finalReplySentTurn).toBeUndefined();
|
|
616
|
+
|
|
617
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
618
|
+
|
|
619
|
+
expect(platform.cardUpdate).toHaveBeenCalledTimes(1);
|
|
620
|
+
expect(platform.sendText).toHaveBeenCalledTimes(2);
|
|
621
|
+
expect(displayCards.has("chat-terminal-retry")).toBe(false);
|
|
622
|
+
expect(mockStreamStates.get("sid-terminal-retry")?.finalReplySentTurn).toBe(1);
|
|
623
|
+
});
|
|
572
624
|
});
|
|
573
625
|
|
|
574
626
|
describe("rebuildBindingsFromRegistry", () => {
|
package/src/cardkit.ts
CHANGED
|
@@ -6,17 +6,41 @@ import { BASE_URL, fileLog, ts } from "./config.ts";
|
|
|
6
6
|
// 由飞书客户端自行渲染增量文本,用户看到的是逐字出现的效果。
|
|
7
7
|
// 参考: https://open.feishu.cn/document/uYjLw4iNukDO6YDN4ITM4MjM
|
|
8
8
|
|
|
9
|
+
const CARDKIT_REQUEST_TIMEOUT_MS = 15_000;
|
|
10
|
+
|
|
11
|
+
async function fetchCardKit(
|
|
12
|
+
url: string,
|
|
13
|
+
init: Parameters<typeof fetch>[1],
|
|
14
|
+
operation: string,
|
|
15
|
+
): Promise<{ resp: Response; respText: string }> {
|
|
16
|
+
const controller = new AbortController();
|
|
17
|
+
const timer = setTimeout(() => controller.abort(), CARDKIT_REQUEST_TIMEOUT_MS);
|
|
18
|
+
try {
|
|
19
|
+
const resp = await fetch(url, { ...init, signal: controller.signal });
|
|
20
|
+
const respText = await resp.text();
|
|
21
|
+
return { resp, respText };
|
|
22
|
+
} catch (err) {
|
|
23
|
+
if ((err as Error).name === "AbortError") {
|
|
24
|
+
console.error(`[${ts()}] [CARDIKT] ${operation} TIMEOUT after ${CARDKIT_REQUEST_TIMEOUT_MS}ms`);
|
|
25
|
+
fileLog.flush();
|
|
26
|
+
throw new Error(`${operation} timeout after ${CARDKIT_REQUEST_TIMEOUT_MS}ms`);
|
|
27
|
+
}
|
|
28
|
+
throw err;
|
|
29
|
+
} finally {
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
9
34
|
export async function createCardKitCard(token: string, cardJson: string): Promise<string> {
|
|
10
35
|
const body = JSON.stringify({ type: "card_json", data: cardJson });
|
|
11
|
-
const resp = await
|
|
36
|
+
const { resp, respText } = await fetchCardKit(`${BASE_URL}/cardkit/v1/cards`, {
|
|
12
37
|
method: "POST",
|
|
13
38
|
headers: {
|
|
14
39
|
Authorization: `Bearer ${token}`,
|
|
15
40
|
"Content-Type": "application/json",
|
|
16
41
|
},
|
|
17
42
|
body,
|
|
18
|
-
});
|
|
19
|
-
const respText = await resp.text();
|
|
43
|
+
}, "createCard");
|
|
20
44
|
let data: { code: number; msg?: string; data?: { card_id?: string } };
|
|
21
45
|
try {
|
|
22
46
|
data = JSON.parse(respText);
|
|
@@ -40,15 +64,14 @@ export async function setCardKitSettings(
|
|
|
40
64
|
settings: Record<string, unknown>,
|
|
41
65
|
sequence: number
|
|
42
66
|
): Promise<void> {
|
|
43
|
-
const resp = await
|
|
67
|
+
const { resp, respText } = await fetchCardKit(`${BASE_URL}/cardkit/v1/cards/${cardId}/settings`, {
|
|
44
68
|
method: "PATCH",
|
|
45
69
|
headers: {
|
|
46
70
|
Authorization: `Bearer ${token}`,
|
|
47
71
|
"Content-Type": "application/json",
|
|
48
72
|
},
|
|
49
73
|
body: JSON.stringify({ settings: JSON.stringify(settings), sequence }),
|
|
50
|
-
});
|
|
51
|
-
const respText = await resp.text();
|
|
74
|
+
}, `settings cardId=${cardId} seq=${sequence}`);
|
|
52
75
|
let data: { code: number; msg?: string };
|
|
53
76
|
try { data = JSON.parse(respText); } catch {
|
|
54
77
|
console.error(`[${ts()}] [CARDIKT] settings FAIL: invalid JSON cardId=${cardId} seq=${sequence} status=${resp.status}`);
|
|
@@ -70,7 +93,7 @@ export async function streamCardKitElement(
|
|
|
70
93
|
content: string,
|
|
71
94
|
sequence: number
|
|
72
95
|
): Promise<void> {
|
|
73
|
-
const resp = await
|
|
96
|
+
const { resp, respText } = await fetchCardKit(
|
|
74
97
|
`${BASE_URL}/cardkit/v1/cards/${cardId}/elements/${elementId}/content`,
|
|
75
98
|
{
|
|
76
99
|
method: "PUT",
|
|
@@ -79,9 +102,9 @@ export async function streamCardKitElement(
|
|
|
79
102
|
"Content-Type": "application/json",
|
|
80
103
|
},
|
|
81
104
|
body: JSON.stringify({ content, sequence }),
|
|
82
|
-
}
|
|
105
|
+
},
|
|
106
|
+
`streamElement cardId=${cardId} element=${elementId} seq=${sequence}`,
|
|
83
107
|
);
|
|
84
|
-
const respText = await resp.text();
|
|
85
108
|
let data: { code: number; msg?: string };
|
|
86
109
|
try { data = JSON.parse(respText); } catch {
|
|
87
110
|
console.error(`[${ts()}] [CARDIKT] streamElement FAIL: invalid JSON cardId=${cardId} element=${elementId} seq=${sequence} status=${resp.status}`);
|
|
@@ -103,15 +126,14 @@ export async function updateCardKitCard(
|
|
|
103
126
|
cardJson: string,
|
|
104
127
|
sequence: number
|
|
105
128
|
): Promise<void> {
|
|
106
|
-
const resp = await
|
|
129
|
+
const { resp, respText } = await fetchCardKit(`${BASE_URL}/cardkit/v1/cards/${cardId}`, {
|
|
107
130
|
method: "PUT",
|
|
108
131
|
headers: {
|
|
109
132
|
Authorization: `Bearer ${token}`,
|
|
110
133
|
"Content-Type": "application/json",
|
|
111
134
|
},
|
|
112
135
|
body: JSON.stringify({ card: { type: "card_json", data: cardJson }, sequence }),
|
|
113
|
-
});
|
|
114
|
-
const respText = await resp.text();
|
|
136
|
+
}, `updateCard cardId=${cardId} seq=${sequence}`);
|
|
115
137
|
let data: { code: number; msg?: string };
|
|
116
138
|
try { data = JSON.parse(respText); } catch {
|
|
117
139
|
console.error(`[${ts()}] [CARDIKT] updateCard FAIL: invalid JSON cardId=${cardId} seq=${sequence} status=${resp.status}`);
|
|
@@ -133,15 +155,14 @@ export async function sendCardKitMessage(
|
|
|
133
155
|
): Promise<string> {
|
|
134
156
|
const payload = { receive_id: chatId, msg_type: "interactive", content: JSON.stringify({ type: "card", data: { card_id: cardId } }) };
|
|
135
157
|
const url = `${BASE_URL}/im/v1/messages?receive_id_type=chat_id`;
|
|
136
|
-
const resp = await
|
|
158
|
+
const { resp, respText } = await fetchCardKit(url, {
|
|
137
159
|
method: "POST",
|
|
138
160
|
headers: {
|
|
139
161
|
Authorization: `Bearer ${token}`,
|
|
140
162
|
"Content-Type": "application/json",
|
|
141
163
|
},
|
|
142
164
|
body: JSON.stringify(payload),
|
|
143
|
-
});
|
|
144
|
-
const respText = await resp.text();
|
|
165
|
+
}, `sendMessage cardId=${cardId} chatId=${chatId}`);
|
|
145
166
|
let data: { code: number; msg?: string; data?: { message_id?: string } };
|
|
146
167
|
try { data = JSON.parse(respText); } catch {
|
|
147
168
|
console.error(`[${ts()}] [CARDIKT] sendMessage FAIL: invalid JSON cardId=${cardId} chatId=${chatId} status=${resp.status}`);
|
package/src/feishu-api.ts
CHANGED
|
@@ -757,6 +757,9 @@ export async function sendTextReply(
|
|
|
757
757
|
config: { wide_screen_mode: true },
|
|
758
758
|
elements: [{ tag: "markdown", content: wrappedText }],
|
|
759
759
|
});
|
|
760
|
+
const timeoutMs = 15_000;
|
|
761
|
+
const controller = new AbortController();
|
|
762
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
760
763
|
try {
|
|
761
764
|
const resp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
|
|
762
765
|
method: "POST",
|
|
@@ -769,8 +772,16 @@ export async function sendTextReply(
|
|
|
769
772
|
msg_type: "interactive",
|
|
770
773
|
content: card,
|
|
771
774
|
}),
|
|
775
|
+
signal: controller.signal,
|
|
772
776
|
});
|
|
773
|
-
const
|
|
777
|
+
const respText = await resp.text();
|
|
778
|
+
let data: { code: number; msg?: string; data?: { message_id?: string } };
|
|
779
|
+
try {
|
|
780
|
+
data = JSON.parse(respText);
|
|
781
|
+
} catch {
|
|
782
|
+
console.error(`[${ts()}] [SEND] text FAIL: chatId=${chatId} invalid JSON status=${resp.status}`);
|
|
783
|
+
return false;
|
|
784
|
+
}
|
|
774
785
|
if (data.code !== 0) {
|
|
775
786
|
console.error(`[${ts()}] [SEND] text FAIL: chatId=${chatId} code=${data.code} msg="${data.msg ?? ""}"`);
|
|
776
787
|
return false;
|
|
@@ -778,8 +789,14 @@ export async function sendTextReply(
|
|
|
778
789
|
console.log(`[${ts()}] [SEND] text OK: chatId=${chatId} msgId=${data.data?.message_id ?? "N/A"}`);
|
|
779
790
|
return true;
|
|
780
791
|
} catch (err) {
|
|
792
|
+
if ((err as Error).name === "AbortError") {
|
|
793
|
+
console.error(`[${ts()}] [SEND] text TIMEOUT after ${timeoutMs}ms: chatId=${chatId}`);
|
|
794
|
+
return false;
|
|
795
|
+
}
|
|
781
796
|
console.error(`[${ts()}] [SEND] text FAIL: chatId=${chatId} ${(err as Error).message}`);
|
|
782
797
|
return false;
|
|
798
|
+
} finally {
|
|
799
|
+
clearTimeout(timer);
|
|
783
800
|
}
|
|
784
801
|
}
|
|
785
802
|
|
package/src/session.ts
CHANGED
|
@@ -1300,17 +1300,21 @@ const CARD_ROTATE_MS = 9 * 60 * 1000;
|
|
|
1300
1300
|
export function startUnifiedDisplayLoop(): void {
|
|
1301
1301
|
if (unifiedDisplayLoopHandle !== null) return;
|
|
1302
1302
|
|
|
1303
|
+
let tickRunning = false;
|
|
1303
1304
|
const interval = setInterval(() => {
|
|
1304
1305
|
void (async () => {
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
const
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1306
|
+
if (tickRunning) return;
|
|
1307
|
+
tickRunning = true;
|
|
1308
|
+
try {
|
|
1309
|
+
for (const [chatId, display] of displayCards) {
|
|
1310
|
+
if (display.cardBusy) continue;
|
|
1311
|
+
|
|
1312
|
+
const sessionId = display.sessionId;
|
|
1313
|
+
const state = await readStreamState(sessionId);
|
|
1314
|
+
if (!state) {
|
|
1315
|
+
displayCards.delete(chatId);
|
|
1316
|
+
continue;
|
|
1317
|
+
}
|
|
1314
1318
|
|
|
1315
1319
|
// 交叉验证:chat 当前绑定的 session 是否仍是 display 记录的 session。
|
|
1316
1320
|
// 若 chat 已被切换到其他 session(如 /newh),旧 display 必须停推。
|
|
@@ -1375,45 +1379,57 @@ export function startUnifiedDisplayLoop(): void {
|
|
|
1375
1379
|
) {
|
|
1376
1380
|
continue;
|
|
1377
1381
|
}
|
|
1378
|
-
const
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
if (isCardKitSequenceConflict(err)) {
|
|
1382
|
+
const terminalCardAlreadyUpdated =
|
|
1383
|
+
display.lastSentAccLen === state.accumulatedContent.length &&
|
|
1384
|
+
display.lastSentFinalReply === state.finalReply;
|
|
1385
|
+
let terminalCardUpdateAccepted = terminalCardAlreadyUpdated;
|
|
1386
|
+
if (!terminalCardAlreadyUpdated) {
|
|
1387
|
+
const nextSeq = display.sequence + 1;
|
|
1388
|
+
const { title: headerTitle, template: headerTemplate } = formatTerminalHeader(state.status);
|
|
1389
|
+
const cardContent = truncateContent(state.accumulatedContent + state.finalReply) || " ";
|
|
1390
|
+
const doneCard = buildProgressCard(cardContent, { showStop: false, headerTitle, headerTemplate });
|
|
1391
|
+
await p.cardUpdate(display.cardId, doneCard, nextSeq).then(() => {
|
|
1389
1392
|
display.sequence = nextSeq;
|
|
1390
1393
|
terminalCardUpdateAccepted = true;
|
|
1394
|
+
}).catch(err => {
|
|
1395
|
+
console.error(`[${ts()}] [DISPLAY] terminal cardUpdate failed: ${(err as Error).message}`);
|
|
1396
|
+
if (isCardKitSequenceConflict(err)) {
|
|
1397
|
+
display.sequence = nextSeq;
|
|
1398
|
+
terminalCardUpdateAccepted = true;
|
|
1399
|
+
}
|
|
1400
|
+
});
|
|
1401
|
+
if (terminalCardUpdateAccepted) {
|
|
1402
|
+
display.lastSentAccLen = state.accumulatedContent.length;
|
|
1403
|
+
display.lastSentFinalReply = state.finalReply;
|
|
1391
1404
|
}
|
|
1392
|
-
}
|
|
1405
|
+
}
|
|
1393
1406
|
|
|
1394
1407
|
// 若 session 仍在 activePrompts 中,说明 runAgentSession 的 finally
|
|
1395
1408
|
// 还没执行,当前 stream state 可能是 stopSession fire-and-forget
|
|
1396
1409
|
// 写入的,finalReply 滞后于内存态。卡片已更新为终态外观,但不发送
|
|
1397
1410
|
// 文本、不删除 display 条目,留给 finally 落盘后的下一次 tick 处理。
|
|
1398
1411
|
if (promptStillActive) {
|
|
1399
|
-
if (terminalCardUpdateAccepted) {
|
|
1400
|
-
display.lastSentAccLen = state.accumulatedContent.length;
|
|
1401
|
-
display.lastSentFinalReply = state.finalReply;
|
|
1402
|
-
}
|
|
1403
1412
|
continue;
|
|
1404
1413
|
}
|
|
1405
1414
|
|
|
1406
|
-
|
|
1407
|
-
finalizeTurnCards(sessionId, state.turnCount, finalSt).catch(() => {});
|
|
1408
|
-
displayCards.delete(chatId);
|
|
1415
|
+
let terminalTextDelivered = true;
|
|
1409
1416
|
if (state.finalReply) {
|
|
1410
1417
|
if (!isFinalReplySentForTurn(state)) {
|
|
1411
|
-
await sendFinalReplyTextOnce(p, chatId, sessionId, state.turnCount, state.finalReply);
|
|
1418
|
+
terminalTextDelivered = await sendFinalReplyTextOnce(p, chatId, sessionId, state.turnCount, state.finalReply);
|
|
1412
1419
|
}
|
|
1413
1420
|
} else if (state.accumulatedContent.trim()) {
|
|
1414
1421
|
const short = truncateContent(state.accumulatedContent, 30, 4000);
|
|
1415
|
-
await p.sendText(chatId, `[生成过程]\n${short}`).catch(() =>
|
|
1422
|
+
terminalTextDelivered = await p.sendText(chatId, `[生成过程]\n${short}`).then((ok) => ok !== false).catch(() => false);
|
|
1416
1423
|
}
|
|
1424
|
+
|
|
1425
|
+
if (!terminalTextDelivered) {
|
|
1426
|
+
console.error(`[${ts()}] [DISPLAY] terminal text send failed, keep display for retry: chatId=${chatId} session=${sessionId} turn=${state.turnCount}`);
|
|
1427
|
+
continue;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
const finalSt = turnFinalStatus(state.status);
|
|
1431
|
+
finalizeTurnCards(sessionId, state.turnCount, finalSt).catch(() => {});
|
|
1432
|
+
displayCards.delete(chatId);
|
|
1417
1433
|
}
|
|
1418
1434
|
p.setChatAvatar(chatId, state.tool, "idle").catch(() => {});
|
|
1419
1435
|
console.log(`[${ts()}] [DISPLAY] unified loop deleted display for ${chatId} (terminal: ${state.status})`);
|
|
@@ -1561,9 +1577,12 @@ export function startUnifiedDisplayLoop(): void {
|
|
|
1561
1577
|
}
|
|
1562
1578
|
}
|
|
1563
1579
|
}
|
|
1564
|
-
|
|
1565
|
-
|
|
1580
|
+
} catch (err) {
|
|
1581
|
+
console.error(`[${ts()}] Display loop error for ${chatId}: ${(err as Error).message}`);
|
|
1582
|
+
}
|
|
1566
1583
|
}
|
|
1584
|
+
} finally {
|
|
1585
|
+
tickRunning = false;
|
|
1567
1586
|
}
|
|
1568
1587
|
})().catch((err: unknown) => {
|
|
1569
1588
|
const e = err instanceof Error ? err : new Error(String(err));
|