fix: gate chat session commands

This commit is contained in:
Shakker
2026-08-02 14:56:01 +01:00
parent f3faf61b30
commit fbdf372827
4 changed files with 173 additions and 10 deletions
@@ -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") {
+33 -8
View File
@@ -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 {
+45
View File
@@ -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 },
+39
View File
@@ -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,