fix(athena): address 9 council-audit findings — dead code, bugs, and hardening
Fixes from multi-model council audit (7 members, 19 findings, 9 selected): - Use parseModelString() for cross-provider Anthropic thinking config (#3) - Update stale AGENTS.md athena directory listing (#4) - Replace prompt in appendMissingCouncilPrompt instead of appending (#5) - Extract duplicated session cleanup logic in agent-switch hook (#6) - Surface skipped council members when >=2 valid members exist (#9) - Expand fallback handoff regex with negation guards (#11) - Remove dead council-member agent from agentSources and tests (#12) - Make runtime council member duplicate check case-insensitive (#14) - Fix false-positive schema tests by adding required name field (#18)
This commit is contained in:
parent
f0d0658eae
commit
11a4d457bf
@ -3541,7 +3541,8 @@
|
|||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"minLength": 1
|
"minLength": 1,
|
||||||
|
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9 .\\-]*$"
|
||||||
},
|
},
|
||||||
"temperature": {
|
"temperature": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
|
|||||||
@ -51,11 +51,10 @@ agents/
|
|||||||
├── momus.ts # Plan review
|
├── momus.ts # Plan review
|
||||||
├── atlas/agent.ts # Todo orchestrator
|
├── atlas/agent.ts # Todo orchestrator
|
||||||
├── athena/ # Multi-model council orchestrator
|
├── athena/ # Multi-model council orchestrator
|
||||||
│ ├── agent.ts # Athena agent factory
|
│ ├── agent.ts # Athena agent factory + system prompt
|
||||||
│ ├── council-member-agent.ts # Council member agent factory
|
│ ├── council-member-agent.ts # Council member agent factory
|
||||||
│ ├── model-parser.ts # Model string parser
|
│ ├── model-thinking-config.ts # Per-provider thinking/reasoning config
|
||||||
│ ├── types.ts # Council types
|
│ └── model-thinking-config.test.ts # Tests for thinking config
|
||||||
│ └── index.ts # Barrel exports
|
|
||||||
├── types.ts # AgentFactory, AgentMode
|
├── types.ts # AgentFactory, AgentMode
|
||||||
├── agent-builder.ts # buildAgent() composition
|
├── agent-builder.ts # buildAgent() composition
|
||||||
├── utils.ts # Agent utilities
|
├── utils.ts # Agent utilities
|
||||||
|
|||||||
@ -52,4 +52,30 @@ describe("applyModelThinkingConfig", () => {
|
|||||||
expect(result).toBe(BASE_CONFIG)
|
expect(result).toBe(BASE_CONFIG)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("given a Claude model through a non-Anthropic provider", () => {
|
||||||
|
it("returns thinking config for github-copilot/claude-opus-4-6", () => {
|
||||||
|
const result = applyModelThinkingConfig(BASE_CONFIG, "github-copilot/claude-opus-4-6")
|
||||||
|
expect(result).toEqual({
|
||||||
|
...BASE_CONFIG,
|
||||||
|
thinking: { type: "enabled", budgetTokens: 32000 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns thinking config for opencode/claude-opus-4-6", () => {
|
||||||
|
const result = applyModelThinkingConfig(BASE_CONFIG, "opencode/claude-opus-4-6")
|
||||||
|
expect(result).toEqual({
|
||||||
|
...BASE_CONFIG,
|
||||||
|
thinking: { type: "enabled", budgetTokens: 32000 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns thinking config for opencode/claude-sonnet-4-6", () => {
|
||||||
|
const result = applyModelThinkingConfig(BASE_CONFIG, "opencode/claude-sonnet-4-6")
|
||||||
|
expect(result).toEqual({
|
||||||
|
...BASE_CONFIG,
|
||||||
|
thinking: { type: "enabled", budgetTokens: 32000 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||||
|
import { parseModelString } from "../../tools/delegate-task/model-string-parser"
|
||||||
import { isGptModel } from "../types"
|
import { isGptModel } from "../types"
|
||||||
|
|
||||||
export function applyModelThinkingConfig(base: AgentConfig, model: string): AgentConfig {
|
export function applyModelThinkingConfig(base: AgentConfig, model: string): AgentConfig {
|
||||||
@ -6,10 +7,12 @@ export function applyModelThinkingConfig(base: AgentConfig, model: string): Agen
|
|||||||
return { ...base, reasoningEffort: "medium" }
|
return { ...base, reasoningEffort: "medium" }
|
||||||
}
|
}
|
||||||
|
|
||||||
const slashIndex = model.indexOf("/")
|
const parsed = parseModelString(model)
|
||||||
const provider = slashIndex > 0 ? model.substring(0, slashIndex).toLowerCase() : ""
|
if (!parsed) {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
if (provider === "anthropic") {
|
if (parsed.providerID.toLowerCase() === "anthropic" || parsed.modelID.startsWith("claude")) {
|
||||||
return { ...base, thinking: { type: "enabled", budgetTokens: 32000 } }
|
return { ...base, thinking: { type: "enabled", budgetTokens: 32000 } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -13,7 +13,6 @@ import { createAtlasAgent, atlasPromptMetadata } from "./atlas"
|
|||||||
import { createMomusAgent, momusPromptMetadata } from "./momus"
|
import { createMomusAgent, momusPromptMetadata } from "./momus"
|
||||||
import { createHephaestusAgent } from "./hephaestus"
|
import { createHephaestusAgent } from "./hephaestus"
|
||||||
import { createAthenaAgent, ATHENA_PROMPT_METADATA } from "./athena/agent"
|
import { createAthenaAgent, ATHENA_PROMPT_METADATA } from "./athena/agent"
|
||||||
import { createCouncilMemberAgent } from "./athena/council-member-agent"
|
|
||||||
import type { AvailableCategory } from "./dynamic-agent-prompt-builder"
|
import type { AvailableCategory } from "./dynamic-agent-prompt-builder"
|
||||||
import {
|
import {
|
||||||
fetchAvailableModels,
|
fetchAvailableModels,
|
||||||
@ -45,7 +44,6 @@ const agentSources: Partial<Record<BuiltinAgentName, AgentSource>> = {
|
|||||||
metis: createMetisAgent,
|
metis: createMetisAgent,
|
||||||
momus: createMomusAgent,
|
momus: createMomusAgent,
|
||||||
athena: createAthenaAgent,
|
athena: createAthenaAgent,
|
||||||
"council-member": createCouncilMemberAgent,
|
|
||||||
// Note: Atlas is handled specially in createBuiltinAgents()
|
// Note: Atlas is handled specially in createBuiltinAgents()
|
||||||
// because it needs OrchestratorContext, not just a model string
|
// because it needs OrchestratorContext, not just a model string
|
||||||
atlas: createAtlasAgent as AgentFactory,
|
atlas: createAtlasAgent as AgentFactory,
|
||||||
@ -211,7 +209,14 @@ export async function createBuiltinAgents(
|
|||||||
|
|
||||||
if (registeredKeys.length > 0) {
|
if (registeredKeys.length > 0) {
|
||||||
const memberList = registeredKeys.map((key) => `- "${key}"`).join("\n")
|
const memberList = registeredKeys.map((key) => `- "${key}"`).join("\n")
|
||||||
const councilTaskInstructions = `\n\n## Registered Council Members\n\nUse these as subagent_type in task calls:\n\n${memberList}`
|
let councilTaskInstructions = `\n\n## Registered Council Members\n\nUse these as subagent_type in task calls:\n\n${memberList}`
|
||||||
|
|
||||||
|
if (skippedMembers.length > 0) {
|
||||||
|
const skipDetails = skippedMembers.map((m) => `- **${m.name}**: ${m.reason}`).join("\n")
|
||||||
|
councilTaskInstructions += `\n\n> **Note**: Some configured council members were skipped:\n${skipDetails}`
|
||||||
|
log("[builtin-agents] Some council members were skipped during registration", { skippedMembers })
|
||||||
|
}
|
||||||
|
|
||||||
result["athena"] = {
|
result["athena"] = {
|
||||||
...result["athena"],
|
...result["athena"],
|
||||||
prompt: (result["athena"].prompt ?? "") + councilTaskInstructions,
|
prompt: (result["athena"].prompt ?? "") + councilTaskInstructions,
|
||||||
|
|||||||
@ -39,14 +39,15 @@ Each member requires \`model\` (\`"provider/model-id"\` format) and \`name\` (di
|
|||||||
After informing the user, **end your turn**. Do NOT try to work around this by using generic agents, the council-member agent, or any other fallback.`
|
After informing the user, **end your turn**. Do NOT try to work around this by using generic agents, the council-member agent, or any other fallback.`
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replaces Athena's prompt with a guard that tells the user to configure council members.
|
* Replaces Athena's orchestration prompt with a guard that tells the user to configure council members.
|
||||||
|
* The original prompt is discarded to avoid contradictory instructions.
|
||||||
* Used when Athena is registered but no valid council config exists.
|
* Used when Athena is registered but no valid council config exists.
|
||||||
*/
|
*/
|
||||||
export function appendMissingCouncilPrompt(
|
export function appendMissingCouncilPrompt(
|
||||||
athenaConfig: AgentConfig,
|
athenaConfig: AgentConfig,
|
||||||
skippedMembers?: Array<{ name: string; reason: string }>,
|
skippedMembers?: Array<{ name: string; reason: string }>,
|
||||||
): AgentConfig {
|
): AgentConfig {
|
||||||
let prompt = (athenaConfig.prompt ?? "") + MISSING_COUNCIL_PROMPT
|
let prompt = MISSING_COUNCIL_PROMPT
|
||||||
|
|
||||||
if (skippedMembers && skippedMembers.length > 0) {
|
if (skippedMembers && skippedMembers.length > 0) {
|
||||||
const skipDetails = skippedMembers.map((m) => `- **${m.name}**: ${m.reason}`).join("\n")
|
const skipDetails = skippedMembers.map((m) => `- **${m.name}**: ${m.reason}`).join("\n")
|
||||||
|
|||||||
@ -27,6 +27,7 @@ export function registerCouncilMemberAgents(
|
|||||||
const agents: Record<string, AgentConfig> = {}
|
const agents: Record<string, AgentConfig> = {}
|
||||||
const registeredKeys: string[] = []
|
const registeredKeys: string[] = []
|
||||||
const skippedMembers: SkippedMember[] = []
|
const skippedMembers: SkippedMember[] = []
|
||||||
|
const registeredNamesLower = new Set<string>()
|
||||||
|
|
||||||
for (const member of councilConfig.members) {
|
for (const member of councilConfig.members) {
|
||||||
const parsed = parseModelString(member.model)
|
const parsed = parseModelString(member.model)
|
||||||
@ -40,16 +41,16 @@ export function registerCouncilMemberAgents(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const key = getCouncilMemberAgentKey(member)
|
const key = getCouncilMemberAgentKey(member)
|
||||||
|
const nameLower = member.name.toLowerCase()
|
||||||
|
|
||||||
if (agents[key]) {
|
if (registeredNamesLower.has(nameLower)) {
|
||||||
skippedMembers.push({
|
skippedMembers.push({
|
||||||
name: member.name,
|
name: member.name,
|
||||||
reason: `Duplicate name: '${member.name}' already registered`,
|
reason: `Duplicate name: '${member.name}' already registered (case-insensitive match)`,
|
||||||
})
|
})
|
||||||
log("[council-member-agents] Skipping duplicate council member name", {
|
log("[council-member-agents] Skipping duplicate council member name", {
|
||||||
name: member.name,
|
name: member.name,
|
||||||
model: member.model,
|
model: member.model,
|
||||||
existingModel: agents[key].model ?? "unknown",
|
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -66,6 +67,7 @@ export function registerCouncilMemberAgents(
|
|||||||
}
|
}
|
||||||
|
|
||||||
registeredKeys.push(key)
|
registeredKeys.push(key)
|
||||||
|
registeredNamesLower.add(nameLower)
|
||||||
|
|
||||||
log("[council-member-agents] Registered council member agent", {
|
log("[council-member-agents] Registered council member agent", {
|
||||||
key,
|
key,
|
||||||
|
|||||||
@ -43,7 +43,7 @@ describe("CouncilMemberSchema", () => {
|
|||||||
|
|
||||||
test("rejects model string without provider/model separator", () => {
|
test("rejects model string without provider/model separator", () => {
|
||||||
//#given
|
//#given
|
||||||
const config = { model: "invalid-model" }
|
const config = { model: "invalid-model", name: "test-member" }
|
||||||
|
|
||||||
//#when
|
//#when
|
||||||
const result = CouncilMemberSchema.safeParse(config)
|
const result = CouncilMemberSchema.safeParse(config)
|
||||||
@ -54,7 +54,7 @@ describe("CouncilMemberSchema", () => {
|
|||||||
|
|
||||||
test("rejects model string with empty provider", () => {
|
test("rejects model string with empty provider", () => {
|
||||||
//#given
|
//#given
|
||||||
const config = { model: "/gpt-5.3-codex" }
|
const config = { model: "/gpt-5.3-codex", name: "test-member" }
|
||||||
|
|
||||||
//#when
|
//#when
|
||||||
const result = CouncilMemberSchema.safeParse(config)
|
const result = CouncilMemberSchema.safeParse(config)
|
||||||
@ -65,7 +65,7 @@ describe("CouncilMemberSchema", () => {
|
|||||||
|
|
||||||
test("rejects model string with empty model ID", () => {
|
test("rejects model string with empty model ID", () => {
|
||||||
//#given
|
//#given
|
||||||
const config = { model: "openai/" }
|
const config = { model: "openai/", name: "test-member" }
|
||||||
|
|
||||||
//#when
|
//#when
|
||||||
const result = CouncilMemberSchema.safeParse(config)
|
const result = CouncilMemberSchema.safeParse(config)
|
||||||
@ -151,7 +151,7 @@ describe("CouncilMemberSchema", () => {
|
|||||||
|
|
||||||
test("rejects temperature below 0", () => {
|
test("rejects temperature below 0", () => {
|
||||||
//#given
|
//#given
|
||||||
const config = { model: "openai/gpt-5.3-codex", temperature: -0.1 }
|
const config = { model: "openai/gpt-5.3-codex", name: "test-member", temperature: -0.1 }
|
||||||
|
|
||||||
//#when
|
//#when
|
||||||
const result = CouncilMemberSchema.safeParse(config)
|
const result = CouncilMemberSchema.safeParse(config)
|
||||||
@ -162,7 +162,7 @@ describe("CouncilMemberSchema", () => {
|
|||||||
|
|
||||||
test("rejects temperature above 2", () => {
|
test("rejects temperature above 2", () => {
|
||||||
//#given
|
//#given
|
||||||
const config = { model: "openai/gpt-5.3-codex", temperature: 2.1 }
|
const config = { model: "openai/gpt-5.3-codex", name: "test-member", temperature: 2.1 }
|
||||||
|
|
||||||
//#when
|
//#when
|
||||||
const result = CouncilMemberSchema.safeParse(config)
|
const result = CouncilMemberSchema.safeParse(config)
|
||||||
@ -173,7 +173,7 @@ describe("CouncilMemberSchema", () => {
|
|||||||
|
|
||||||
test("rejects member config with unknown fields", () => {
|
test("rejects member config with unknown fields", () => {
|
||||||
//#given
|
//#given
|
||||||
const config = { model: "openai/gpt-5.3-codex", unknownField: true }
|
const config = { model: "openai/gpt-5.3-codex", name: "test-member", unknownField: true }
|
||||||
|
|
||||||
//#when
|
//#when
|
||||||
const result = CouncilMemberSchema.safeParse(config)
|
const result = CouncilMemberSchema.safeParse(config)
|
||||||
|
|||||||
@ -51,17 +51,34 @@ export function extractTextPartsFromMessageResponse(response: unknown): string {
|
|||||||
.join("\n")
|
.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
export function detectFallbackHandoffTarget(messageText: string): "atlas" | "prometheus" | undefined {
|
const HANDOFF_TARGETS = ["prometheus", "atlas"] as const
|
||||||
|
type HandoffTarget = (typeof HANDOFF_TARGETS)[number]
|
||||||
|
|
||||||
|
const HANDOFF_VERBS = [
|
||||||
|
"switching",
|
||||||
|
"handing\\s+off",
|
||||||
|
"delegating",
|
||||||
|
"routing",
|
||||||
|
"transferring",
|
||||||
|
"passing",
|
||||||
|
]
|
||||||
|
|
||||||
|
function buildHandoffPattern(target: string): RegExp {
|
||||||
|
const verbGroup = HANDOFF_VERBS.join("|")
|
||||||
|
return new RegExp(
|
||||||
|
`(?<!\\bnot\\s+)(?<!\\bdon'?t\\s+)(?<!\\bnever\\s+)(?:${verbGroup})\\s+(?:(?:control|this|it|work)\\s+)?to\\s+\\*{0,2}\\s*${target}\\b`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectFallbackHandoffTarget(messageText: string): HandoffTarget | undefined {
|
||||||
if (!messageText) return undefined
|
if (!messageText) return undefined
|
||||||
|
|
||||||
const normalized = messageText.toLowerCase()
|
const normalized = messageText.toLowerCase()
|
||||||
|
|
||||||
if (/switching\s+to\s+\*{0,2}\s*prometheus\b/.test(normalized) || /handing\s+off\s+to\s+\*{0,2}\s*prometheus\b/.test(normalized)) {
|
for (const target of HANDOFF_TARGETS) {
|
||||||
return "prometheus"
|
if (buildHandoffPattern(target).test(normalized)) {
|
||||||
}
|
return target
|
||||||
|
}
|
||||||
if (/switching\s+to\s+\*{0,2}\s*atlas\b/.test(normalized) || /handing\s+off\s+to\s+\*{0,2}\s*atlas\b/.test(normalized)) {
|
|
||||||
return "atlas"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined
|
return undefined
|
||||||
|
|||||||
@ -14,6 +14,15 @@ import {
|
|||||||
const processedFallbackMessages = new Set<string>()
|
const processedFallbackMessages = new Set<string>()
|
||||||
const MAX_PROCESSED_FALLBACK_MARKERS = 500
|
const MAX_PROCESSED_FALLBACK_MARKERS = 500
|
||||||
|
|
||||||
|
function clearFallbackMarkersForSession(sessionID: string): void {
|
||||||
|
clearPendingSwitchRuntime(sessionID)
|
||||||
|
for (const key of Array.from(processedFallbackMessages)) {
|
||||||
|
if (key.startsWith(`${sessionID}:`)) {
|
||||||
|
processedFallbackMessages.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getSessionIDFromStatusEvent(input: { event: { properties?: Record<string, unknown> } }): string | undefined {
|
function getSessionIDFromStatusEvent(input: { event: { properties?: Record<string, unknown> } }): string | undefined {
|
||||||
const props = input.event.properties as Record<string, unknown> | undefined
|
const props = input.event.properties as Record<string, unknown> | undefined
|
||||||
const fromProps = typeof props?.sessionID === "string" ? props.sessionID : undefined
|
const fromProps = typeof props?.sessionID === "string" ? props.sessionID : undefined
|
||||||
@ -46,12 +55,7 @@ export function createAgentSwitchHook(ctx: PluginInput) {
|
|||||||
const info = props?.info as Record<string, unknown> | undefined
|
const info = props?.info as Record<string, unknown> | undefined
|
||||||
const deletedSessionID = info?.id
|
const deletedSessionID = info?.id
|
||||||
if (typeof deletedSessionID === "string") {
|
if (typeof deletedSessionID === "string") {
|
||||||
clearPendingSwitchRuntime(deletedSessionID)
|
clearFallbackMarkersForSession(deletedSessionID)
|
||||||
for (const key of Array.from(processedFallbackMessages)) {
|
|
||||||
if (key.startsWith(`${deletedSessionID}:`)) {
|
|
||||||
processedFallbackMessages.delete(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -61,12 +65,7 @@ export function createAgentSwitchHook(ctx: PluginInput) {
|
|||||||
const info = props?.info as Record<string, unknown> | undefined
|
const info = props?.info as Record<string, unknown> | undefined
|
||||||
const erroredSessionID = info?.id ?? props?.sessionID
|
const erroredSessionID = info?.id ?? props?.sessionID
|
||||||
if (typeof erroredSessionID === "string") {
|
if (typeof erroredSessionID === "string") {
|
||||||
clearPendingSwitchRuntime(erroredSessionID)
|
clearFallbackMarkersForSession(erroredSessionID)
|
||||||
for (const key of Array.from(processedFallbackMessages)) {
|
|
||||||
if (key.startsWith(`${erroredSessionID}:`)) {
|
|
||||||
processedFallbackMessages.delete(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,7 +16,7 @@ describe("createToolExecuteBeforeHandler", () => {
|
|||||||
|
|
||||||
const backgroundManager = {
|
const backgroundManager = {
|
||||||
getTasksByParentSession: () => [
|
getTasksByParentSession: () => [
|
||||||
{ agent: "council-member", status: "running" },
|
{ agent: "Council: Claude Opus 4.6", status: "running" },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,7 +50,7 @@ describe("createToolExecuteBeforeHandler", () => {
|
|||||||
|
|
||||||
const backgroundManager = {
|
const backgroundManager = {
|
||||||
getTasksByParentSession: () => [
|
getTasksByParentSession: () => [
|
||||||
{ agent: "council-member", status: "pending" },
|
{ agent: "Council: GPT 5.2", status: "pending" },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,8 +84,8 @@ describe("createToolExecuteBeforeHandler", () => {
|
|||||||
|
|
||||||
const backgroundManager = {
|
const backgroundManager = {
|
||||||
getTasksByParentSession: () => [
|
getTasksByParentSession: () => [
|
||||||
{ agent: "council-member", status: "completed" },
|
{ agent: "Council: Claude Opus 4.6", status: "completed" },
|
||||||
{ agent: "council-member", status: "cancelled" },
|
{ agent: "Council: GPT 5.2", status: "cancelled" },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -28,7 +28,7 @@ export function createToolExecuteBeforeHandler(args: {
|
|||||||
|
|
||||||
const tasks = backgroundManager.getTasksByParentSession(sessionID)
|
const tasks = backgroundManager.getTasksByParentSession(sessionID)
|
||||||
return tasks.some((task) =>
|
return tasks.some((task) =>
|
||||||
(task.agent === "council-member" || task.agent.startsWith(COUNCIL_MEMBER_KEY_PREFIX)) &&
|
task.agent.startsWith(COUNCIL_MEMBER_KEY_PREFIX) &&
|
||||||
(task.status === "pending" || task.status === "running")
|
(task.status === "pending" || task.status === "running")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user