



























@@ -0,0 +1,381 @@
1+import fs from "node:fs";
2+import os from "node:os";
3+import path from "node:path";
4+import { loadSessionStore } from "openclaw/plugin-sdk/session-store-runtime";
5+import { afterEach, beforeEach, describe, expect, it } from "vitest";
6+import type { OpenClawConfig } from "../runtime-api.js";
7+import { isFeishuSessionStoreKey, runFeishuDoctorSequence } from "./doctor.js";
8+9+type EnvSnapshot = {
10+HOME?: string;
11+OPENCLAW_HOME?: string;
12+OPENCLAW_STATE_DIR?: string;
13+};
14+15+function captureEnv(): EnvSnapshot {
16+return {
17+HOME: process.env.HOME,
18+OPENCLAW_HOME: process.env.OPENCLAW_HOME,
19+OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR,
20+};
21+}
22+23+function restoreEnv(snapshot: EnvSnapshot) {
24+for (const key of Object.keys(snapshot) as Array<keyof EnvSnapshot>) {
25+const value = snapshot[key];
26+if (value === undefined) {
27+delete process.env[key];
28+} else {
29+process.env[key] = value;
30+}
31+}
32+}
33+34+function feishuConfig(): OpenClawConfig {
35+return {
36+channels: {
37+feishu: {
38+appId: "cli_xxx",
39+appSecret: "secret_xxx",
40+},
41+},
42+} as OpenClawConfig;
43+}
44+45+function stateDir(): string {
46+const dir = process.env.OPENCLAW_STATE_DIR;
47+if (!dir) {
48+throw new Error("OPENCLAW_STATE_DIR is not set");
49+}
50+return dir;
51+}
52+53+function sessionsDir(agentId = "main"): string {
54+return path.join(stateDir(), "agents", agentId, "sessions");
55+}
56+57+function storePath(agentId = "main"): string {
58+return path.join(sessionsDir(agentId), "sessions.json");
59+}
60+61+function writeStore(entries: Record<string, unknown>, agentId = "main"): string {
62+const target = storePath(agentId);
63+fs.mkdirSync(path.dirname(target), { recursive: true });
64+fs.writeFileSync(target, JSON.stringify(entries, null, 2));
65+return target;
66+}
67+68+function writeTranscript(sessionId: string, lines: unknown[], agentId = "main"): string {
69+const target = path.join(sessionsDir(agentId), `${sessionId}.jsonl`);
70+fs.mkdirSync(path.dirname(target), { recursive: true });
71+fs.writeFileSync(target, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`);
72+return target;
73+}
74+75+function sessionHeader(sessionId: string) {
76+return {
77+type: "session",
78+id: sessionId,
79+version: 7,
80+timestamp: new Date(0).toISOString(),
81+cwd: "/tmp",
82+};
83+}
84+85+function userMessage(content: string) {
86+return {
87+type: "message",
88+id: `msg-${content || "blank"}-${Math.random().toString(36).slice(2)}`,
89+parentId: null,
90+timestamp: new Date(0).toISOString(),
91+message: { role: "user", content },
92+};
93+}
94+95+function listBackupDirs(): string[] {
96+const backupsDir = path.join(stateDir(), "backups");
97+return fs.existsSync(backupsDir)
98+ ? fs.readdirSync(backupsDir).filter((name) => name.startsWith("feishu-state-repair-"))
99+ : [];
100+}
101+102+describe("Feishu doctor state repair", () => {
103+let envSnapshot: EnvSnapshot;
104+let tempHome = "";
105+106+beforeEach(() => {
107+envSnapshot = captureEnv();
108+tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-feishu-doctor-"));
109+process.env.HOME = tempHome;
110+process.env.OPENCLAW_HOME = tempHome;
111+process.env.OPENCLAW_STATE_DIR = path.join(tempHome, ".openclaw");
112+fs.mkdirSync(process.env.OPENCLAW_STATE_DIR, { recursive: true, mode: 0o700 });
113+});
114+115+afterEach(() => {
116+restoreEnv(envSnapshot);
117+fs.rmSync(tempHome, { recursive: true, force: true });
118+});
119+120+it("matches only Feishu channel session keys", () => {
121+expect(isFeishuSessionStoreKey("agent:main:feishu:direct:ou_user")).toBe(true);
122+expect(isFeishuSessionStoreKey("feishu:direct:ou_user")).toBe(true);
123+expect(isFeishuSessionStoreKey("agent:codex:acp:binding:feishu:default:abc123")).toBe(false);
124+expect(isFeishuSessionStoreKey("agent:main:discord:direct:user")).toBe(false);
125+});
126+127+it("stays quiet for healthy Feishu state and transcripts", async () => {
128+const feishuDedupDir = path.join(stateDir(), "feishu", "dedup");
129+fs.mkdirSync(feishuDedupDir, { recursive: true });
130+fs.writeFileSync(path.join(feishuDedupDir, "default.json"), JSON.stringify({ msg1: 1 }));
131+132+writeTranscript("sess-ok", [sessionHeader("sess-ok"), userMessage("hello")]);
133+writeStore({
134+"agent:main:feishu:direct:ou_user": {
135+sessionId: "sess-ok",
136+sessionFile: "sess-ok.jsonl",
137+updatedAt: Date.now(),
138+},
139+});
140+141+const result = await runFeishuDoctorSequence({
142+cfg: feishuConfig(),
143+env: process.env,
144+shouldRepair: false,
145+});
146+147+expect(result).toEqual({ changeNotes: [], warningNotes: [] });
148+});
149+150+it("keeps custom-store sessions with canonical absolute transcripts", async () => {
151+const transcriptPath = writeTranscript("sess-abs", [
152+sessionHeader("sess-abs"),
153+userMessage("hello"),
154+]);
155+const customStorePath = path.join(stateDir(), "custom-sessions", "sessions.json");
156+fs.mkdirSync(path.dirname(customStorePath), { recursive: true });
157+fs.writeFileSync(
158+customStorePath,
159+JSON.stringify({
160+"agent:main:feishu:direct:ou_user": {
161+sessionId: "sess-abs",
162+sessionFile: transcriptPath,
163+updatedAt: Date.now(),
164+},
165+}),
166+);
167+168+const result = await runFeishuDoctorSequence({
169+cfg: {
170+ ...feishuConfig(),
171+session: { store: customStorePath },
172+} as OpenClawConfig,
173+env: process.env,
174+shouldRepair: false,
175+});
176+177+expect(result).toEqual({ changeNotes: [], warningNotes: [] });
178+});
179+180+it("keeps Feishu sessions with separated blank user messages", async () => {
181+writeTranscript("sess-separated-blanks", [
182+sessionHeader("sess-separated-blanks"),
183+userMessage(""),
184+userMessage("hello"),
185+userMessage(""),
186+userMessage("world"),
187+userMessage(""),
188+]);
189+writeStore({
190+"agent:main:feishu:direct:ou_user": {
191+sessionId: "sess-separated-blanks",
192+sessionFile: "sess-separated-blanks.jsonl",
193+updatedAt: Date.now(),
194+},
195+});
196+197+const result = await runFeishuDoctorSequence({
198+cfg: feishuConfig(),
199+env: process.env,
200+shouldRepair: false,
201+});
202+203+expect(result).toEqual({ changeNotes: [], warningNotes: [] });
204+});
205+206+it("warns before repair when Feishu local state is corrupt", async () => {
207+const feishuDedupDir = path.join(stateDir(), "feishu", "dedup");
208+fs.mkdirSync(feishuDedupDir, { recursive: true });
209+fs.writeFileSync(path.join(feishuDedupDir, "default.json"), "{");
210+211+const result = await runFeishuDoctorSequence({
212+cfg: feishuConfig(),
213+env: process.env,
214+shouldRepair: false,
215+});
216+217+expect(result.changeNotes).toEqual([]);
218+expect(result.warningNotes.join("\n")).toContain("Feishu local channel state may need repair");
219+expect(result.warningNotes.join("\n")).toContain("preserving Feishu App ID/secret config");
220+expect(result.warningNotes.join("\n")).toContain("openclaw doctor --fix");
221+});
222+223+it("rebuilds corrupt Feishu state without deleting healthy Feishu sessions", async () => {
224+const feishuDedupDir = path.join(stateDir(), "feishu", "dedup");
225+fs.mkdirSync(feishuDedupDir, { recursive: true });
226+fs.writeFileSync(path.join(feishuDedupDir, "default.json"), "{");
227+228+const transcriptPath = writeTranscript("sess-ok", [
229+sessionHeader("sess-ok"),
230+userMessage("hello"),
231+]);
232+const targetStorePath = writeStore({
233+"agent:main:feishu:direct:ou_user": {
234+sessionId: "sess-ok",
235+sessionFile: "sess-ok.jsonl",
236+updatedAt: Date.now(),
237+},
238+});
239+240+const result = await runFeishuDoctorSequence({
241+cfg: feishuConfig(),
242+env: process.env,
243+shouldRepair: true,
244+});
245+246+expect(result.warningNotes).toEqual([]);
247+expect(result.changeNotes.join("\n")).toContain("Rebuilt Feishu runtime state: yes");
248+expect(result.changeNotes.join("\n")).toContain("Removed 0 Feishu-scoped session entries");
249+250+const store = loadSessionStore(targetStorePath, { skipCache: true });
251+expect(store["agent:main:feishu:direct:ou_user"]).toBeDefined();
252+expect(fs.existsSync(transcriptPath)).toBe(true);
253+254+expect(fs.existsSync(path.join(stateDir(), "feishu"))).toBe(true);
255+expect(fs.existsSync(path.join(stateDir(), "feishu", "dedup", "default.json"))).toBe(false);
256+257+const backups = listBackupDirs();
258+expect(backups).toHaveLength(1);
259+const backupDir = path.join(stateDir(), "backups", backups[0] ?? "");
260+expect(fs.existsSync(path.join(backupDir, "feishu", "dedup", "default.json"))).toBe(true);
261+expect(fs.existsSync(path.join(backupDir, "session-stores", "main", "sessions.json"))).toBe(
262+false,
263+);
264+});
265+266+it("archives only unhealthy Feishu direct sessions while preserving state, config, and other sessions", async () => {
267+const feishuDedupDir = path.join(stateDir(), "feishu", "dedup");
268+fs.mkdirSync(feishuDedupDir, { recursive: true });
269+fs.writeFileSync(path.join(feishuDedupDir, "default.json"), JSON.stringify({ msg1: 1 }));
270+271+const transcriptPath = writeTranscript("sess-bad", [
272+sessionHeader("sess-bad"),
273+userMessage(""),
274+userMessage(""),
275+userMessage(""),
276+]);
277+const trajectoryPath = path.join(sessionsDir(), "sess-bad.trajectory.jsonl");
278+const trajectoryIndexPath = path.join(sessionsDir(), "sess-bad.trajectory-path.json");
279+fs.writeFileSync(trajectoryPath, "{}\n");
280+fs.writeFileSync(trajectoryIndexPath, "{}\n");
281+const acpTranscriptPath = writeTranscript("sess-acp-bad", [
282+sessionHeader("sess-acp-bad"),
283+userMessage(""),
284+userMessage(""),
285+userMessage(""),
286+]);
287+288+const targetStorePath = writeStore({
289+"agent:main:feishu:direct:ou_user": {
290+sessionId: "sess-bad",
291+sessionFile: "sess-bad.jsonl",
292+updatedAt: Date.now(),
293+},
294+"agent:codex:acp:binding:feishu:default:abc123": {
295+sessionId: "sess-acp-bad",
296+sessionFile: "sess-acp-bad.jsonl",
297+updatedAt: Date.now(),
298+route: { channel: "feishu", target: { to: "ou_user", chatType: "direct" } },
299+},
300+"agent:main:discord:direct:user": {
301+sessionId: "sess-discord",
302+updatedAt: Date.now(),
303+},
304+});
305+306+const result = await runFeishuDoctorSequence({
307+cfg: feishuConfig(),
308+env: process.env,
309+shouldRepair: true,
310+});
311+312+expect(result.warningNotes).toEqual([]);
313+expect(result.changeNotes.join("\n")).toContain("Feishu local state repaired");
314+expect(result.changeNotes.join("\n")).toContain("Rebuilt Feishu runtime state: not needed");
315+expect(result.changeNotes.join("\n")).toContain("Preserved Feishu App ID/secret config");
316+317+expect(fs.existsSync(path.join(stateDir(), "feishu"))).toBe(true);
318+expect(fs.existsSync(path.join(stateDir(), "feishu", "dedup", "default.json"))).toBe(true);
319+320+const backups = listBackupDirs();
321+expect(backups).toHaveLength(1);
322+const backupDir = path.join(stateDir(), "backups", backups[0] ?? "");
323+expect(fs.existsSync(path.join(backupDir, "feishu", "dedup", "default.json"))).toBe(false);
324+expect(fs.existsSync(path.join(backupDir, "session-stores", "main", "sessions.json"))).toBe(
325+true,
326+);
327+328+const store = loadSessionStore(targetStorePath, { skipCache: true });
329+expect(store["agent:main:feishu:direct:ou_user"]).toBeUndefined();
330+expect(store["agent:codex:acp:binding:feishu:default:abc123"]).toBeDefined();
331+expect(store["agent:main:discord:direct:user"]).toBeDefined();
332+333+expect(fs.existsSync(transcriptPath)).toBe(false);
334+expect(fs.existsSync(acpTranscriptPath)).toBe(true);
335+expect(fs.existsSync(trajectoryPath)).toBe(false);
336+expect(fs.existsSync(trajectoryIndexPath)).toBe(false);
337+const archivedNames = fs.readdirSync(sessionsDir());
338+expect(archivedNames.some((name) => name.startsWith("sess-bad.jsonl.deleted."))).toBe(true);
339+expect(
340+archivedNames.some((name) => name.startsWith("sess-bad.trajectory.jsonl.deleted.")),
341+).toBe(true);
342+expect(
343+archivedNames.some((name) => name.startsWith("sess-bad.trajectory-path.json.deleted.")),
344+).toBe(true);
345+});
346+347+it("archives unhealthy default-scope sessions when metadata identifies Feishu", async () => {
348+const transcriptPath = writeTranscript("sess-default-feishu-bad", [
349+sessionHeader("sess-default-feishu-bad"),
350+userMessage(""),
351+userMessage(""),
352+userMessage(""),
353+]);
354+const targetStorePath = writeStore({
355+"agent:main:main": {
356+sessionId: "sess-default-feishu-bad",
357+sessionFile: "sess-default-feishu-bad.jsonl",
358+updatedAt: Date.now(),
359+origin: { provider: "feishu", from: "feishu:ou_user" },
360+route: { channel: "feishu", target: { to: "ou_user", chatType: "direct" } },
361+},
362+"agent:main:main-non-feishu": {
363+sessionId: "sess-other",
364+updatedAt: Date.now(),
365+origin: { provider: "discord" },
366+},
367+});
368+369+const result = await runFeishuDoctorSequence({
370+cfg: feishuConfig(),
371+env: process.env,
372+shouldRepair: true,
373+});
374+375+expect(result.warningNotes).toEqual([]);
376+const store = loadSessionStore(targetStorePath, { skipCache: true });
377+expect(store["agent:main:main"]).toBeUndefined();
378+expect(store["agent:main:main-non-feishu"]).toBeDefined();
379+expect(fs.existsSync(transcriptPath)).toBe(false);
380+});
381+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。