mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
fix: gate chat session commands
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type { GatewaySessionRow, SessionsListResult } from "../../api/types.ts";
|
||||
import type { ApplicationGatewaySnapshot } from "../../app/gateway.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import type { SessionCapability, SessionPatch } from "../../lib/sessions/index.ts";
|
||||
import {
|
||||
@@ -45,14 +46,42 @@ function executeSlashCommand(
|
||||
sessionKey: string,
|
||||
commandName: string,
|
||||
args: string,
|
||||
context: Omit<Parameters<typeof executeSlashCommandImpl>[4], "sessions"> = {},
|
||||
context: Omit<
|
||||
Parameters<typeof executeSlashCommandImpl>[4],
|
||||
"sessionAccessSnapshot" | "sessions"
|
||||
> & {
|
||||
sessionAccessSnapshot?: Parameters<typeof executeSlashCommandImpl>[4]["sessionAccessSnapshot"];
|
||||
} = {},
|
||||
) {
|
||||
const {
|
||||
sessionAccessSnapshot = {
|
||||
client,
|
||||
hello: null,
|
||||
phase: "connected",
|
||||
},
|
||||
...rest
|
||||
} = context;
|
||||
return executeSlashCommandImpl(client, sessionKey, commandName, args, {
|
||||
sessions: createSessionCapability(client),
|
||||
...context,
|
||||
...rest,
|
||||
sessionAccessSnapshot,
|
||||
});
|
||||
}
|
||||
|
||||
function restrictedSnapshot(
|
||||
client: GatewayBrowserClient,
|
||||
methods: string[],
|
||||
): Pick<ApplicationGatewaySnapshot, "client" | "hello" | "phase"> {
|
||||
return {
|
||||
client,
|
||||
phase: "connected",
|
||||
hello: {
|
||||
auth: { role: "operator", scopes: ["operator.read"] },
|
||||
features: { methods },
|
||||
} as ApplicationGatewaySnapshot["hello"],
|
||||
};
|
||||
}
|
||||
|
||||
function row(key: string, overrides?: Partial<GatewaySessionRow>): GatewaySessionRow {
|
||||
return {
|
||||
key,
|
||||
@@ -79,6 +108,31 @@ function expectNoRequestCall(request: ReturnType<typeof vi.fn>, method: string)
|
||||
}
|
||||
|
||||
describe("executeSlashCommand directives", () => {
|
||||
it("does not compact a session without operator.admin", async () => {
|
||||
const request = vi.fn();
|
||||
const client = { request } as unknown as GatewayBrowserClient;
|
||||
|
||||
const result = await executeSlashCommand(client, "main", "compact", "", {
|
||||
sessionAccessSnapshot: restrictedSnapshot(client, ["sessions.compact"]),
|
||||
});
|
||||
|
||||
expect(result.failed).toBe(true);
|
||||
expectNoRequestCall(request, "sessions.compact");
|
||||
});
|
||||
|
||||
it("does not patch session settings without operator.admin", async () => {
|
||||
const request = vi.fn();
|
||||
const client = { request } as unknown as GatewayBrowserClient;
|
||||
|
||||
const result = await executeSlashCommand(client, "main", "model", "gpt-5-mini", {
|
||||
sessionAccessSnapshot: restrictedSnapshot(client, ["sessions.patch"]),
|
||||
chatModelCatalog: [{ id: "gpt-5-mini", name: "GPT-5 Mini", provider: "openai" }],
|
||||
});
|
||||
|
||||
expect(result.failed).toBe(true);
|
||||
expectNoRequestCall(request, "sessions.patch");
|
||||
});
|
||||
|
||||
it("resolves the legacy main alias for bare /model", async () => {
|
||||
const request = vi.fn(async (method: string, _payload?: unknown) => {
|
||||
if (method === "sessions.list") {
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
ModelCatalogEntry,
|
||||
SessionsListResult,
|
||||
} from "../../api/types.ts";
|
||||
import type { ApplicationGatewaySnapshot } from "../../app/gateway.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import {
|
||||
getSlashCommandCategoryLabel,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
resolveThinkingLevelInput,
|
||||
} from "../../lib/chat/thinking.ts";
|
||||
import { formatCompactTokenCount } from "../../lib/format.ts";
|
||||
import { readSessionMethodAccess } from "../../lib/session-method-access.ts";
|
||||
import { isSessionRunActive } from "../../lib/session-run-state.ts";
|
||||
import type { SessionCapability } from "../../lib/sessions/index.ts";
|
||||
import {
|
||||
@@ -44,10 +46,7 @@ import {
|
||||
normalizeOptionalLowercaseString,
|
||||
} from "../../lib/string-coerce.ts";
|
||||
import { generateUUID } from "../../lib/uuid.ts";
|
||||
import {
|
||||
patchChatCommandSessionSettings as patchSession,
|
||||
selectedGlobalScope,
|
||||
} from "./chat-settings-patches.ts";
|
||||
import { patchChatCommandSessionSettings, selectedGlobalScope } from "./chat-settings-patches.ts";
|
||||
|
||||
type SlashCommandResult = {
|
||||
/** Markdown-formatted result to display in chat. */
|
||||
@@ -68,6 +67,7 @@ type SlashCommandResult = {
|
||||
|
||||
type SlashCommandContext = {
|
||||
sessions: SessionCapability;
|
||||
sessionAccessSnapshot: Pick<ApplicationGatewaySnapshot, "client" | "hello" | "phase">;
|
||||
chatModelCatalog?: ModelCatalogEntry[];
|
||||
modelCatalog?: ModelCatalogEntry[];
|
||||
sessionsResult?: SessionsListResult | null;
|
||||
@@ -76,6 +76,26 @@ type SlashCommandContext = {
|
||||
agentId?: string;
|
||||
};
|
||||
|
||||
async function patchSession(
|
||||
context: SlashCommandContext,
|
||||
sessionKey: string,
|
||||
patch: Parameters<typeof patchChatCommandSessionSettings>[2],
|
||||
) {
|
||||
const params = {
|
||||
key: sessionKey,
|
||||
...selectedGlobalScope(sessionKey, context),
|
||||
...patch,
|
||||
};
|
||||
const access = readSessionMethodAccess(context.sessionAccessSnapshot, {
|
||||
method: "sessions.patch",
|
||||
params,
|
||||
});
|
||||
if (!access.allowed) {
|
||||
throw new Error(access.reason);
|
||||
}
|
||||
return await patchChatCommandSessionSettings(context, sessionKey, patch);
|
||||
}
|
||||
|
||||
function normalizeVerboseLevel(raw?: string | null): "off" | "on" | "full" | undefined {
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
@@ -172,10 +192,15 @@ async function executeCompact(
|
||||
context: SlashCommandContext,
|
||||
): Promise<SlashCommandResult> {
|
||||
try {
|
||||
const result = await context.sessions.compact(
|
||||
sessionKey,
|
||||
selectedGlobalScope(sessionKey, context),
|
||||
);
|
||||
const options = selectedGlobalScope(sessionKey, context);
|
||||
const access = readSessionMethodAccess(context.sessionAccessSnapshot, {
|
||||
method: "sessions.compact",
|
||||
requiredScope: "operator.admin",
|
||||
});
|
||||
if (!access.allowed) {
|
||||
throw new Error(access.reason);
|
||||
}
|
||||
const result = await context.sessions.compact(sessionKey, options);
|
||||
if (result?.ok !== true) {
|
||||
const reason = typeof result?.reason === "string" ? result.reason.trim() : "";
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type { ApplicationGatewaySnapshot } from "../../app/gateway.ts";
|
||||
import {
|
||||
SLASH_COMMANDS,
|
||||
getSlashCommandCategoryLabel,
|
||||
@@ -26,6 +28,14 @@ function expectRecordFields(value: unknown, label: string, expected: Record<stri
|
||||
}
|
||||
}
|
||||
|
||||
function legacyConnectedSessionAccess() {
|
||||
return {
|
||||
client: { request: vi.fn() } as unknown as GatewayBrowserClient,
|
||||
connected: true,
|
||||
hello: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("refreshSlashCommands", () => {
|
||||
it("resolves localized UI command metadata", () => {
|
||||
const clear = SLASH_COMMANDS.find((entry) => entry.name === "clear");
|
||||
@@ -245,6 +255,39 @@ describe("refreshSlashCommands", () => {
|
||||
});
|
||||
|
||||
describe("conversation reset confirmation", () => {
|
||||
it.each([
|
||||
["stop", "chat.abort"],
|
||||
["clear", "sessions.reset"],
|
||||
["compact", "sessions.compact"],
|
||||
] as const)("rejects /%s without its exact operator scope", async (command, method) => {
|
||||
const request = vi.fn();
|
||||
const reset = vi.fn();
|
||||
const client = { request } as unknown as GatewayBrowserClient;
|
||||
const host = {
|
||||
client,
|
||||
connected: true,
|
||||
hello: {
|
||||
auth: { role: "operator", scopes: ["operator.read"] },
|
||||
features: { methods: [method] },
|
||||
} as ApplicationGatewaySnapshot["hello"],
|
||||
sessionKey: "agent:main:current",
|
||||
chatRunId: command === "stop" ? "run-1" : null,
|
||||
sessions: { reset },
|
||||
confirmConversationReset: vi.fn(async () => true),
|
||||
lastError: null,
|
||||
chatError: null,
|
||||
};
|
||||
|
||||
const result = await dispatchChatSlashCommand(host as never, command, "", {
|
||||
sendResetMessage: vi.fn(),
|
||||
});
|
||||
|
||||
expect(result).toBe("failed");
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
expect(reset).not.toHaveBeenCalled();
|
||||
expect(host.lastError).toBeTruthy();
|
||||
});
|
||||
|
||||
it("propagates cancelled /new session creation", async () => {
|
||||
const result = await dispatchChatSlashCommand(
|
||||
{ createChatSession: vi.fn(async () => false) } as never,
|
||||
@@ -323,6 +366,7 @@ describe("conversation reset confirmation", () => {
|
||||
const sendResetMessage = vi.fn(async () => {});
|
||||
const reset = vi.fn();
|
||||
const host = {
|
||||
...legacyConnectedSessionAccess(),
|
||||
chatRunId: null as string | null,
|
||||
sessionKey: "agent:main:current",
|
||||
confirmConversationReset: vi.fn(async () => await confirmation),
|
||||
@@ -357,6 +401,7 @@ describe("conversation reset confirmation", () => {
|
||||
const reset = vi.fn();
|
||||
const result = await dispatchChatSlashCommand(
|
||||
{
|
||||
...legacyConnectedSessionAccess(),
|
||||
sessionKey: "agent:main:current",
|
||||
confirmConversationReset: vi.fn(async () => false),
|
||||
sessions: { reset },
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { CommandsListResult } from "../../../../packages/gateway-protocol/src/index.js";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type { ModelCatalogEntry, SessionsListResult } from "../../api/types.ts";
|
||||
import type { ApplicationGatewaySnapshot } from "../../app/gateway.ts";
|
||||
import type { ChatQueueItem } from "../../lib/chat/chat-types.ts";
|
||||
import {
|
||||
buildFallbackSlashCommands,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
import { executeSlashCommand } from "./chat-command-executor.ts";
|
||||
import { clearChatHistory } from "./chat-history.ts";
|
||||
import { enqueuePendingRunMessage } from "./chat-queue.ts";
|
||||
import { readChatSessionActionAccess } from "./chat-session-action-access.ts";
|
||||
import { handleAbortChat } from "./run-lifecycle.ts";
|
||||
import { scheduleChatScroll } from "./scroll.ts";
|
||||
|
||||
@@ -74,6 +76,31 @@ function setChatCommandError(
|
||||
host.chatError = error;
|
||||
}
|
||||
|
||||
function currentSessionAccessSnapshot(
|
||||
host: ChatCommandHost,
|
||||
): Pick<ApplicationGatewaySnapshot, "client" | "hello" | "phase"> {
|
||||
return {
|
||||
client: host.client ?? null,
|
||||
hello: host.hello ?? null,
|
||||
phase: host.connected ? "connected" : "offline",
|
||||
};
|
||||
}
|
||||
|
||||
function requireChatSessionAction(
|
||||
host: ChatCommandHost,
|
||||
action: "abort" | "compact" | "reset",
|
||||
): boolean {
|
||||
const access = readChatSessionActionAccess(
|
||||
currentSessionAccessSnapshot(host),
|
||||
Boolean(host.chatRunId),
|
||||
)[action];
|
||||
if (access.allowed) {
|
||||
return true;
|
||||
}
|
||||
setChatCommandError(host, access.reason);
|
||||
return false;
|
||||
}
|
||||
|
||||
function remoteSlashCommandCacheKey(agentId: string | undefined): string {
|
||||
return agentId ?? "";
|
||||
}
|
||||
@@ -227,6 +254,9 @@ export async function dispatchChatSlashCommand(
|
||||
): Promise<ChatCommandDispatchResult> {
|
||||
switch (name) {
|
||||
case "stop":
|
||||
if (!requireChatSessionAction(host, "abort")) {
|
||||
return "failed";
|
||||
}
|
||||
await handleAbortChat(host);
|
||||
return "completed";
|
||||
case "new":
|
||||
@@ -244,12 +274,20 @@ export async function dispatchChatSlashCommand(
|
||||
return "completed";
|
||||
}
|
||||
case "clear": {
|
||||
if (!requireChatSessionAction(host, "reset")) {
|
||||
return "failed";
|
||||
}
|
||||
const confirmation = await confirmConversationResetForCurrentSession(host);
|
||||
if (confirmation !== "confirmed") {
|
||||
return confirmation;
|
||||
}
|
||||
return await clearChatHistory(host);
|
||||
}
|
||||
case "compact":
|
||||
if (!requireChatSessionAction(host, "compact")) {
|
||||
return "failed";
|
||||
}
|
||||
break;
|
||||
case "export-session":
|
||||
await host.exportCurrentChat?.();
|
||||
return "completed";
|
||||
@@ -280,6 +318,7 @@ export async function dispatchChatSlashCommand(
|
||||
try {
|
||||
result = await executeSlashCommand(targetClient, targetSessionKey, name, args, {
|
||||
sessions: host.sessions,
|
||||
sessionAccessSnapshot: currentSessionAccessSnapshot(host),
|
||||
chatModelCatalog: host.chatModelCatalog,
|
||||
sessionsResult: host.sessionsResult,
|
||||
sessionsResultAgentId: host.sessionsResultAgentId,
|
||||
|
||||
Reference in New Issue
Block a user