feat: add project-aware agent workspace
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createAgentApiClient } from "./client";
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
|
||||
|
||||
describe("authenticated session exports", () => {
|
||||
it("fetches only Agent session files with identity and project headers", async () => {
|
||||
const fetchMock = vi.fn(async () => new Response("ZIP", { headers: { "Content-Type": "application/zip" } }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.stubGlobal("URL", class extends URL {
|
||||
static createObjectURL = vi.fn(() => "blob:fixture");
|
||||
static revokeObjectURL = vi.fn();
|
||||
});
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
const client = createAgentApiClient("https://agent.test", { getAccessToken: async () => "FIXTURE", getProjectId: async () => "project-a" });
|
||||
const path = "/api/v1/session/session-a/files/mcp-11111111-1111-1111-1111-111111111111.zip";
|
||||
await client.downloadFile(path);
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(url).toBe(`https://agent.test${path}`);
|
||||
expect(new Headers(init.headers).get("Authorization")).toBe("Bearer FIXTURE");
|
||||
expect(new Headers(init.headers).get("X-Project-Id")).toBe("project-a");
|
||||
expect(init.redirect).toBe("error");
|
||||
expect(click).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects arbitrary URLs and paths before requesting any credential", async () => {
|
||||
const getAccessToken = vi.fn();
|
||||
const client = createAgentApiClient("https://agent.test", { getAccessToken });
|
||||
for (const path of ["https://foreign.test/file.zip", "/api/v1/admin/files/a.zip", "/api/v1/session/s/files/secret.env", "/api/v1/session/s/files/../owner.json"]) {
|
||||
await expect(client.downloadFile(path)).rejects.toThrow("无效");
|
||||
}
|
||||
expect(getAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2,16 +2,45 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createAgentApiClient } from "./client";
|
||||
|
||||
describe("Agent prompt suggestions", () => {
|
||||
it("uses owned topic endpoints and retains full-text hits from another topic", async () => {
|
||||
const topic = { id: "topic-a", name: "城南压力问题", createdAt: "2026-10-22T01:00:00Z", updatedAt: "2026-10-22T01:00:00Z" };
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(Response.json({ topics: [topic] }))
|
||||
.mockResolvedValueOnce(Response.json({ sessions: [{ id: "session-b", title: "另一段对话", topic_id: "topic-b", topic_name: "检修事件", search_snippet: "正文中的南桥阀门", purpose: "task_collaboration" }], has_more: false }))
|
||||
.mockResolvedValueOnce(Response.json({ session_id: "session-b", topic_id: "topic-a" }))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const client = createAgentApiClient("http://agent.local", { getAccessToken: async () => "token", getProjectId: async () => "project-a" });
|
||||
expect(await client.listTopics()).toEqual([topic]);
|
||||
const result = await client.listSessionPage({ q: "南桥阀门", includeCollaboration: true });
|
||||
expect(result.sessions[0]).toMatchObject({ title: "另一段对话", topicId: "topic-b", topicName: "检修事件", searchSnippet: "正文中的南桥阀门", purpose: "task_collaboration" });
|
||||
const query = new URL(String(fetchMock.mock.calls[1]?.[0])).searchParams;
|
||||
expect(query.get("q")).toBe("南桥阀门");
|
||||
expect(query.has("topic_id")).toBe(false);
|
||||
expect(query.get("include_collaboration")).toBe("true");
|
||||
await client.moveSessionToTopic("session-b", "topic-a");
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[2]?.[1]?.body))).toEqual({ topic_id: "topic-a" });
|
||||
await client.deleteTopic("topic-a");
|
||||
expect(String(fetchMock.mock.calls[3]?.[0])).toContain("/topics/topic-a");
|
||||
for (const [, init] of fetchMock.mock.calls) {
|
||||
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer token");
|
||||
expect(new Headers(init?.headers).get("X-Project-Id")).toBe("project-a");
|
||||
}
|
||||
});
|
||||
|
||||
it("submits and edits pending input through the scoped Responses and session endpoints", async () => {
|
||||
const input = { id: `msg_${"1".repeat(32)}`, text: "补充压力测点", delivery: "steer", status: "pending" };
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(Response.json(input)).mockResolvedValueOnce(Response.json({ items: [input] }))
|
||||
.mockResolvedValueOnce(Response.json({ ...input, delivery: "queue" }));
|
||||
.mockResolvedValueOnce(Response.json({ ...input, delivery: "queue" }))
|
||||
.mockResolvedValueOnce(Response.json({ ...input, status: "cancelled" }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const client = createAgentApiClient("http://agent.local", { getAccessToken: async () => "token", getProjectId: async () => "project" });
|
||||
await expect(client.submitResponseInput("session-1", input.text, "steer", input.id)).resolves.toEqual(input);
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ input: input.text, conversation: "session-1", stream: false, tjwater: { delivery: "steer", input_id: input.id } });
|
||||
await expect(client.listResponseInputs("session-1")).resolves.toEqual([input]);
|
||||
await expect(client.updateResponseInput("session-1", input.id, "queue")).resolves.toEqual({ ...input, delivery: "queue" });
|
||||
await expect(client.cancelResponseInput("session-1", input.id)).resolves.toEqual({ ...input, status: "cancelled" });
|
||||
expect(fetchMock.mock.calls[3]?.[1]?.method).toBe("DELETE");
|
||||
expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://agent.local/api/v1/responses");
|
||||
expect(String(fetchMock.mock.calls[2]?.[0])).toContain(`/api/v1/session/session-1/inputs/${input.id}`);
|
||||
for (const [, request] of fetchMock.mock.calls) {
|
||||
@@ -83,7 +112,8 @@ describe("Agent API client sessions", () => {
|
||||
runStatus: "running",
|
||||
purpose: "ordinary_chat",
|
||||
taskId: undefined,
|
||||
trigger: undefined
|
||||
trigger: undefined,
|
||||
topicId: null, topicName: undefined, searchSnippet: undefined
|
||||
},
|
||||
{
|
||||
id: "old",
|
||||
@@ -96,7 +126,8 @@ describe("Agent API client sessions", () => {
|
||||
runStatus: undefined,
|
||||
purpose: "ordinary_chat",
|
||||
taskId: undefined,
|
||||
trigger: undefined
|
||||
trigger: undefined,
|
||||
topicId: null, topicName: undefined, searchSnippet: undefined
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -39,12 +39,15 @@ export type AgentModelsResponse = {
|
||||
};
|
||||
|
||||
export type AgentInputDelivery = "steer" | "queue";
|
||||
export type AgentResponseInput = { id: string; text: string; delivery: AgentInputDelivery; status: "pending" | "delivered" };
|
||||
export type AgentResponseInput = { id: string; text: string; delivery: AgentInputDelivery; status: "pending" | "delivered" | "cancelled" };
|
||||
|
||||
export type AgentTopic = { id: string; name: string; createdAt: string; updatedAt: string };
|
||||
|
||||
export type AgentChatSession = {
|
||||
id?: string;
|
||||
session_id: string;
|
||||
title?: string;
|
||||
topic_id?: string | null;
|
||||
status?: string;
|
||||
run_status?: AgentRunStatus;
|
||||
is_streaming?: boolean;
|
||||
@@ -64,12 +67,16 @@ export type AgentChatSessionSummary = {
|
||||
trigger?: { subjectId: string; workflow: "water_quality_complaint"; occurredAt: string };
|
||||
id: string;
|
||||
title: string;
|
||||
topicId?: string | null;
|
||||
topicName?: string;
|
||||
searchSnippet?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
status?: string;
|
||||
parentSessionId?: string;
|
||||
isStreaming?: boolean;
|
||||
runStatus?: AgentRunStatus;
|
||||
needsInput?: boolean;
|
||||
purpose: AgentSessionPurpose;
|
||||
taskId?: string;
|
||||
};
|
||||
@@ -89,10 +96,15 @@ export type AgentPromptSuggestion = {
|
||||
activityId: string | null;
|
||||
};
|
||||
|
||||
export type AgentMessagePagination = { hasMore: boolean; beforeCursor: string | null };
|
||||
export type AgentSessionPage = { sessions: AgentChatSessionSummary[]; hasMore: boolean; nextCursor: string | null };
|
||||
export type AgentHistoryMessagePage = { messages: unknown[]; messagePage: AgentMessagePagination };
|
||||
|
||||
export type AgentLoadedChatSession = AgentChatSessionSummary & {
|
||||
sessionId: string;
|
||||
isTitleManuallyEdited: boolean;
|
||||
messages: unknown[];
|
||||
messagePage?: AgentMessagePagination;
|
||||
};
|
||||
|
||||
export type AgentSessionStreamDataEventType =
|
||||
@@ -124,6 +136,7 @@ export type AgentSessionStreamEvent =
|
||||
| {
|
||||
type: "state";
|
||||
executionPlans?: unknown[];
|
||||
messagePage?: AgentMessagePagination;
|
||||
sessionId: string;
|
||||
messages: unknown[];
|
||||
isStreaming: boolean;
|
||||
@@ -136,20 +149,30 @@ export type AgentSessionStreamEvent =
|
||||
};
|
||||
|
||||
export type AgentApiClient = {
|
||||
downloadFile: (path: string) => Promise<void>;
|
||||
submitResponseInput: (sessionId: string, text: string, delivery: AgentInputDelivery, id: string) => Promise<AgentResponseInput>;
|
||||
listResponseInputs: (sessionId: string) => Promise<AgentResponseInput[]>;
|
||||
updateResponseInput: (sessionId: string, id: string, delivery: AgentInputDelivery) => Promise<AgentResponseInput>;
|
||||
cancelResponseInput: (sessionId: string, id: string) => Promise<AgentResponseInput>;
|
||||
listAgentMessages: (sessionId: string, options?: { cursor?: string; signal?: AbortSignal }) => Promise<AgentMessagePage>;
|
||||
streamAgentMessages: (sessionId: string, options: { signal?: AbortSignal; onMessage: () => void }) => Promise<void>;
|
||||
generatePromptSuggestions: (activities: AgentRecentActivity[], model: string, signal?: AbortSignal) => Promise<AgentPromptSuggestion[]>;
|
||||
createSession: (options?: { triggerContext?: AgentEventTriggerContext }) => Promise<AgentChatSession>;
|
||||
createSession: (options?: { triggerContext?: AgentEventTriggerContext; topicId?: string }) => Promise<AgentChatSession>;
|
||||
listSessions: (options?: { purpose?: AgentSessionPurpose; taskId?: string }) => Promise<AgentChatSessionSummary[]>;
|
||||
listSessionPage: (options?: { q?: string; cursor?: string; limit?: number; purpose?: AgentSessionPurpose; topicId?: string; includeCollaboration?: boolean; signal?: AbortSignal }) => Promise<AgentSessionPage>;
|
||||
listTopics: () => Promise<AgentTopic[]>;
|
||||
createTopic: (name: string) => Promise<AgentTopic>;
|
||||
renameTopic: (id: string, name: string) => Promise<AgentTopic>;
|
||||
deleteTopic: (id: string) => Promise<void>;
|
||||
moveSessionToTopic: (sessionId: string, topicId: string | null) => Promise<void>;
|
||||
loadOlderMessages: (sessionId: string, before: string, signal?: AbortSignal) => Promise<AgentHistoryMessagePage>;
|
||||
createTaskCollaborationSession: (taskId: string) => Promise<AgentChatSession>;
|
||||
loadSession: (sessionId: string) => Promise<AgentLoadedChatSession | null>;
|
||||
loadSession: (sessionId: string, options?: { messageLimit?: number; signal?: AbortSignal }) => Promise<AgentLoadedChatSession | null>;
|
||||
streamSession: (
|
||||
sessionId: string,
|
||||
options: {
|
||||
signal?: AbortSignal;
|
||||
messageLimit?: number;
|
||||
onEvent: (event: AgentSessionStreamEvent) => void;
|
||||
}
|
||||
) => Promise<void>;
|
||||
@@ -218,6 +241,19 @@ export function createAgentApiClient(
|
||||
};
|
||||
|
||||
return {
|
||||
async downloadFile(path) {
|
||||
const match = /^\/api\/v1(\/session\/[^/?#]+\/files\/mcp-[a-f0-9-]{36}\.(?:zip|xlsx))$/.exec(path);
|
||||
if (!match) throw new Error("无效的会话文件地址");
|
||||
const response = await request(match[1], { redirect: "error" });
|
||||
if (!response.ok) throw new Error("文件不可用或当前账号无权下载");
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = path.split("/").at(-1) ?? "export.zip";
|
||||
anchor.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
},
|
||||
async submitResponseInput(sessionId, text, delivery, id) {
|
||||
return requestJson<AgentResponseInput>("/responses", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
@@ -233,6 +269,11 @@ export function createAgentApiClient(
|
||||
method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ delivery })
|
||||
});
|
||||
},
|
||||
async cancelResponseInput(sessionId, id) {
|
||||
return requestJson<AgentResponseInput>(`/session/${encodeURIComponent(sessionId)}/inputs/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
},
|
||||
async generatePromptSuggestions(activities, model, signal) {
|
||||
const result = await requestJson<unknown>("/suggestions", {
|
||||
method: "POST",
|
||||
@@ -260,7 +301,7 @@ export function createAgentApiClient(
|
||||
return requestJson<AgentChatSession>("/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ trigger_context: options?.triggerContext })
|
||||
body: JSON.stringify({ trigger_context: options?.triggerContext, topic_id: options?.topicId })
|
||||
});
|
||||
},
|
||||
|
||||
@@ -274,6 +315,31 @@ export function createAgentApiClient(
|
||||
return (payload.sessions ?? []).map(toSessionSummary).filter(isPresent).sort(compareSessionSummaries);
|
||||
},
|
||||
|
||||
async listSessionPage(options) {
|
||||
const params = new URLSearchParams({ purpose: options?.purpose ?? "ordinary_chat", limit: String(options?.limit ?? 30) });
|
||||
if (options?.q) params.set("q", options.q);
|
||||
if (options?.topicId) params.set("topic_id", options.topicId);
|
||||
if (options?.includeCollaboration) params.set("include_collaboration", "true");
|
||||
if (options?.cursor) params.set("cursor", options.cursor);
|
||||
const payload = await requestJson<{ sessions?: unknown[]; has_more?: boolean; next_cursor?: string | null }>(
|
||||
`/sessions?${params}`, { signal: options?.signal });
|
||||
return { sessions: (payload.sessions ?? []).map(toSessionSummary).filter(isPresent),
|
||||
hasMore: payload.has_more === true, nextCursor: typeof payload.next_cursor === "string" ? payload.next_cursor : null };
|
||||
},
|
||||
async listTopics() { return (await requestJson<{ topics: AgentTopic[] }>("/topics")).topics; },
|
||||
async createTopic(name) { return requestJson<AgentTopic>("/topics", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }) }); },
|
||||
async renameTopic(id, name) { return requestJson<AgentTopic>(`/topics/${encodeURIComponent(id)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }) }); },
|
||||
async deleteTopic(id) { await requestJson(`/topics/${encodeURIComponent(id)}`, { method: "DELETE" }); },
|
||||
async moveSessionToTopic(sessionId, topicId) { await requestJson(`/session/${encodeURIComponent(sessionId)}/topic`, {
|
||||
method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ topic_id: topicId }),
|
||||
}); },
|
||||
async loadOlderMessages(sessionId, before, signal) {
|
||||
const query = new URLSearchParams({ limit: "40", before });
|
||||
const payload = await requestJson<{ messages?: unknown[]; message_page?: unknown }>(
|
||||
`/session/${encodeURIComponent(sessionId)}/messages?${query}`, { signal });
|
||||
return { messages: payload.messages ?? [], messagePage: readMessagePagination(payload.message_page) ?? { hasMore: false, beforeCursor: null } };
|
||||
},
|
||||
|
||||
async createTaskCollaborationSession(taskId) {
|
||||
return requestJson<AgentChatSession>(
|
||||
`/task/${encodeURIComponent(taskId)}/collaboration/session`,
|
||||
@@ -293,8 +359,9 @@ export function createAgentApiClient(
|
||||
});
|
||||
},
|
||||
|
||||
async loadSession(sessionId) {
|
||||
const response = await request(`/session/${encodeURIComponent(sessionId)}`);
|
||||
async loadSession(sessionId, options) {
|
||||
const query = options?.messageLimit ? `?message_limit=${options.messageLimit}` : "";
|
||||
const response = await request(`/session/${encodeURIComponent(sessionId)}${query}`, { signal: options?.signal });
|
||||
const text = await response.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
|
||||
@@ -310,7 +377,7 @@ export function createAgentApiClient(
|
||||
|
||||
async streamSession(sessionId, options) {
|
||||
const response = await request(
|
||||
`/session/${encodeURIComponent(sessionId)}/stream`,
|
||||
`/session/${encodeURIComponent(sessionId)}/stream${options.messageLimit ? `?message_limit=${options.messageLimit}` : ""}`,
|
||||
{ signal: options.signal }
|
||||
);
|
||||
|
||||
@@ -564,6 +631,9 @@ function toSessionSummary(value: unknown): AgentChatSessionSummary | null {
|
||||
return {
|
||||
id,
|
||||
title: normalizeTitle(value.title),
|
||||
topicId: typeof value.topic_id === "string" ? value.topic_id : null,
|
||||
topicName: typeof value.topic_name === "string" ? value.topic_name : undefined,
|
||||
searchSnippet: typeof value.search_snippet === "string" ? value.search_snippet : undefined,
|
||||
createdAt: toMillis(value.created_at),
|
||||
updatedAt: toMillis(value.updated_at),
|
||||
status: typeof value.status === "string" ? value.status : undefined,
|
||||
@@ -591,7 +661,8 @@ function toLoadedSession(value: unknown): AgentLoadedChatSession {
|
||||
sessionId: typeof value.session_id === "string" ? value.session_id : summary.id,
|
||||
isTitleManuallyEdited:
|
||||
typeof value.is_title_manually_edited === "boolean" ? value.is_title_manually_edited : false,
|
||||
messages: Array.isArray(value.messages) ? value.messages : []
|
||||
messages: Array.isArray(value.messages) ? value.messages : [],
|
||||
messagePage: readMessagePagination(value.message_page)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -657,6 +728,7 @@ function emitSessionSseEvent(
|
||||
onEvent({
|
||||
type: "state",
|
||||
executionPlans: Array.isArray(data.execution_plans) ? data.execution_plans : undefined,
|
||||
messagePage: readMessagePagination(data.message_page),
|
||||
sessionId,
|
||||
messages: Array.isArray(data.messages) ? data.messages : [],
|
||||
isStreaming: data.is_streaming === true,
|
||||
@@ -717,3 +789,8 @@ function compareSessionSummaries(left: AgentChatSessionSummary, right: AgentChat
|
||||
|
||||
return right.id.localeCompare(left.id);
|
||||
}
|
||||
|
||||
function readMessagePagination(value: unknown): AgentMessagePagination | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
return { hasMore: value.has_more === true, beforeCursor: typeof value.before_cursor === "string" ? value.before_cursor : null };
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("Responses transport", () => {
|
||||
const messages = [{ id: "old", role: "assistant", parts: [{ type: "text", text: "旧回答" }] }, { id: "new", role: "user", parts: [{ type: "text", text: "继续核查" }] }] as UIMessage[];
|
||||
expect(createResponseRequest(messages, { conversation: "owned", approvalMode: "always" })).toEqual({
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "继续核查" }] }], stream: true, conversation: "owned",
|
||||
tjwater: { approval_mode: "always", capabilities: [] },
|
||||
tjwater: { client_message_id: "new", approval_mode: "always", capabilities: [] },
|
||||
});
|
||||
});
|
||||
it("decodes split UTF-8 and CRLF while retaining approval events", async () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ const record = (value: unknown): value is Record<string, unknown> => typeof valu
|
||||
|
||||
export function createResponseRequest(messages: UIMessage[], options: {
|
||||
conversation?: string; model?: string; approvalMode?: string; capabilities?: string[];
|
||||
triggerContext?: unknown; taskContext?: unknown;
|
||||
triggerContext?: unknown; taskContext?: unknown; topicId?: string;
|
||||
}) {
|
||||
const latest = [...messages].reverse().find((message) => message.role === "user");
|
||||
const text = latest?.parts.filter((part) => part.type === "text").map((part) => part.text).join("\n").trim();
|
||||
@@ -13,7 +13,7 @@ export function createResponseRequest(messages: UIMessage[], options: {
|
||||
input: [{ role: "user", content: [{ type: "input_text", text }] }], stream: true,
|
||||
...(options.model ? { model: options.model } : {}),
|
||||
...(options.conversation ? { conversation: options.conversation } : {}),
|
||||
tjwater: { approval_mode: options.approvalMode ?? "request", capabilities: options.capabilities ?? [],
|
||||
tjwater: { ...(options.topicId ? { topic_id: options.topicId } : {}), ...(latest?.id ? { client_message_id: latest.id } : {}), approval_mode: options.approvalMode ?? "request", capabilities: options.capabilities ?? [],
|
||||
...(options.triggerContext ? { trigger_context: options.triggerContext } : {}),
|
||||
...(options.taskContext ? { task_context: options.taskContext } : {}),
|
||||
},
|
||||
@@ -44,6 +44,9 @@ export function responsesToUiStream(stream: ReadableStream<Uint8Array>): Readabl
|
||||
if (value.type === "response.created") {
|
||||
if (responseId || !record(value.response) || typeof value.response.id !== "string") throw new Error("Responses 创建事件不正确");
|
||||
responseId = value.response.id;
|
||||
if (record(value.response.conversation) && typeof value.response.conversation.id === "string") {
|
||||
controller.enqueue({ type: "data-session_created", data: { session_id: value.response.conversation.id }, transient: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!responseId) throw new Error("Responses 流缺少创建事件");
|
||||
|
||||
Reference in New Issue
Block a user