chatccc 0.2.171 → 0.2.173
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
CHANGED
|
@@ -169,6 +169,26 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
169
169
|
expect(platform.sendText).toHaveBeenCalledWith("wx-chat", "生成中...");
|
|
170
170
|
});
|
|
171
171
|
|
|
172
|
+
it("does not send the stopped success text until the running prompt really exits", async () => {
|
|
173
|
+
const platform = mockPlatform();
|
|
174
|
+
await recordSessionRegistry({
|
|
175
|
+
chatId: "wx-chat",
|
|
176
|
+
sessionId: "sid-wechat",
|
|
177
|
+
tool: "claude",
|
|
178
|
+
chatName: "busy-session",
|
|
179
|
+
running: true,
|
|
180
|
+
});
|
|
181
|
+
activePrompts.set("sid-wechat", {
|
|
182
|
+
controller: new AbortController(),
|
|
183
|
+
stopped: false,
|
|
184
|
+
startTime: Date.now(),
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
await handleCommand(platform, "/stop", "wx-chat", "wx-user", Date.now(), "p2p");
|
|
188
|
+
|
|
189
|
+
expect(platform.sendText).not.toHaveBeenCalledWith("wx-chat", "会话已停止。");
|
|
190
|
+
});
|
|
191
|
+
|
|
172
192
|
it("cleans stale Feishu p2p binding, creates a group, and sends the private message as first prompt", async () => {
|
|
173
193
|
const platform = mockPlatform("feishu");
|
|
174
194
|
const prompt = vi.fn(async function* (_sessionId: string, userText: string) {
|
|
@@ -177,7 +197,7 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
177
197
|
blocks: [{ type: "text" as const, text: `收到: ${userText}` }],
|
|
178
198
|
};
|
|
179
199
|
});
|
|
180
|
-
_setAdapterForToolForTest("claude", {
|
|
200
|
+
_setAdapterForToolForTest("claude", {
|
|
181
201
|
displayName: "Claude",
|
|
182
202
|
sessionDescPrefix: "Claude Session:",
|
|
183
203
|
createSession: vi.fn(async () => ({ sessionId: "sid-feishu-new" })),
|
|
@@ -88,6 +88,7 @@ import {
|
|
|
88
88
|
recordChatPlatform,
|
|
89
89
|
_getPlatformForChatForTest,
|
|
90
90
|
runAgentSession,
|
|
91
|
+
stopSession,
|
|
91
92
|
startUnifiedDisplayLoop,
|
|
92
93
|
stopUnifiedDisplayLoop,
|
|
93
94
|
_setProcessAliveForTest,
|
|
@@ -396,6 +397,139 @@ describe("runAgentSession process monitor", () => {
|
|
|
396
397
|
expect.stringContaining("进程异常结束"),
|
|
397
398
|
);
|
|
398
399
|
});
|
|
400
|
+
|
|
401
|
+
it("recreates the progress card when the initial CardKit send fails", async () => {
|
|
402
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
403
|
+
const platform = mockPlatform("feishu");
|
|
404
|
+
platform.cardCreate = vi.fn()
|
|
405
|
+
.mockResolvedValueOnce("bad-card")
|
|
406
|
+
.mockResolvedValueOnce("good-card");
|
|
407
|
+
platform.cardSend = vi.fn()
|
|
408
|
+
.mockRejectedValueOnce(new Error("cardid is invalid"))
|
|
409
|
+
.mockResolvedValueOnce("message-good");
|
|
410
|
+
setSessionPlatform(platform);
|
|
411
|
+
bindChatToSession("sid-card-retry", "chat-card-retry");
|
|
412
|
+
recordLastActiveChat("sid-card-retry", "chat-card-retry");
|
|
413
|
+
|
|
414
|
+
const adapter: ToolAdapter = {
|
|
415
|
+
displayName: "Claude Code",
|
|
416
|
+
sessionDescPrefix: "Claude Code Session:",
|
|
417
|
+
createSession: async () => ({ sessionId: "sid-card-retry" }),
|
|
418
|
+
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
419
|
+
closeSession: async () => {},
|
|
420
|
+
prompt: async function* () {
|
|
421
|
+
yield { type: "assistant", blocks: [{ type: "text", text: "done" }] };
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
_setAdapterForToolForTest("claude", adapter);
|
|
425
|
+
|
|
426
|
+
await runAgentSession("sid-card-retry", "prompt", platform, "chat-card-retry", Date.now(), "claude");
|
|
427
|
+
|
|
428
|
+
expect(platform.cardCreate).toHaveBeenCalledTimes(2);
|
|
429
|
+
expect(platform.cardSend).toHaveBeenNthCalledWith(1, "chat-card-retry", "bad-card");
|
|
430
|
+
expect(platform.cardSend).toHaveBeenNthCalledWith(2, "chat-card-retry", "good-card");
|
|
431
|
+
expect(displayCards.get("chat-card-retry")?.cardId).toBe("good-card");
|
|
432
|
+
expect(platform.sendText).not.toHaveBeenCalledWith(
|
|
433
|
+
"chat-card-retry",
|
|
434
|
+
"生成中卡片发送失败,结果将以文本形式发送。",
|
|
435
|
+
);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("does not register an invisible progress card and sends the final text fallback", async () => {
|
|
439
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
440
|
+
const platform = mockPlatform("feishu");
|
|
441
|
+
platform.cardCreate = vi.fn()
|
|
442
|
+
.mockResolvedValueOnce("bad-card-1")
|
|
443
|
+
.mockResolvedValueOnce("bad-card-2");
|
|
444
|
+
platform.cardSend = vi.fn()
|
|
445
|
+
.mockRejectedValueOnce(new Error("cardid is invalid"))
|
|
446
|
+
.mockRejectedValueOnce(new Error("cardid is invalid again"));
|
|
447
|
+
setSessionPlatform(platform);
|
|
448
|
+
bindChatToSession("sid-card-fallback", "chat-card-fallback");
|
|
449
|
+
recordLastActiveChat("sid-card-fallback", "chat-card-fallback");
|
|
450
|
+
|
|
451
|
+
const adapter: ToolAdapter = {
|
|
452
|
+
displayName: "Claude Code",
|
|
453
|
+
sessionDescPrefix: "Claude Code Session:",
|
|
454
|
+
createSession: async () => ({ sessionId: "sid-card-fallback" }),
|
|
455
|
+
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
456
|
+
closeSession: async () => {},
|
|
457
|
+
prompt: async function* () {
|
|
458
|
+
yield { type: "assistant", blocks: [{ type: "text", text: "final answer" }] };
|
|
459
|
+
},
|
|
460
|
+
};
|
|
461
|
+
_setAdapterForToolForTest("claude", adapter);
|
|
462
|
+
|
|
463
|
+
await runAgentSession("sid-card-fallback", "prompt", platform, "chat-card-fallback", Date.now(), "claude");
|
|
464
|
+
|
|
465
|
+
expect(displayCards.has("chat-card-fallback")).toBe(false);
|
|
466
|
+
expect(platform.sendText).toHaveBeenCalledWith(
|
|
467
|
+
"chat-card-fallback",
|
|
468
|
+
"生成中卡片发送失败,结果将以文本形式发送。",
|
|
469
|
+
);
|
|
470
|
+
expect(platform.sendText).toHaveBeenCalledWith("chat-card-fallback", "final answer");
|
|
471
|
+
expect(mockStreamStates.get("sid-card-fallback")?.finalReplySentTurn).toBe(1);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("sends the stopped notice only after the prompt generator exits", async () => {
|
|
475
|
+
const platform = mockPlatform("feishu");
|
|
476
|
+
setSessionPlatform(platform);
|
|
477
|
+
bindChatToSession("sid-stop-notice", "chat-stop-notice");
|
|
478
|
+
recordLastActiveChat("sid-stop-notice", "chat-stop-notice");
|
|
479
|
+
|
|
480
|
+
const closeSession = vi.fn();
|
|
481
|
+
let releasePrompt: (() => void) | undefined;
|
|
482
|
+
const adapter: ToolAdapter = {
|
|
483
|
+
displayName: "Claude Code",
|
|
484
|
+
sessionDescPrefix: "Claude Code Session:",
|
|
485
|
+
createSession: async () => ({ sessionId: "sid-stop-notice" }),
|
|
486
|
+
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
487
|
+
closeSession: async () => {},
|
|
488
|
+
prompt: async function* (
|
|
489
|
+
_sid: string,
|
|
490
|
+
_text: string,
|
|
491
|
+
_cwd: string,
|
|
492
|
+
signal?: AbortSignal,
|
|
493
|
+
options?: ToolPromptOptions,
|
|
494
|
+
) {
|
|
495
|
+
const waitForStop = new Promise<void>((resolve) => {
|
|
496
|
+
releasePrompt = resolve;
|
|
497
|
+
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
498
|
+
});
|
|
499
|
+
options?.onSessionCreated?.(() => {
|
|
500
|
+
closeSession();
|
|
501
|
+
releasePrompt?.();
|
|
502
|
+
});
|
|
503
|
+
yield { type: "assistant", blocks: [{ type: "text", text: "partial answer" }] };
|
|
504
|
+
await waitForStop;
|
|
505
|
+
},
|
|
506
|
+
};
|
|
507
|
+
_setAdapterForToolForTest("claude", adapter);
|
|
508
|
+
|
|
509
|
+
const runPromise = runAgentSession(
|
|
510
|
+
"sid-stop-notice",
|
|
511
|
+
"prompt",
|
|
512
|
+
platform,
|
|
513
|
+
"chat-stop-notice",
|
|
514
|
+
Date.now(),
|
|
515
|
+
"claude",
|
|
516
|
+
);
|
|
517
|
+
|
|
518
|
+
await vi.waitFor(() => {
|
|
519
|
+
expect(activePrompts.get("sid-stop-notice")?.closeSession).toBeTypeOf("function");
|
|
520
|
+
});
|
|
521
|
+
expect(platform.sendText).not.toHaveBeenCalledWith("chat-stop-notice", "会话已停止。");
|
|
522
|
+
|
|
523
|
+
const ok = stopSession("sid-stop-notice");
|
|
524
|
+
expect(ok).toBe(true);
|
|
525
|
+
expect(closeSession).toHaveBeenCalledTimes(1);
|
|
526
|
+
|
|
527
|
+
await runPromise;
|
|
528
|
+
|
|
529
|
+
expect(platform.sendText).toHaveBeenCalledWith("chat-stop-notice", "会话已停止。");
|
|
530
|
+
expect(activePrompts.has("sid-stop-notice")).toBe(false);
|
|
531
|
+
});
|
|
532
|
+
|
|
399
533
|
it("re-injects IM skill capabilities for each resumed Claude prompt", async () => {
|
|
400
534
|
const platform = mockPlatform("feishu");
|
|
401
535
|
setSessionPlatform(platform);
|
|
@@ -41,9 +41,13 @@ vi.mock("../stream-state.ts", () => ({
|
|
|
41
41
|
import { stopSession } from "../session.ts";
|
|
42
42
|
import { activePrompts } from "../session-chat-binding.ts";
|
|
43
43
|
|
|
44
|
-
function seedRunningSession(
|
|
44
|
+
function seedRunningSession(
|
|
45
|
+
sid: string,
|
|
46
|
+
accumulated = "partial output",
|
|
47
|
+
closeSession?: () => void,
|
|
48
|
+
): AbortController {
|
|
45
49
|
const controller = new AbortController();
|
|
46
|
-
activePrompts.set(sid, { controller, stopped: false, startTime: Date.now() });
|
|
50
|
+
activePrompts.set(sid, { controller, stopped: false, startTime: Date.now(), closeSession });
|
|
47
51
|
stateStore.set(sid, {
|
|
48
52
|
sessionId: sid,
|
|
49
53
|
status: "running",
|
|
@@ -123,4 +127,14 @@ describe("stopSession 行为护栏", () => {
|
|
|
123
127
|
stopSession("sid-C");
|
|
124
128
|
expect(activePrompts.get("sid-C")?.stopped).toBe(true);
|
|
125
129
|
});
|
|
130
|
+
|
|
131
|
+
it("调用 adapter 提供的 closeSession 以主动关闭底层 SDK session", () => {
|
|
132
|
+
const closeSession = vi.fn();
|
|
133
|
+
seedRunningSession("sid-D", "partial output", closeSession);
|
|
134
|
+
|
|
135
|
+
const ok = stopSession("sid-D");
|
|
136
|
+
|
|
137
|
+
expect(ok).toBe(true);
|
|
138
|
+
expect(closeSession).toHaveBeenCalledTimes(1);
|
|
139
|
+
});
|
|
126
140
|
});
|
package/src/orchestrator.ts
CHANGED
|
@@ -778,8 +778,7 @@ export async function handleCommand(
|
|
|
778
778
|
logTrace(tid, "BRANCH", { cmd: "/stop" });
|
|
779
779
|
if (stopSession(sessionId)) {
|
|
780
780
|
console.log(`[${ts()}] [STOP] User sent /stop, session=${sessionId}`);
|
|
781
|
-
|
|
782
|
-
logTrace(tid, "DONE", { outcome: "stopped" });
|
|
781
|
+
logTrace(tid, "DONE", { outcome: "stop_requested" });
|
|
783
782
|
} else {
|
|
784
783
|
await platform
|
|
785
784
|
.sendText(chatId, "当前没有正在进行的会话。")
|
package/src/session.ts
CHANGED
|
@@ -81,6 +81,36 @@ async function sendFinalReplyTextOnce(
|
|
|
81
81
|
return sent;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
async function createVisibleProgressCard(
|
|
85
|
+
platform: PlatformAdapter,
|
|
86
|
+
chatId: string,
|
|
87
|
+
sessionId: string,
|
|
88
|
+
turnCount: number,
|
|
89
|
+
notifyFailureText?: string,
|
|
90
|
+
): Promise<string | null> {
|
|
91
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
92
|
+
let cardId: string | null = null;
|
|
93
|
+
try {
|
|
94
|
+
cardId = await platform.cardCreate(
|
|
95
|
+
buildProgressCard("", { showStop: true, headerTitle: "生成中..." }),
|
|
96
|
+
);
|
|
97
|
+
if (!cardId) throw new Error("empty card id");
|
|
98
|
+
await platform.cardSend(chatId, cardId);
|
|
99
|
+
await addCardToTurn(sessionId, turnCount, cardId);
|
|
100
|
+
return cardId;
|
|
101
|
+
} catch (err) {
|
|
102
|
+
console.error(
|
|
103
|
+
`[${ts()}] [DISPLAY] progress card send attempt ${attempt} failed: chatId=${chatId} cardId=${cardId || "(none)"} ${(err as Error).message}`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (notifyFailureText) {
|
|
109
|
+
await platform.sendText(chatId, notifyFailureText).catch(() => {});
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
84
114
|
// ---------------------------------------------------------------------------
|
|
85
115
|
// Shared state (imported by index.ts)
|
|
86
116
|
// ---------------------------------------------------------------------------
|
|
@@ -1053,11 +1083,14 @@ export async function runAgentSession(
|
|
|
1053
1083
|
if (displayChatIdForNew) {
|
|
1054
1084
|
const ppNew = platformForChat(displayChatIdForNew);
|
|
1055
1085
|
if (ppNew && ppNew.kind !== "wechat") {
|
|
1056
|
-
const cardId = await
|
|
1057
|
-
|
|
1058
|
-
|
|
1086
|
+
const cardId = await createVisibleProgressCard(
|
|
1087
|
+
ppNew,
|
|
1088
|
+
displayChatIdForNew,
|
|
1089
|
+
sessionId,
|
|
1090
|
+
nextTurnCount,
|
|
1091
|
+
"生成中卡片发送失败,结果将以文本形式发送。",
|
|
1092
|
+
);
|
|
1059
1093
|
if (cardId) {
|
|
1060
|
-
await ppNew.cardSend(displayChatIdForNew, cardId).catch(() => null);
|
|
1061
1094
|
displayCards.set(displayChatIdForNew, {
|
|
1062
1095
|
cardId,
|
|
1063
1096
|
sequence: 1,
|
|
@@ -1069,7 +1102,6 @@ export async function runAgentSession(
|
|
|
1069
1102
|
turnCount: nextTurnCount,
|
|
1070
1103
|
dotCount: 0,
|
|
1071
1104
|
});
|
|
1072
|
-
addCardToTurn(sessionId, nextTurnCount, cardId).catch(() => {});
|
|
1073
1105
|
}
|
|
1074
1106
|
} else if (ppNew && ppNew.kind === "wechat") {
|
|
1075
1107
|
// WeChat: 无卡片,但需要 display entry 追踪已发送内容
|
|
@@ -1247,7 +1279,10 @@ export async function runAgentSession(
|
|
|
1247
1279
|
});
|
|
1248
1280
|
}
|
|
1249
1281
|
const active1 = getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
|
|
1250
|
-
if (active1)
|
|
1282
|
+
if (active1) {
|
|
1283
|
+
await platform.sendText(active1, "会话已停止。").catch(() => {});
|
|
1284
|
+
platform.setChatAvatar(active1, tool, "idle").catch(() => {});
|
|
1285
|
+
}
|
|
1251
1286
|
console.log(`[${ts()}] Session ${sessionId} stopped (content chunks: ${state.chunkCount})`);
|
|
1252
1287
|
if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "stopped", chunks: state.chunkCount });
|
|
1253
1288
|
} else if (wasAbnormalExit) {
|
|
@@ -1281,7 +1316,14 @@ export async function runAgentSession(
|
|
|
1281
1316
|
});
|
|
1282
1317
|
}
|
|
1283
1318
|
const active2 = getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
|
|
1284
|
-
if (active2)
|
|
1319
|
+
if (active2) {
|
|
1320
|
+
const terminalState = await readStreamState(sessionId);
|
|
1321
|
+
if (finalReply && !displayCards.has(active2) && (!terminalState || !isFinalReplySentForTurn(terminalState))) {
|
|
1322
|
+
const pp = platformForChat(active2) ?? platform;
|
|
1323
|
+
await sendFinalReplyTextOnce(pp, active2, sessionId, nextTurnCount, finalReply);
|
|
1324
|
+
}
|
|
1325
|
+
platform.setChatAvatar(active2, tool, "idle").catch(() => {});
|
|
1326
|
+
}
|
|
1285
1327
|
console.log(`[${ts()}] Session ${sessionId} stream complete (content chunks: ${state.chunkCount})`);
|
|
1286
1328
|
if (tid) logTrace(tid, "SESSION_END", { sessionId, chunks: state.chunkCount, finalTextLen: finalReply.length });
|
|
1287
1329
|
}
|
|
@@ -1479,28 +1521,33 @@ export function startUnifiedDisplayLoop(): void {
|
|
|
1479
1521
|
if (Date.now() - display.cardCreatedAt > CARD_ROTATE_MS) {
|
|
1480
1522
|
display.cardBusy = true;
|
|
1481
1523
|
try {
|
|
1524
|
+
const newCardId = await createVisibleProgressCard(
|
|
1525
|
+
p,
|
|
1526
|
+
chatId,
|
|
1527
|
+
sessionId,
|
|
1528
|
+
display.turnCount,
|
|
1529
|
+
display.streamErrorNotified ? undefined : "生成中卡片发送失败,结果将继续更新在上一张卡片中。",
|
|
1530
|
+
);
|
|
1531
|
+
if (!newCardId) {
|
|
1532
|
+
display.streamErrorNotified = true;
|
|
1533
|
+
continue;
|
|
1534
|
+
}
|
|
1482
1535
|
const oldSeqBase = display.sequence;
|
|
1483
1536
|
const oldContent = state.accumulatedContent + state.finalReply;
|
|
1484
1537
|
const oldCard = buildProgressCard(truncateContent(oldContent) || " ", { showStop: false, headerTitle: "生成中(上轮)" });
|
|
1485
|
-
await p.cardUpdate(display.cardId, oldCard, oldSeqBase + 1).
|
|
1538
|
+
await p.cardUpdate(display.cardId, oldCard, oldSeqBase + 1).then(() => {
|
|
1539
|
+
display.sequence = oldSeqBase + 1;
|
|
1540
|
+
}).catch(err => {
|
|
1486
1541
|
console.error(`[${ts()}] [DISPLAY] rotation old cardUpdate failed: ${(err as Error).message}`);
|
|
1487
1542
|
});
|
|
1488
1543
|
markCardDone(sessionId, display.turnCount, display.cardId).catch(() => {});
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
display.cardId = newCardId;
|
|
1497
|
-
display.sequence = 1;
|
|
1498
|
-
display.cardCreatedAt = Date.now();
|
|
1499
|
-
display.rotationAccLen = state.accumulatedContent.length;
|
|
1500
|
-
display.rotationFinalReply = state.finalReply;
|
|
1501
|
-
display.lastSentContent = "";
|
|
1502
|
-
display.streamErrorNotified = false;
|
|
1503
|
-
}
|
|
1544
|
+
display.cardId = newCardId;
|
|
1545
|
+
display.sequence = 1;
|
|
1546
|
+
display.cardCreatedAt = Date.now();
|
|
1547
|
+
display.rotationAccLen = state.accumulatedContent.length;
|
|
1548
|
+
display.rotationFinalReply = state.finalReply;
|
|
1549
|
+
display.lastSentContent = "";
|
|
1550
|
+
display.streamErrorNotified = false;
|
|
1504
1551
|
} catch (err) {
|
|
1505
1552
|
console.error(`[${ts()}] [CARDIKT] rotation FAIL for ${chatId}: ${(err as Error).message}`);
|
|
1506
1553
|
} finally {
|
|
@@ -1623,6 +1670,11 @@ export function stopSession(sessionId: string): boolean {
|
|
|
1623
1670
|
prompt.stopped = true;
|
|
1624
1671
|
clearPromptProcessMonitor(sessionId);
|
|
1625
1672
|
cancelQueuedMessage(sessionId);
|
|
1673
|
+
try {
|
|
1674
|
+
prompt.closeSession?.();
|
|
1675
|
+
} catch (err) {
|
|
1676
|
+
console.warn(`[${ts()}] [STOP] closeSession failed for ${sessionId}: ${(err as Error).message}`);
|
|
1677
|
+
}
|
|
1626
1678
|
prompt.controller.abort();
|
|
1627
1679
|
|
|
1628
1680
|
// 强制杀死 CLI 子进程。controller.abort() 只在 for-await 收到下一条
|