chatccc 0.2.93 → 0.2.95
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__/session.test.ts +219 -4
- package/src/__tests__/stream-state.test.ts +42 -12
- package/src/adapters/adapter-interface.ts +13 -0
- package/src/adapters/codex-adapter.ts +4 -0
- package/src/adapters/cursor-adapter.ts +4 -0
- package/src/session-chat-binding.ts +9 -0
- package/src/session.ts +217 -28
- package/src/stream-state.ts +33 -14
package/package.json
CHANGED
|
@@ -4,17 +4,63 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
|
|
6
6
|
// mock stream-state 以支持在测试中控制累积长度
|
|
7
|
-
const mockStreamStates = new Map<string, {
|
|
7
|
+
const mockStreamStates = new Map<string, {
|
|
8
|
+
accumulatedContent: string;
|
|
9
|
+
finalReply: string;
|
|
10
|
+
status?: "running" | "done" | "stopped" | "error";
|
|
11
|
+
turnCount?: number;
|
|
12
|
+
finalReplySentTurn?: number;
|
|
13
|
+
finalReplySentAt?: number;
|
|
14
|
+
}>();
|
|
8
15
|
vi.mock("../stream-state.ts", () => ({
|
|
9
16
|
readStreamState: async (sid: string) => {
|
|
10
17
|
const state = mockStreamStates.get(sid);
|
|
11
18
|
if (!state) return null;
|
|
12
|
-
return {
|
|
19
|
+
return {
|
|
20
|
+
sessionId: sid,
|
|
21
|
+
accumulatedContent: state.accumulatedContent,
|
|
22
|
+
finalReply: state.finalReply,
|
|
23
|
+
finalReplySentTurn: state.finalReplySentTurn,
|
|
24
|
+
finalReplySentAt: state.finalReplySentAt,
|
|
25
|
+
status: state.status ?? "running",
|
|
26
|
+
chunkCount: 0,
|
|
27
|
+
turnCount: state.turnCount ?? 0,
|
|
28
|
+
contextTokens: 0,
|
|
29
|
+
updatedAt: Date.now(),
|
|
30
|
+
cwd: "",
|
|
31
|
+
tool: "claude",
|
|
32
|
+
};
|
|
33
|
+
},
|
|
34
|
+
writeStreamState: async (state: {
|
|
35
|
+
sessionId: string;
|
|
36
|
+
accumulatedContent: string;
|
|
37
|
+
finalReply: string;
|
|
38
|
+
status?: "running" | "done" | "stopped" | "error";
|
|
39
|
+
turnCount?: number;
|
|
40
|
+
finalReplySentTurn?: number;
|
|
41
|
+
finalReplySentAt?: number;
|
|
42
|
+
}) => {
|
|
43
|
+
mockStreamStates.set(state.sessionId, {
|
|
44
|
+
accumulatedContent: state.accumulatedContent,
|
|
45
|
+
finalReply: state.finalReply,
|
|
46
|
+
status: state.status,
|
|
47
|
+
turnCount: state.turnCount,
|
|
48
|
+
finalReplySentTurn: state.finalReplySentTurn,
|
|
49
|
+
finalReplySentAt: state.finalReplySentAt,
|
|
50
|
+
});
|
|
13
51
|
},
|
|
14
|
-
writeStreamState: async () => {},
|
|
15
52
|
createEmptyStreamState: (sid: string, cwd: string, tool: string, turnCount: number) => ({
|
|
16
53
|
sessionId: sid, status: "running" as const, accumulatedContent: "", finalReply: "", chunkCount: 0, turnCount, contextTokens: 0, updatedAt: Date.now(), cwd, tool,
|
|
17
54
|
}),
|
|
55
|
+
isFinalReplySentForTurn: (state: { turnCount: number; finalReplySentTurn?: number }) => state.finalReplySentTurn === state.turnCount,
|
|
56
|
+
markFinalReplySent: async (sid: string, turnCount: number, sentAt = Date.now()) => {
|
|
57
|
+
const state = mockStreamStates.get(sid);
|
|
58
|
+
if (!state) return;
|
|
59
|
+
if ((state.turnCount ?? 0) !== turnCount) return;
|
|
60
|
+
if ((state.status ?? "running") === "running") return;
|
|
61
|
+
state.finalReplySentTurn = turnCount;
|
|
62
|
+
state.finalReplySentAt = sentAt;
|
|
63
|
+
},
|
|
18
64
|
fixStaleStreamStates: async () => {},
|
|
19
65
|
}));
|
|
20
66
|
import {
|
|
@@ -41,8 +87,13 @@ import {
|
|
|
41
87
|
setSessionPlatform,
|
|
42
88
|
recordChatPlatform,
|
|
43
89
|
_getPlatformForChatForTest,
|
|
90
|
+
runAgentSession,
|
|
44
91
|
startUnifiedDisplayLoop,
|
|
45
92
|
stopUnifiedDisplayLoop,
|
|
93
|
+
_setProcessAliveForTest,
|
|
94
|
+
_resetProcessAliveForTest,
|
|
95
|
+
_setProcessMonitorIntervalForTest,
|
|
96
|
+
_resetProcessMonitorIntervalForTest,
|
|
46
97
|
} from "../session.ts";
|
|
47
98
|
import {
|
|
48
99
|
activePrompts,
|
|
@@ -56,7 +107,7 @@ import {
|
|
|
56
107
|
displayCards,
|
|
57
108
|
} from "../session-chat-binding.ts";
|
|
58
109
|
import type { AccumulatorState } from "../session.ts";
|
|
59
|
-
import type { ToolAdapter, UnifiedBlock, SessionInfo } from "../adapters/adapter-interface.ts";
|
|
110
|
+
import type { ToolAdapter, ToolPromptOptions, UnifiedBlock, SessionInfo } from "../adapters/adapter-interface.ts";
|
|
60
111
|
import type { PlatformAdapter } from "../platform-adapter.ts";
|
|
61
112
|
|
|
62
113
|
// Helper to create a mock active session entry
|
|
@@ -185,6 +236,168 @@ describe("chat platform routing", () => {
|
|
|
185
236
|
// processedMessages 全部保留——SDK 重连不应当影响后台 prompt 的执行。
|
|
186
237
|
// ---------------------------------------------------------------------------
|
|
187
238
|
|
|
239
|
+
describe("runAgentSession previous final delivery guard", () => {
|
|
240
|
+
let registryFile = "";
|
|
241
|
+
let toolsFile = "";
|
|
242
|
+
|
|
243
|
+
beforeEach(async () => {
|
|
244
|
+
resetState();
|
|
245
|
+
resetBindingState();
|
|
246
|
+
mockStreamStates.clear();
|
|
247
|
+
const dir = await mkdtemp(join(tmpdir(), "chatccc-final-guard-"));
|
|
248
|
+
registryFile = join(dir, "session-registry.json");
|
|
249
|
+
toolsFile = join(dir, "session-tools.json");
|
|
250
|
+
_setSessionRegistryFileForTest(registryFile);
|
|
251
|
+
_setSessionToolsFileForTest(toolsFile);
|
|
252
|
+
_setAdapterForToolForTest(
|
|
253
|
+
"claude",
|
|
254
|
+
mockAdapter((sid) => ({ sessionId: sid, cwd: "/tmp" })),
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
afterEach(() => {
|
|
259
|
+
_resetSessionRegistryFileForTest();
|
|
260
|
+
_resetSessionToolsFileForTest();
|
|
261
|
+
_clearAdapterCacheForTest();
|
|
262
|
+
resetBindingState();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("does not resend the previous terminal final when the same turn is already marked sent", async () => {
|
|
266
|
+
const platform = mockPlatform("feishu");
|
|
267
|
+
setSessionPlatform(platform);
|
|
268
|
+
bindChatToSession("sid-final", "chat-final");
|
|
269
|
+
recordLastActiveChat("sid-final", "chat-final");
|
|
270
|
+
sessionInfoMap.set("chat-final", {
|
|
271
|
+
sessionId: "sid-final",
|
|
272
|
+
turnCount: 1,
|
|
273
|
+
lastContextTokens: 0,
|
|
274
|
+
startTime: 0,
|
|
275
|
+
tool: "claude",
|
|
276
|
+
});
|
|
277
|
+
mockStreamStates.set("sid-final", {
|
|
278
|
+
accumulatedContent: "",
|
|
279
|
+
finalReply: "old final",
|
|
280
|
+
status: "done",
|
|
281
|
+
turnCount: 1,
|
|
282
|
+
finalReplySentTurn: 1,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
await runAgentSession("sid-final", "next prompt", platform, "chat-final", Date.now(), "claude");
|
|
286
|
+
|
|
287
|
+
expect(platform.sendText).not.toHaveBeenCalledWith("chat-final", "old final");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("resends the previous terminal final when there is no delivery marker", async () => {
|
|
291
|
+
const platform = mockPlatform("feishu");
|
|
292
|
+
setSessionPlatform(platform);
|
|
293
|
+
bindChatToSession("sid-unsent", "chat-unsent");
|
|
294
|
+
recordLastActiveChat("sid-unsent", "chat-unsent");
|
|
295
|
+
sessionInfoMap.set("chat-unsent", {
|
|
296
|
+
sessionId: "sid-unsent",
|
|
297
|
+
turnCount: 1,
|
|
298
|
+
lastContextTokens: 0,
|
|
299
|
+
startTime: 0,
|
|
300
|
+
tool: "claude",
|
|
301
|
+
});
|
|
302
|
+
mockStreamStates.set("sid-unsent", {
|
|
303
|
+
accumulatedContent: "",
|
|
304
|
+
finalReply: "old final",
|
|
305
|
+
status: "done",
|
|
306
|
+
turnCount: 1,
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
await runAgentSession("sid-unsent", "next prompt", platform, "chat-unsent", Date.now(), "claude");
|
|
310
|
+
|
|
311
|
+
expect(platform.sendText).toHaveBeenCalledWith("chat-unsent", "old final");
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
describe("runAgentSession process monitor", () => {
|
|
316
|
+
let registryFile = "";
|
|
317
|
+
let toolsFile = "";
|
|
318
|
+
|
|
319
|
+
beforeEach(async () => {
|
|
320
|
+
vi.useFakeTimers();
|
|
321
|
+
resetState();
|
|
322
|
+
resetBindingState();
|
|
323
|
+
mockStreamStates.clear();
|
|
324
|
+
const dir = await mkdtemp(join(tmpdir(), "chatccc-process-monitor-"));
|
|
325
|
+
registryFile = join(dir, "session-registry.json");
|
|
326
|
+
toolsFile = join(dir, "session-tools.json");
|
|
327
|
+
_setSessionRegistryFileForTest(registryFile);
|
|
328
|
+
_setSessionToolsFileForTest(toolsFile);
|
|
329
|
+
_setProcessMonitorIntervalForTest(50);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
afterEach(() => {
|
|
333
|
+
_resetSessionRegistryFileForTest();
|
|
334
|
+
_resetSessionToolsFileForTest();
|
|
335
|
+
_clearAdapterCacheForTest();
|
|
336
|
+
_resetProcessAliveForTest();
|
|
337
|
+
_resetProcessMonitorIntervalForTest();
|
|
338
|
+
resetBindingState();
|
|
339
|
+
vi.useRealTimers();
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("marks the turn as error and sends a separate notice when the CLI process disappears", async () => {
|
|
343
|
+
const platform = mockPlatform("feishu");
|
|
344
|
+
setSessionPlatform(platform);
|
|
345
|
+
bindChatToSession("sid-process", "chat-process");
|
|
346
|
+
recordLastActiveChat("sid-process", "chat-process");
|
|
347
|
+
_setProcessAliveForTest(() => false);
|
|
348
|
+
|
|
349
|
+
const adapter: ToolAdapter = {
|
|
350
|
+
displayName: "Cursor",
|
|
351
|
+
sessionDescPrefix: "Cursor Session:",
|
|
352
|
+
createSession: async () => ({ sessionId: "sid-process" }),
|
|
353
|
+
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "/tmp" }),
|
|
354
|
+
closeSession: async () => {},
|
|
355
|
+
prompt: async function* (
|
|
356
|
+
_sid: string,
|
|
357
|
+
_text: string,
|
|
358
|
+
_cwd: string,
|
|
359
|
+
signal?: AbortSignal,
|
|
360
|
+
options?: ToolPromptOptions,
|
|
361
|
+
) {
|
|
362
|
+
options?.onProcessStart?.({ pid: 12345 });
|
|
363
|
+
yield { type: "assistant", blocks: [{ type: "text", text: "partial answer" }] };
|
|
364
|
+
await new Promise<void>((resolve) => {
|
|
365
|
+
if (signal?.aborted) {
|
|
366
|
+
resolve();
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
370
|
+
});
|
|
371
|
+
},
|
|
372
|
+
};
|
|
373
|
+
_setAdapterForToolForTest("cursor", adapter);
|
|
374
|
+
|
|
375
|
+
const runPromise = runAgentSession(
|
|
376
|
+
"sid-process",
|
|
377
|
+
"prompt",
|
|
378
|
+
platform,
|
|
379
|
+
"chat-process",
|
|
380
|
+
Date.now(),
|
|
381
|
+
"cursor",
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
await vi.waitFor(() => {
|
|
385
|
+
expect(activePrompts.get("sid-process")?.processPid).toBe(12345);
|
|
386
|
+
});
|
|
387
|
+
await vi.advanceTimersByTimeAsync(60);
|
|
388
|
+
await runPromise;
|
|
389
|
+
|
|
390
|
+
const state = mockStreamStates.get("sid-process");
|
|
391
|
+
expect(state?.status).toBe("error");
|
|
392
|
+
expect(state?.finalReply).toContain("partial answer");
|
|
393
|
+
expect(activePrompts.has("sid-process")).toBe(false);
|
|
394
|
+
expect(platform.sendText).toHaveBeenCalledWith(
|
|
395
|
+
"chat-process",
|
|
396
|
+
expect.stringContaining("进程异常结束"),
|
|
397
|
+
);
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
188
401
|
describe("unified display loop WeChat delta", () => {
|
|
189
402
|
beforeEach(() => {
|
|
190
403
|
vi.useFakeTimers();
|
|
@@ -1004,6 +1217,7 @@ describe("switchChatBinding", () => {
|
|
|
1004
1217
|
displayCards.set("chat-1", {
|
|
1005
1218
|
cardId: "c1", sequence: 1, cardBusy: false,
|
|
1006
1219
|
cardCreatedAt: 0, lastSentContent: "", streamErrorNotified: false,
|
|
1220
|
+
sessionId: "old-sid", turnCount: 5, dotCount: 0,
|
|
1007
1221
|
});
|
|
1008
1222
|
|
|
1009
1223
|
const result = await switchChatBinding({
|
|
@@ -1068,6 +1282,7 @@ describe("switchChatBinding", () => {
|
|
|
1068
1282
|
const oldDisplay = {
|
|
1069
1283
|
cardId: "c1", sequence: 1, cardBusy: false,
|
|
1070
1284
|
cardCreatedAt: 0, lastSentContent: "", streamErrorNotified: false,
|
|
1285
|
+
sessionId: "old-sid", turnCount: 5, dotCount: 0,
|
|
1071
1286
|
};
|
|
1072
1287
|
displayCards.set("chat-1", oldDisplay);
|
|
1073
1288
|
|
|
@@ -15,10 +15,12 @@ vi.mock("../config.ts", async () => {
|
|
|
15
15
|
});
|
|
16
16
|
|
|
17
17
|
const {
|
|
18
|
-
writeStreamState,
|
|
19
|
-
readStreamState,
|
|
20
|
-
createEmptyStreamState,
|
|
21
|
-
|
|
18
|
+
writeStreamState,
|
|
19
|
+
readStreamState,
|
|
20
|
+
createEmptyStreamState,
|
|
21
|
+
isFinalReplySentForTurn,
|
|
22
|
+
markFinalReplySent,
|
|
23
|
+
STREAMS_DIR,
|
|
22
24
|
_setRenameForTest,
|
|
23
25
|
_resetRenameForTest,
|
|
24
26
|
} = await import("../stream-state.ts");
|
|
@@ -117,9 +119,36 @@ describe("writeStreamState — atomic rename", () => {
|
|
|
117
119
|
const got = await readStreamState("never-existed");
|
|
118
120
|
expect(got).toBeNull();
|
|
119
121
|
});
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
122
|
+
it("marks final reply delivery for the matching terminal turn only", async () => {
|
|
123
|
+
const state = createEmptyStreamState("sid-delivered", "/tmp", "claude", 2);
|
|
124
|
+
state.status = "done";
|
|
125
|
+
state.finalReply = "done";
|
|
126
|
+
await writeStreamState(state);
|
|
127
|
+
|
|
128
|
+
await markFinalReplySent("sid-delivered", 2, 12345);
|
|
129
|
+
|
|
130
|
+
const got = await readStreamState("sid-delivered");
|
|
131
|
+
expect(got).not.toBeNull();
|
|
132
|
+
expect(got!.finalReplySentTurn).toBe(2);
|
|
133
|
+
expect(got!.finalReplySentAt).toBe(12345);
|
|
134
|
+
expect(isFinalReplySentForTurn(got!)).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("does not mark a running state or a different turn as delivered", async () => {
|
|
138
|
+
const state = createEmptyStreamState("sid-running", "/tmp", "claude", 3);
|
|
139
|
+
await writeStreamState(state);
|
|
140
|
+
|
|
141
|
+
await markFinalReplySent("sid-running", 2, 12345);
|
|
142
|
+
await markFinalReplySent("sid-running", 3, 12345);
|
|
143
|
+
|
|
144
|
+
const got = await readStreamState("sid-running");
|
|
145
|
+
expect(got).not.toBeNull();
|
|
146
|
+
expect(got!.finalReplySentTurn).toBeUndefined();
|
|
147
|
+
expect(isFinalReplySentForTurn(got!)).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("createEmptyStreamState", () => {
|
|
123
152
|
it("生成的 state 字段正确且 status=running", () => {
|
|
124
153
|
const s = createEmptyStreamState("sid-X", "/cwd", "cursor", 5);
|
|
125
154
|
expect(s.sessionId).toBe("sid-X");
|
|
@@ -127,8 +156,9 @@ describe("createEmptyStreamState", () => {
|
|
|
127
156
|
expect(s.tool).toBe("cursor");
|
|
128
157
|
expect(s.turnCount).toBe(5);
|
|
129
158
|
expect(s.status).toBe("running");
|
|
130
|
-
expect(s.accumulatedContent).toBe("");
|
|
131
|
-
expect(s.finalReply).toBe("");
|
|
132
|
-
expect(s.
|
|
133
|
-
|
|
134
|
-
});
|
|
159
|
+
expect(s.accumulatedContent).toBe("");
|
|
160
|
+
expect(s.finalReply).toBe("");
|
|
161
|
+
expect(s.finalReplySentTurn).toBeUndefined();
|
|
162
|
+
expect(s.chunkCount).toBe(0);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -113,6 +113,18 @@ export interface SessionInfo {
|
|
|
113
113
|
model?: string;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
export interface ToolProcessInfo {
|
|
117
|
+
/** Root PID returned by child_process.spawn. With shell:true this is the shell wrapper PID. */
|
|
118
|
+
pid: number;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface ToolPromptOptions {
|
|
122
|
+
/** Called once a CLI-backed prompt process has been spawned. */
|
|
123
|
+
onProcessStart?: (info: ToolProcessInfo) => void;
|
|
124
|
+
/** Called when the adapter leaves the prompt process scope normally or by abort. */
|
|
125
|
+
onProcessExit?: (info: ToolProcessInfo) => void;
|
|
126
|
+
}
|
|
127
|
+
|
|
116
128
|
// ---------------------------------------------------------------------------
|
|
117
129
|
// ToolAdapter — 统一的 AI 工具适配器接口
|
|
118
130
|
// ---------------------------------------------------------------------------
|
|
@@ -139,6 +151,7 @@ export interface ToolAdapter {
|
|
|
139
151
|
userText: string,
|
|
140
152
|
cwd: string,
|
|
141
153
|
signal?: AbortSignal,
|
|
154
|
+
options?: ToolPromptOptions,
|
|
142
155
|
): AsyncIterable<UnifiedStreamMessage>;
|
|
143
156
|
|
|
144
157
|
/**
|
|
@@ -13,6 +13,7 @@ import { randomUUID } from "node:crypto";
|
|
|
13
13
|
|
|
14
14
|
import type {
|
|
15
15
|
ToolAdapter,
|
|
16
|
+
ToolPromptOptions,
|
|
16
17
|
UnifiedBlock,
|
|
17
18
|
UnifiedStreamMessage,
|
|
18
19
|
CreateSessionResult,
|
|
@@ -238,6 +239,7 @@ class CodexAdapter implements ToolAdapter {
|
|
|
238
239
|
userText: string,
|
|
239
240
|
cwd: string,
|
|
240
241
|
signal?: AbortSignal,
|
|
242
|
+
options?: ToolPromptOptions,
|
|
241
243
|
): AsyncIterable<UnifiedStreamMessage> {
|
|
242
244
|
let meta = await this.metaStore.get(sessionId);
|
|
243
245
|
const threadId = meta?.threadId;
|
|
@@ -250,6 +252,7 @@ class CodexAdapter implements ToolAdapter {
|
|
|
250
252
|
: [...CODEX_BASE_ARGS, "resume", threadId, "-"];
|
|
251
253
|
|
|
252
254
|
const proc = spawnCodex(args, cwd, userText);
|
|
255
|
+
if (proc.pid !== undefined) options?.onProcessStart?.({ pid: proc.pid });
|
|
253
256
|
|
|
254
257
|
// 关键:spawn 用了 shell:true,proc.pid 指向的是壳进程(cmd.exe / sh)。
|
|
255
258
|
// 真正干活的是壳的孙子 codex.exe。普通 proc.kill() 在 Windows 上只杀第一层,
|
|
@@ -278,6 +281,7 @@ class CodexAdapter implements ToolAdapter {
|
|
|
278
281
|
} finally {
|
|
279
282
|
signal?.removeEventListener("abort", onAbort);
|
|
280
283
|
await killProcessTree(proc.pid);
|
|
284
|
+
if (proc.pid !== undefined) options?.onProcessExit?.({ pid: proc.pid });
|
|
281
285
|
}
|
|
282
286
|
}
|
|
283
287
|
|
|
@@ -10,6 +10,7 @@ import { createInterface } from "node:readline";
|
|
|
10
10
|
|
|
11
11
|
import type {
|
|
12
12
|
ToolAdapter,
|
|
13
|
+
ToolPromptOptions,
|
|
13
14
|
UnifiedBlock,
|
|
14
15
|
UnifiedStreamMessage,
|
|
15
16
|
CreateSessionResult,
|
|
@@ -354,10 +355,12 @@ class CursorAdapter implements ToolAdapter {
|
|
|
354
355
|
userText: string,
|
|
355
356
|
cwd: string,
|
|
356
357
|
signal?: AbortSignal,
|
|
358
|
+
options?: ToolPromptOptions,
|
|
357
359
|
): AsyncIterable<UnifiedStreamMessage> {
|
|
358
360
|
console.log(`[Cursor debug] prompt start: sessionId=${sessionId}, cwd=${cwd}, userTextLen=${userText.length}`);
|
|
359
361
|
const proc = spawnAgent(["--resume", sessionId], cwd, userText);
|
|
360
362
|
this.activeProcs.add(proc);
|
|
363
|
+
if (proc.pid !== undefined) options?.onProcessStart?.({ pid: proc.pid });
|
|
361
364
|
|
|
362
365
|
// 见 codex-adapter.ts 同位置注释:spawn 用了 shell:true,必须杀整棵树,
|
|
363
366
|
// 否则 abort 后真正在跑的孙进程 cursor-agent 还会继续输出 & 占用资源。
|
|
@@ -384,6 +387,7 @@ class CursorAdapter implements ToolAdapter {
|
|
|
384
387
|
signal?.removeEventListener("abort", onAbort);
|
|
385
388
|
await killProcessTree(proc.pid);
|
|
386
389
|
this.activeProcs.delete(proc);
|
|
390
|
+
if (proc.pid !== undefined) options?.onProcessExit?.({ pid: proc.pid });
|
|
387
391
|
console.log(`[Cursor debug] prompt end: sessionId=${sessionId}, signalAborted=${signal?.aborted ?? false}`);
|
|
388
392
|
}
|
|
389
393
|
}
|
|
@@ -65,6 +65,12 @@ export interface ActivePrompt {
|
|
|
65
65
|
controller: AbortController;
|
|
66
66
|
stopped: boolean;
|
|
67
67
|
startTime: number;
|
|
68
|
+
/** Root PID for the CLI process currently serving this prompt, if the adapter exposes one. */
|
|
69
|
+
processPid?: number;
|
|
70
|
+
processMonitor?: ReturnType<typeof setInterval>;
|
|
71
|
+
/** Set when the watchdog detects that the CLI process disappeared before stream finalization. */
|
|
72
|
+
abnormalExit?: boolean;
|
|
73
|
+
abnormalExitNotified?: boolean;
|
|
68
74
|
}
|
|
69
75
|
|
|
70
76
|
export const activePrompts = new Map<string, ActivePrompt>();
|
|
@@ -206,6 +212,9 @@ export function consumeQueuedMessage(platform: PlatformAdapter, msg: QueuedMessa
|
|
|
206
212
|
export function resetBindingState(): void {
|
|
207
213
|
sessionChatsMap.clear();
|
|
208
214
|
lastActiveChatMap.clear();
|
|
215
|
+
for (const prompt of activePrompts.values()) {
|
|
216
|
+
if (prompt.processMonitor) clearInterval(prompt.processMonitor);
|
|
217
|
+
}
|
|
209
218
|
activePrompts.clear();
|
|
210
219
|
queuedMessages.clear();
|
|
211
220
|
displayCards.clear();
|
package/src/session.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { simplifyToolUse, simplifyToolResult } from "./simplify.ts";
|
|
|
25
25
|
import { logTrace } from "./trace.ts";
|
|
26
26
|
import type { UnifiedBlock } from "./adapters/adapter-interface.ts";
|
|
27
27
|
import type { ToolAdapter } from "./adapters/adapter-interface.ts";
|
|
28
|
+
import type { ToolProcessInfo } from "./adapters/adapter-interface.ts";
|
|
28
29
|
import { createClaudeAdapter } from "./adapters/claude-adapter.ts";
|
|
29
30
|
import { createCursorAdapter } from "./adapters/cursor-adapter.ts";
|
|
30
31
|
import { createCodexAdapter } from "./adapters/codex-adapter.ts";
|
|
@@ -37,7 +38,14 @@ function compressWechatDisplayText(text: string): string {
|
|
|
37
38
|
if (lines.length <= 10) return text;
|
|
38
39
|
return [...lines.slice(0, 5), "...", ...lines.slice(-5)].join("\n");
|
|
39
40
|
}
|
|
40
|
-
import {
|
|
41
|
+
import {
|
|
42
|
+
readStreamState,
|
|
43
|
+
writeStreamState,
|
|
44
|
+
createEmptyStreamState,
|
|
45
|
+
fixStaleStreamStates,
|
|
46
|
+
isFinalReplySentForTurn,
|
|
47
|
+
markFinalReplySent,
|
|
48
|
+
} from "./stream-state.ts";
|
|
41
49
|
import { addCardToTurn, finalizeTurnCards, markCardDone } from "./turn-cards.ts";
|
|
42
50
|
import {
|
|
43
51
|
bindChatToSession,
|
|
@@ -59,6 +67,18 @@ import {
|
|
|
59
67
|
consumeQueuePreservedChat,
|
|
60
68
|
} from "./session-chat-binding.ts";
|
|
61
69
|
|
|
70
|
+
async function sendFinalReplyTextOnce(
|
|
71
|
+
platform: PlatformAdapter,
|
|
72
|
+
chatId: string,
|
|
73
|
+
sessionId: string,
|
|
74
|
+
turnCount: number,
|
|
75
|
+
text: string,
|
|
76
|
+
): Promise<boolean> {
|
|
77
|
+
const sent = await platform.sendText(chatId, text).then((ok) => ok !== false).catch(() => false);
|
|
78
|
+
if (sent) await markFinalReplySent(sessionId, turnCount);
|
|
79
|
+
return sent;
|
|
80
|
+
}
|
|
81
|
+
|
|
62
82
|
// ---------------------------------------------------------------------------
|
|
63
83
|
// Shared state (imported by index.ts)
|
|
64
84
|
// ---------------------------------------------------------------------------
|
|
@@ -95,6 +115,110 @@ function platformForChat(chatId: string): PlatformAdapter | null {
|
|
|
95
115
|
return chatPlatformMap.get(chatId) ?? platformRef;
|
|
96
116
|
}
|
|
97
117
|
|
|
118
|
+
const DEFAULT_PROCESS_MONITOR_INTERVAL_MS = 5000;
|
|
119
|
+
let processMonitorIntervalMs = DEFAULT_PROCESS_MONITOR_INTERVAL_MS;
|
|
120
|
+
let isProcessAliveImpl = (pid: number): boolean => {
|
|
121
|
+
try {
|
|
122
|
+
process.kill(pid, 0);
|
|
123
|
+
return true;
|
|
124
|
+
} catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export function _setProcessAliveForTest(impl: (pid: number) => boolean): void {
|
|
130
|
+
isProcessAliveImpl = impl;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function _resetProcessAliveForTest(): void {
|
|
134
|
+
isProcessAliveImpl = (pid: number): boolean => {
|
|
135
|
+
try {
|
|
136
|
+
process.kill(pid, 0);
|
|
137
|
+
return true;
|
|
138
|
+
} catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function _setProcessMonitorIntervalForTest(ms: number): void {
|
|
145
|
+
processMonitorIntervalMs = ms;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function _resetProcessMonitorIntervalForTest(): void {
|
|
149
|
+
processMonitorIntervalMs = DEFAULT_PROCESS_MONITOR_INTERVAL_MS;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function clearPromptProcessMonitor(sessionId: string): void {
|
|
153
|
+
const prompt = activePrompts.get(sessionId);
|
|
154
|
+
if (!prompt?.processMonitor) return;
|
|
155
|
+
clearInterval(prompt.processMonitor);
|
|
156
|
+
prompt.processMonitor = undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function formatTerminalHeader(status: "running" | "done" | "stopped" | "error"): {
|
|
160
|
+
title: string;
|
|
161
|
+
template?: string;
|
|
162
|
+
} {
|
|
163
|
+
if (status === "stopped") return { title: "已停止", template: "red" };
|
|
164
|
+
if (status === "error") return { title: "异常结束", template: "red" };
|
|
165
|
+
return { title: "完成" };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function turnFinalStatus(status: "running" | "done" | "stopped" | "error"): "done" | "stopped" {
|
|
169
|
+
return status === "stopped" || status === "error" ? "stopped" : "done";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function startPromptProcessMonitor(sessionId: string, info: ToolProcessInfo): void {
|
|
173
|
+
const prompt = activePrompts.get(sessionId);
|
|
174
|
+
if (!prompt) return;
|
|
175
|
+
prompt.processPid = info.pid;
|
|
176
|
+
clearPromptProcessMonitor(sessionId);
|
|
177
|
+
|
|
178
|
+
const check = async () => {
|
|
179
|
+
const current = activePrompts.get(sessionId);
|
|
180
|
+
if (!current || current !== prompt) {
|
|
181
|
+
clearPromptProcessMonitor(sessionId);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (current.stopped || current.abnormalExit) return;
|
|
185
|
+
if (isProcessAliveImpl(info.pid)) return;
|
|
186
|
+
|
|
187
|
+
current.abnormalExit = true;
|
|
188
|
+
clearPromptProcessMonitor(sessionId);
|
|
189
|
+
|
|
190
|
+
const state = await readStreamState(sessionId);
|
|
191
|
+
if (state?.status === "running") {
|
|
192
|
+
await writeStreamState({
|
|
193
|
+
...state,
|
|
194
|
+
status: "error",
|
|
195
|
+
updatedAt: Date.now(),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const chatId = pickDisplayChat(sessionId) ?? getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
|
|
200
|
+
const p = chatId ? platformForChat(chatId) : null;
|
|
201
|
+
if (chatId && p && !current.abnormalExitNotified) {
|
|
202
|
+
current.abnormalExitNotified = true;
|
|
203
|
+
await p.sendText(
|
|
204
|
+
chatId,
|
|
205
|
+
`⚠️ 进程异常结束:session ${sessionId} 对应的 CLI 进程 PID ${info.pid} 已不存在,已按完成处理。若回复不完整,请重新发送上一条指令。`,
|
|
206
|
+
).catch(() => {});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// 主动关闭 readline,让 runAgentSession 的 finally 落盘 error 终态并清理 activePrompts。
|
|
210
|
+
current.controller.abort();
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const handle = setInterval(() => {
|
|
214
|
+
void check().catch((err) => {
|
|
215
|
+
console.warn(`[${ts()}] [PROCESS-MONITOR] check failed for ${sessionId}: ${(err as Error).message}`);
|
|
216
|
+
});
|
|
217
|
+
}, processMonitorIntervalMs);
|
|
218
|
+
handle.unref?.();
|
|
219
|
+
prompt.processMonitor = handle;
|
|
220
|
+
}
|
|
221
|
+
|
|
98
222
|
export function _getPlatformForChatForTest(chatId: string): PlatformAdapter | null {
|
|
99
223
|
return platformForChat(chatId);
|
|
100
224
|
}
|
|
@@ -170,6 +294,9 @@ export function resetState(): void {
|
|
|
170
294
|
processedMessages.clear();
|
|
171
295
|
lastMsgTimestamps.clear();
|
|
172
296
|
chatPlatformMap.clear();
|
|
297
|
+
for (const prompt of activePrompts.values()) {
|
|
298
|
+
if (prompt.processMonitor) clearInterval(prompt.processMonitor);
|
|
299
|
+
}
|
|
173
300
|
activePrompts.clear();
|
|
174
301
|
displayCards.clear();
|
|
175
302
|
stopUnifiedDisplayLoop();
|
|
@@ -809,27 +936,38 @@ export async function runAgentSession(
|
|
|
809
936
|
if (display && pp) {
|
|
810
937
|
// 统一 display loop 被 cardBusy 挡住或尚未 tick → 现在终结卡片
|
|
811
938
|
while (display.cardBusy) await new Promise(r => setTimeout(r, 20));
|
|
812
|
-
const nextSeq = display.sequence + 1;
|
|
813
|
-
const headerTitle = prevState.status === "stopped" ? "已停止" : "完成";
|
|
814
|
-
const headerTemplate = prevState.status === "stopped" ? "red" : undefined;
|
|
815
|
-
const cardContent = truncateContent(prevState.accumulatedContent + prevState.finalReply) || " ";
|
|
816
|
-
const doneCard = buildProgressCard(cardContent, { showStop: false, headerTitle, headerTemplate });
|
|
817
|
-
await pp.cardUpdate(display.cardId, doneCard, nextSeq).catch(() => {});
|
|
818
|
-
displayCards.delete(displayChatId);
|
|
819
|
-
|
|
820
|
-
// 持久化:标记上一轮所有卡片为终态
|
|
821
|
-
const finalStatus = prevState.status === "stopped" ? "stopped" : "done";
|
|
822
|
-
finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
|
|
823
939
|
|
|
824
|
-
|
|
825
|
-
|
|
940
|
+
// 竞态防护:等待期间统一 display loop 的 tick 可能也已读到同一个
|
|
941
|
+
// terminal state,发送了 finalReply 并删除/替换了 displayCards 条目。
|
|
942
|
+
// 通过引用比较检测——若统一 loop 已处理则只补持久化和头像,不重复发。
|
|
943
|
+
if (displayCards.get(displayChatId) !== display) {
|
|
944
|
+
const finalStatus = turnFinalStatus(prevState.status);
|
|
945
|
+
finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
|
|
946
|
+
pp.setChatAvatar(displayChatId, prevState.tool, "idle").catch(() => {});
|
|
947
|
+
} else {
|
|
948
|
+
const nextSeq = display.sequence + 1;
|
|
949
|
+
const { title: headerTitle, template: headerTemplate } = formatTerminalHeader(prevState.status);
|
|
950
|
+
const cardContent = truncateContent(prevState.accumulatedContent + prevState.finalReply) || " ";
|
|
951
|
+
const doneCard = buildProgressCard(cardContent, { showStop: false, headerTitle, headerTemplate });
|
|
952
|
+
await pp.cardUpdate(display.cardId, doneCard, nextSeq).catch(() => {});
|
|
953
|
+
// cardUpdate IO 期间统一 loop 可能也已处理此 display → 删前检查引用
|
|
954
|
+
const stillOursAfterUpdate = displayCards.get(displayChatId) === display;
|
|
955
|
+
displayCards.delete(displayChatId);
|
|
956
|
+
|
|
957
|
+
// 持久化:标记上一轮所有卡片为终态
|
|
958
|
+
const finalStatus = turnFinalStatus(prevState.status);
|
|
959
|
+
finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
|
|
960
|
+
|
|
961
|
+
if (prevState.finalReply && stillOursAfterUpdate && !isFinalReplySentForTurn(prevState)) {
|
|
962
|
+
await sendFinalReplyTextOnce(pp, displayChatId, sessionId, prevState.turnCount, prevState.finalReply);
|
|
963
|
+
}
|
|
964
|
+
pp.setChatAvatar(displayChatId, prevState.tool, "idle").catch(() => {});
|
|
826
965
|
}
|
|
827
|
-
|
|
828
|
-
} else if (pp && prevState.finalReply) {
|
|
966
|
+
} else if (pp && prevState.finalReply && !isFinalReplySentForTurn(prevState)) {
|
|
829
967
|
// 无 display 记录但上一轮有 finalReply(极快轮次),至少发送
|
|
830
|
-
const finalStatus = prevState.status
|
|
968
|
+
const finalStatus = turnFinalStatus(prevState.status);
|
|
831
969
|
finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
|
|
832
|
-
await pp
|
|
970
|
+
await sendFinalReplyTextOnce(pp, displayChatId, sessionId, prevState.turnCount, prevState.finalReply);
|
|
833
971
|
}
|
|
834
972
|
// else: displayCards 无记录且无 finalReply → 无需处理
|
|
835
973
|
}
|
|
@@ -897,7 +1035,14 @@ export async function runAgentSession(
|
|
|
897
1035
|
const toolCallMap = new Map<string, { name: string; input: unknown }>();
|
|
898
1036
|
|
|
899
1037
|
try {
|
|
900
|
-
for await (const unifiedMsg of adapter.prompt(sessionId, userTextWithCapabilities, cwd, controller.signal
|
|
1038
|
+
for await (const unifiedMsg of adapter.prompt(sessionId, userTextWithCapabilities, cwd, controller.signal, {
|
|
1039
|
+
onProcessStart: (processInfo) => {
|
|
1040
|
+
startPromptProcessMonitor(sessionId, processInfo);
|
|
1041
|
+
},
|
|
1042
|
+
onProcessExit: () => {
|
|
1043
|
+
clearPromptProcessMonitor(sessionId);
|
|
1044
|
+
},
|
|
1045
|
+
})) {
|
|
901
1046
|
for (const block of unifiedMsg.blocks) {
|
|
902
1047
|
accumulateBlockContent(block, state, toolCallMap);
|
|
903
1048
|
|
|
@@ -940,13 +1085,15 @@ export async function runAgentSession(
|
|
|
940
1085
|
// 标记 prompt 结束
|
|
941
1086
|
const prompt = activePrompts.get(sessionId);
|
|
942
1087
|
const wasStopped = prompt?.stopped ?? false;
|
|
1088
|
+
const wasAbnormalExit = prompt?.abnormalExit ?? false;
|
|
1089
|
+
clearPromptProcessMonitor(sessionId);
|
|
943
1090
|
activePrompts.delete(sessionId);
|
|
944
1091
|
|
|
945
1092
|
// 先写最终状态(done/stopped),确保 display loop 在下一轮消费前
|
|
946
1093
|
// 读到新状态并终结旧卡片。否则 setImmediate 在 CHECK 阶段先于
|
|
947
1094
|
// writeFile I/O(POLL 阶段)执行,display loop 会误以为旧轮仍在
|
|
948
1095
|
// 运行中并更新旧卡片,而不是新建卡片。
|
|
949
|
-
const finalStatus = wasStopped ? "stopped" : "done";
|
|
1096
|
+
const finalStatus = wasAbnormalExit ? "error" : wasStopped ? "stopped" : "done";
|
|
950
1097
|
const finalReply = pickFinalReply(state).trim();
|
|
951
1098
|
await writeStreamState({
|
|
952
1099
|
sessionId,
|
|
@@ -1008,6 +1155,23 @@ export async function runAgentSession(
|
|
|
1008
1155
|
if (active1) platform.setChatAvatar(active1, tool, "idle").catch(() => {});
|
|
1009
1156
|
console.log(`[${ts()}] Session ${sessionId} stopped (content chunks: ${state.chunkCount})`);
|
|
1010
1157
|
if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "stopped", chunks: state.chunkCount });
|
|
1158
|
+
} else if (wasAbnormalExit) {
|
|
1159
|
+
for (const cid of getChatsForSession(sessionId)) {
|
|
1160
|
+
const finfo = sessionInfoMap.get(cid);
|
|
1161
|
+
await recordSessionRegistry({
|
|
1162
|
+
chatId: cid,
|
|
1163
|
+
sessionId,
|
|
1164
|
+
tool,
|
|
1165
|
+
turnCount: finfo?.turnCount ?? nextTurnCount,
|
|
1166
|
+
lastContextTokens: finfo?.lastContextTokens ?? nextContextTokens,
|
|
1167
|
+
startTime: finfo?.startTime ?? now,
|
|
1168
|
+
running: false,
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
const activeErr = getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
|
|
1172
|
+
if (activeErr) platform.setChatAvatar(activeErr, tool, "idle").catch(() => {});
|
|
1173
|
+
console.log(`[${ts()}] Session ${sessionId} process exited unexpectedly (content chunks: ${state.chunkCount})`);
|
|
1174
|
+
if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "process_missing", chunks: state.chunkCount });
|
|
1011
1175
|
} else {
|
|
1012
1176
|
for (const cid of getChatsForSession(sessionId)) {
|
|
1013
1177
|
const finfo = sessionInfoMap.get(cid);
|
|
@@ -1092,24 +1256,45 @@ export function startUnifiedDisplayLoop(): void {
|
|
|
1092
1256
|
replyDelta = state.finalReply;
|
|
1093
1257
|
}
|
|
1094
1258
|
const remaining = (accDelta + replyDelta).trim();
|
|
1259
|
+
|
|
1260
|
+
// 若 session 仍在 activePrompts 中,说明 runAgentSession 的 finally
|
|
1261
|
+
// 还没执行,当前 stream state 可能是 stopSession fire-and-forget
|
|
1262
|
+
// 写入的,finalReply 滞后于内存态。跳过发送,等 finally 落盘后
|
|
1263
|
+
// 下一次 tick 再处理,避免发送过期内容或与后续发送重复。
|
|
1264
|
+
if (activePrompts.has(sessionId)) continue;
|
|
1265
|
+
|
|
1095
1266
|
const tail = "━━━ 回答结束 ━━━";
|
|
1096
1267
|
const finalMsg = remaining ? remaining + "\n" + tail : tail;
|
|
1097
|
-
|
|
1268
|
+
if (!isFinalReplySentForTurn(state)) {
|
|
1269
|
+
await sendFinalReplyTextOnce(p, chatId, sessionId, state.turnCount, finalMsg);
|
|
1270
|
+
}
|
|
1098
1271
|
displayCards.delete(chatId);
|
|
1099
1272
|
} else {
|
|
1100
1273
|
// 发送最终结果(卡片平台)
|
|
1101
1274
|
while (display.cardBusy) await new Promise(r => setTimeout(r, 20));
|
|
1102
1275
|
const nextSeq = display.sequence + 1;
|
|
1103
|
-
const headerTitle = state.status
|
|
1104
|
-
const headerTemplate = state.status === "stopped" ? "red" : undefined;
|
|
1276
|
+
const { title: headerTitle, template: headerTemplate } = formatTerminalHeader(state.status);
|
|
1105
1277
|
const cardContent = truncateContent(state.accumulatedContent + state.finalReply) || " ";
|
|
1106
1278
|
const doneCard = buildProgressCard(cardContent, { showStop: false, headerTitle, headerTemplate });
|
|
1107
1279
|
await p.cardUpdate(display.cardId, doneCard, nextSeq).catch(() => {});
|
|
1108
|
-
|
|
1280
|
+
|
|
1281
|
+
// 若 session 仍在 activePrompts 中,说明 runAgentSession 的 finally
|
|
1282
|
+
// 还没执行,当前 stream state 可能是 stopSession fire-and-forget
|
|
1283
|
+
// 写入的,finalReply 滞后于内存态。卡片已更新为终态外观,但不发送
|
|
1284
|
+
// 文本、不删除 display 条目,留给 finally 落盘后的下一次 tick 处理。
|
|
1285
|
+
if (activePrompts.has(sessionId)) {
|
|
1286
|
+
display.lastSentAccLen = state.accumulatedContent.length;
|
|
1287
|
+
display.lastSentFinalReply = state.finalReply;
|
|
1288
|
+
continue;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
const finalSt = turnFinalStatus(state.status);
|
|
1109
1292
|
finalizeTurnCards(sessionId, state.turnCount, finalSt).catch(() => {});
|
|
1110
1293
|
displayCards.delete(chatId);
|
|
1111
1294
|
if (state.finalReply) {
|
|
1112
|
-
|
|
1295
|
+
if (!isFinalReplySentForTurn(state)) {
|
|
1296
|
+
await sendFinalReplyTextOnce(p, chatId, sessionId, state.turnCount, state.finalReply);
|
|
1297
|
+
}
|
|
1113
1298
|
} else if (state.accumulatedContent.trim()) {
|
|
1114
1299
|
const short = truncateContent(state.accumulatedContent, 30, 4000);
|
|
1115
1300
|
await p.sendText(chatId, `[生成过程]\n${short}`).catch(() => {});
|
|
@@ -1165,7 +1350,7 @@ export function startUnifiedDisplayLoop(): void {
|
|
|
1165
1350
|
try {
|
|
1166
1351
|
const oldSeqBase = display.sequence;
|
|
1167
1352
|
const oldContent = state.accumulatedContent + state.finalReply;
|
|
1168
|
-
const oldCard = buildProgressCard(truncateContent(oldContent) || " ", { showStop: false, headerTitle: "
|
|
1353
|
+
const oldCard = buildProgressCard(truncateContent(oldContent) || " ", { showStop: false, headerTitle: "生成中(上轮)" });
|
|
1169
1354
|
await p.cardUpdate(display.cardId, oldCard, oldSeqBase + 1).catch(() => {});
|
|
1170
1355
|
markCardDone(sessionId, display.turnCount, display.cardId).catch(() => {});
|
|
1171
1356
|
const newCardId = await p.cardCreate(buildProgressCard(
|
|
@@ -1290,6 +1475,7 @@ export function stopSession(sessionId: string): boolean {
|
|
|
1290
1475
|
const prompt = activePrompts.get(sessionId);
|
|
1291
1476
|
if (!prompt) return false;
|
|
1292
1477
|
prompt.stopped = true;
|
|
1478
|
+
clearPromptProcessMonitor(sessionId);
|
|
1293
1479
|
cancelQueuedMessage(sessionId);
|
|
1294
1480
|
prompt.controller.abort();
|
|
1295
1481
|
console.log(`[${ts()}] [STOP] Session ${sessionId} aborted`);
|
|
@@ -1381,7 +1567,8 @@ export async function getSessionStatus(chatId: string): Promise<SessionStatus |
|
|
|
1381
1567
|
const info = sessionInfoMap.get(chatId);
|
|
1382
1568
|
if (!info) return null;
|
|
1383
1569
|
|
|
1384
|
-
const
|
|
1570
|
+
const activePrompt = activePrompts.get(info.sessionId);
|
|
1571
|
+
const isActive = !!activePrompt && !activePrompt.stopped && !activePrompt.abnormalExit;
|
|
1385
1572
|
const { model, effort } = await resolveModelEffort(info.tool, info.sessionId);
|
|
1386
1573
|
|
|
1387
1574
|
const registry = await loadSessionRegistry();
|
|
@@ -1456,7 +1643,9 @@ export async function getAllSessionsStatus(): Promise<SessionsListEntry[]> {
|
|
|
1456
1643
|
chatId: info.chatId,
|
|
1457
1644
|
sessionId: info.sessionId,
|
|
1458
1645
|
chatName: info.chatName || "",
|
|
1459
|
-
active: activePrompts.
|
|
1646
|
+
active: !!activePrompts.get(info.sessionId) &&
|
|
1647
|
+
!activePrompts.get(info.sessionId)?.stopped &&
|
|
1648
|
+
!activePrompts.get(info.sessionId)?.abnormalExit,
|
|
1460
1649
|
turnCount: info.turnCount,
|
|
1461
1650
|
startTime: info.startTime,
|
|
1462
1651
|
model,
|
package/src/stream-state.ts
CHANGED
|
@@ -9,14 +9,17 @@ import { USER_DATA_DIR, ts } from "./config.ts";
|
|
|
9
9
|
|
|
10
10
|
export const STREAMS_DIR = join(USER_DATA_DIR, "state", "streams");
|
|
11
11
|
|
|
12
|
-
export interface StreamState {
|
|
13
|
-
sessionId: string;
|
|
14
|
-
status: "running" | "done" | "stopped" | "error";
|
|
15
|
-
accumulatedContent: string;
|
|
16
|
-
finalReply: string;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
12
|
+
export interface StreamState {
|
|
13
|
+
sessionId: string;
|
|
14
|
+
status: "running" | "done" | "stopped" | "error";
|
|
15
|
+
accumulatedContent: string;
|
|
16
|
+
finalReply: string;
|
|
17
|
+
/** The turn whose terminal text reply has already been delivered to IM. */
|
|
18
|
+
finalReplySentTurn?: number;
|
|
19
|
+
finalReplySentAt?: number;
|
|
20
|
+
chunkCount: number;
|
|
21
|
+
turnCount: number;
|
|
22
|
+
contextTokens: number;
|
|
20
23
|
updatedAt: number;
|
|
21
24
|
cwd: string;
|
|
22
25
|
tool: string;
|
|
@@ -71,7 +74,7 @@ export function _resetRenameForTest(): void {
|
|
|
71
74
|
* 真文件,这一帧可能被 reader 读到半截,但 readStreamState 的 try/catch 兜底
|
|
72
75
|
* 会让 loop 跳过这一帧,2 秒后下次 write 大概率成功——比写盘失败丢数据好。
|
|
73
76
|
*/
|
|
74
|
-
export async function writeStreamState(state: StreamState): Promise<void> {
|
|
77
|
+
export async function writeStreamState(state: StreamState): Promise<void> {
|
|
75
78
|
const filePath = getStreamStatePath(state.sessionId);
|
|
76
79
|
const payload = JSON.stringify(state, null, 2);
|
|
77
80
|
try {
|
|
@@ -95,10 +98,26 @@ export async function writeStreamState(state: StreamState): Promise<void> {
|
|
|
95
98
|
} catch (err) {
|
|
96
99
|
console.error(`[${ts()}] Failed to write stream-state for ${state.sessionId}: ${(err as Error).message}`);
|
|
97
100
|
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export function
|
|
101
|
-
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function isFinalReplySentForTurn(state: Pick<StreamState, "turnCount" | "finalReplySentTurn">): boolean {
|
|
104
|
+
return state.finalReplySentTurn === state.turnCount;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function markFinalReplySent(sessionId: string, turnCount: number, sentAt = Date.now()): Promise<void> {
|
|
108
|
+
const state = await readStreamState(sessionId);
|
|
109
|
+
if (!state) return;
|
|
110
|
+
if (state.turnCount !== turnCount) return;
|
|
111
|
+
if (state.status === "running") return;
|
|
112
|
+
|
|
113
|
+
state.finalReplySentTurn = turnCount;
|
|
114
|
+
state.finalReplySentAt = sentAt;
|
|
115
|
+
state.updatedAt = Date.now();
|
|
116
|
+
await writeStreamState(state);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function createEmptyStreamState(sessionId: string, cwd: string, tool: string, turnCount: number): StreamState {
|
|
120
|
+
return {
|
|
102
121
|
sessionId,
|
|
103
122
|
status: "running",
|
|
104
123
|
accumulatedContent: "",
|
|
@@ -138,4 +157,4 @@ export async function fixStaleStreamStates(): Promise<void> {
|
|
|
138
157
|
} catch (err) {
|
|
139
158
|
console.error(`[${ts()}] [STREAM-STATE] fixStaleStreamStates failed: ${(err as Error).message}`);
|
|
140
159
|
}
|
|
141
|
-
}
|
|
160
|
+
}
|