




















1+import fs from "node:fs/promises";
2+import os from "node:os";
3+import path from "node:path";
4+import { afterEach, describe, expect, it } from "vitest";
5+import { getSessionEntry, updateSessionStore, upsertSessionEntry } from "../../config/sessions.js";
6+import type { OpenClawConfig } from "../../config/types.openclaw.js";
7+import { buildBuiltinChatCommands } from "../commands-registry.shared.js";
8+import { takeCommandSessionMetadataChanges } from "./command-session-metadata.js";
9+import { loadCommandHandlers } from "./commands-handlers.runtime.js";
10+import { handleNameCommand, parseNameCommand } from "./commands-name.js";
11+import type { HandleCommandsParams } from "./commands-types.js";
12+13+const sessionKey = "agent:main:web:main";
14+let tempRoots: string[] = [];
15+16+afterEach(async () => {
17+await Promise.all(tempRoots.map((root) => fs.rm(root, { recursive: true, force: true })));
18+tempRoots = [];
19+});
20+21+async function createStorePath(): Promise<string> {
22+const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-name-command-"));
23+tempRoots.push(root);
24+return path.join(root, "sessions.json");
25+}
26+27+function buildNameParams(
28+commandBodyNormalized: string,
29+storePath: string,
30+overrides: { isAuthorizedSender?: boolean; commandSource?: string; sessionKey?: string } = {},
31+): HandleCommandsParams {
32+const activeSessionKey = overrides.sessionKey ?? sessionKey;
33+return {
34+cfg: {} as OpenClawConfig,
35+ctx: {
36+Provider: "web",
37+Surface: "web",
38+CommandSource: overrides.commandSource ?? "text",
39+},
40+command: {
41+ commandBodyNormalized,
42+isAuthorizedSender: overrides.isAuthorizedSender ?? true,
43+senderIsOwner: true,
44+senderId: "tester",
45+channel: "web",
46+channelId: "web",
47+surface: "web",
48+ownerList: [],
49+rawBodyNormalized: commandBodyNormalized,
50+},
51+directives: {},
52+sessionStore: {},
53+elevated: { enabled: true, allowed: true, failures: [] },
54+sessionKey: activeSessionKey,
55+ storePath,
56+workspaceDir: "/tmp",
57+provider: "openai",
58+model: "gpt-5.5",
59+contextTokens: 0,
60+defaultGroupActivation: () => "mention",
61+resolvedVerboseLevel: "off",
62+resolvedReasoningLevel: "off",
63+resolveDefaultThinkingLevel: async () => undefined,
64+isGroup: false,
65+} as unknown as HandleCommandsParams;
66+}
67+68+describe("name command", () => {
69+it("parses the captured title and ignores other commands", () => {
70+expect(parseNameCommand("/name Quarterly planning")).toEqual({
71+title: "Quarterly planning",
72+});
73+expect(parseNameCommand("/name")).toEqual({ title: "" });
74+expect(parseNameCommand("/goal status")).toBeNull();
75+});
76+77+it("registers and loads the command on text and native surfaces", () => {
78+const command = buildBuiltinChatCommands().find((entry) => entry.key === "name");
79+80+expect(command).toMatchObject({
81+nativeName: "name",
82+textAliases: ["/name"],
83+acceptsArgs: true,
84+scope: "both",
85+category: "session",
86+});
87+expect(command?.args).toEqual([
88+expect.objectContaining({
89+name: "title",
90+captureRemaining: true,
91+}),
92+]);
93+expect(loadCommandHandlers()).toContain(handleNameCommand);
94+});
95+96+it("renames the current session and persists the label", async () => {
97+const storePath = await createStorePath();
98+await upsertSessionEntry({
99+ storePath,
100+ sessionKey,
101+entry: { sessionId: "sess-main", updatedAt: 1, totalTokens: 0, totalTokensFresh: true },
102+});
103+104+const params = buildNameParams("/name Billing rework", storePath);
105+const result = await handleNameCommand(params, true);
106+107+expect(result?.shouldContinue).toBe(false);
108+expect(result?.reply?.text).toContain("Billing rework");
109+expect(getSessionEntry({ storePath, sessionKey })?.label).toBe("Billing rework");
110+expect(params.sessionEntry?.label).toBe("Billing rework");
111+expect(takeCommandSessionMetadataChanges(params.ctx)).toEqual([
112+{ sessionKey, reason: "command-metadata" },
113+]);
114+});
115+116+it("suggests a name without mutating when no argument is given", async () => {
117+const storePath = await createStorePath();
118+await upsertSessionEntry({
119+ storePath,
120+ sessionKey,
121+entry: { sessionId: "sess-main", updatedAt: 1, totalTokens: 0, totalTokensFresh: true },
122+});
123+124+const params = buildNameParams("/name", storePath);
125+params.sessionEntry = getSessionEntry({ storePath, sessionKey });
126+const result = await handleNameCommand(params, true);
127+128+expect(result?.shouldContinue).toBe(false);
129+expect(result?.reply?.text).toContain("Use /name <title>");
130+expect(getSessionEntry({ storePath, sessionKey })?.label).toBeUndefined();
131+expect(takeCommandSessionMetadataChanges(params.ctx)).toBeUndefined();
132+});
133+134+it("rejects a label already used by another session", async () => {
135+const storePath = await createStorePath();
136+const now = Date.now();
137+await updateSessionStore(storePath, (store) => {
138+store[sessionKey] = {
139+sessionId: "sess-main",
140+updatedAt: now,
141+totalTokens: 0,
142+totalTokensFresh: true,
143+};
144+store["agent:main:web:other"] = {
145+sessionId: "sess-other",
146+updatedAt: now,
147+totalTokens: 0,
148+totalTokensFresh: true,
149+label: "Taken",
150+};
151+return null;
152+});
153+154+const params = buildNameParams("/name Taken", storePath);
155+const result = await handleNameCommand(params, true);
156+157+expect(result?.reply?.text).toContain("label already in use");
158+expect(getSessionEntry({ storePath, sessionKey })?.label).toBeUndefined();
159+expect(takeCommandSessionMetadataChanges(params.ctx)).toBeUndefined();
160+});
161+162+it("reads the persisted name when params.sessionEntry is absent", async () => {
163+const storePath = await createStorePath();
164+await upsertSessionEntry({
165+ storePath,
166+ sessionKey,
167+entry: {
168+sessionId: "sess-main",
169+updatedAt: 1,
170+totalTokens: 0,
171+totalTokensFresh: true,
172+label: "Billing rework",
173+},
174+});
175+176+const params = buildNameParams("/name", storePath);
177+const result = await handleNameCommand(params, true);
178+179+expect(result?.reply?.text).toContain("Current session name: Billing rework");
180+});
181+182+it("seeds a brand-new native session entry that is not yet persisted", async () => {
183+const storePath = await createStorePath();
184+const params = buildNameParams("/name First native", storePath, { commandSource: "slash" });
185+// Native slash sessions hand the handler an in-memory entry that the fast
186+// path has not written to the store yet. The rename must seed it instead of
187+// reporting "no active session to name".
188+params.sessionEntry = {
189+sessionId: "sess-native",
190+updatedAt: 1,
191+totalTokens: 0,
192+totalTokensFresh: true,
193+};
194+195+const result = await handleNameCommand(params, true);
196+197+expect(result?.shouldContinue).toBe(false);
198+expect(result?.reply?.text).toContain("First native");
199+expect(getSessionEntry({ storePath, sessionKey })?.label).toBe("First native");
200+expect(params.sessionEntry?.label).toBe("First native");
201+expect(takeCommandSessionMetadataChanges(params.ctx)).toEqual([
202+{ sessionKey, reason: "command-metadata" },
203+]);
204+});
205+206+it("persists the rename under the canonical key when stored under a legacy alias", async () => {
207+const storePath = await createStorePath();
208+const legacyKey = "agent:main:web:Main";
209+const now = Date.now();
210+await updateSessionStore(storePath, (store) => {
211+store[legacyKey] = {
212+sessionId: "sess-main",
213+updatedAt: now,
214+totalTokens: 0,
215+totalTokensFresh: true,
216+};
217+return null;
218+});
219+220+const params = buildNameParams("/name Canonical", storePath);
221+const result = await handleNameCommand(params, true);
222+223+expect(result?.reply?.text).toContain("Canonical");
224+expect(getSessionEntry({ storePath, sessionKey })?.label).toBe("Canonical");
225+226+const keys = await updateSessionStore(storePath, (store) => Object.keys(store), {
227+skipSaveWhenResult: () => true,
228+});
229+expect(keys).toContain(sessionKey);
230+expect(keys).not.toContain(legacyKey);
231+expect(takeCommandSessionMetadataChanges(params.ctx)).toEqual([
232+{ sessionKey, reason: "command-metadata" },
233+]);
234+});
235+236+it("does not rename for an unauthorized sender", async () => {
237+const storePath = await createStorePath();
238+await upsertSessionEntry({
239+ storePath,
240+ sessionKey,
241+entry: { sessionId: "sess-main", updatedAt: 1, totalTokens: 0, totalTokensFresh: true },
242+});
243+244+const params = buildNameParams("/name Secret", storePath, { isAuthorizedSender: false });
245+const result = await handleNameCommand(params, true);
246+247+expect(result?.shouldContinue).toBe(false);
248+expect(getSessionEntry({ storePath, sessionKey })?.label).toBeUndefined();
249+expect(takeCommandSessionMetadataChanges(params.ctx)).toBeUndefined();
250+});
251+252+it("returns null when text commands are disabled", async () => {
253+const storePath = await createStorePath();
254+const params = buildNameParams("/name Anything", storePath);
255+expect(await handleNameCommand(params, false)).toBeNull();
256+});
257+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。