mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
fix(opencode2): support current plugin lifecycle and session storage
This commit is contained in:
@@ -1,10 +1,16 @@
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { opencodeDiscoveries } from './session-scanner-opencode-sources'
|
||||
import { opencode2Discoveries, opencodeDiscoveries } from './session-scanner-opencode-sources'
|
||||
|
||||
const { discoverOpenCodeSessionsMock, listOpenCodeDatabasesMock } = vi.hoisted(() => ({
|
||||
discoverOpenCodeSessionsMock: vi.fn(),
|
||||
listOpenCodeDatabasesMock: vi.fn()
|
||||
const { discoverOpenCodeSessionsMock, listOpenCodeDatabasesMock, listOpenCode2SessionsMock } =
|
||||
vi.hoisted(() => ({
|
||||
discoverOpenCodeSessionsMock: vi.fn(),
|
||||
listOpenCodeDatabasesMock: vi.fn(),
|
||||
listOpenCode2SessionsMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./session-scanner-opencode-sqlite-worker-spawn', () => ({
|
||||
listOpenCode2SqliteSessionsViaWorker: listOpenCode2SessionsMock
|
||||
}))
|
||||
|
||||
vi.mock('./session-scanner-opencode-sqlite-discovery', () => ({
|
||||
@@ -21,6 +27,19 @@ describe('opencodeDiscoveries', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('checks the shared database for v2 sessions as well as the beta databases', async () => {
|
||||
const dbPaths = [join('/data', 'opencode.db'), join('/data', 'opencode-next.db')]
|
||||
listOpenCode2SessionsMock.mockResolvedValue([])
|
||||
await Promise.all(opencode2Discoveries({ opencodeDbPaths: dbPaths }, [], 25, []))
|
||||
expect(listOpenCode2SessionsMock).toHaveBeenCalledWith({ dbPaths, limit: 25, issues: [] })
|
||||
await Promise.all(opencodeDiscoveries({ opencodeDbPaths: dbPaths }, [], 25, []))
|
||||
expect(discoverOpenCodeSessionsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dbPaths: [dbPaths[0]]
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('discovers local storage from the OpenCode XDG data directory', async () => {
|
||||
vi.stubEnv('XDG_DATA_HOME', '/xdg/data')
|
||||
vi.stubEnv('OPENCODE_CONFIG_DIR', '/opencode/config')
|
||||
|
||||
@@ -10,27 +10,6 @@ import { listOpenCode2SqliteSessionsViaWorker } from './session-scanner-opencode
|
||||
import { isOpenCodeV2DatabaseName } from '../../shared/opencode-database-name'
|
||||
import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types'
|
||||
|
||||
// Why: opencode2 (beta) stores sessions in channel-scoped DBs
|
||||
// (opencode-next.db / opencode-local.db) alongside the v1 opencode.db. Both
|
||||
// match the `opencode*.db` glob, so paths are split by basename here: the v1
|
||||
// discovery never sees v2 DBs (different, beta-unstable schema) and vice versa.
|
||||
|
||||
function splitDatabasePaths(dbPaths: readonly string[]): {
|
||||
v1Paths: string[]
|
||||
v2Paths: string[]
|
||||
} {
|
||||
const v1Paths: string[] = []
|
||||
const v2Paths: string[] = []
|
||||
for (const dbPath of dbPaths) {
|
||||
if (isOpenCodeV2DatabaseName(basename(dbPath))) {
|
||||
v2Paths.push(dbPath)
|
||||
} else {
|
||||
v1Paths.push(dbPath)
|
||||
}
|
||||
}
|
||||
return { v1Paths, v2Paths }
|
||||
}
|
||||
|
||||
export function opencodeDiscoveries(
|
||||
options: AiVaultScanOptions,
|
||||
wslHomeDirs: readonly string[],
|
||||
@@ -39,9 +18,8 @@ export function opencodeDiscoveries(
|
||||
): Promise<SessionFileDiscovery>[] {
|
||||
const storageDirs = opencodeStorageDirs(options, wslHomeDirs)
|
||||
return storageDirs.map(async (storageDir, index) => {
|
||||
const { v1Paths } = splitDatabasePaths(
|
||||
await opencodeDbPathsForSource(options, wslHomeDirs, storageDir, index, issues)
|
||||
)
|
||||
const dbPaths = await opencodeDbPathsForSource(options, wslHomeDirs, storageDir, index, issues)
|
||||
const v1Paths = dbPaths.filter((path) => !isOpenCodeV2DatabaseName(basename(path)))
|
||||
return discoverOpenCodeSessions({ storageDir, dbPaths: v1Paths, limitPerAgent: limit, issues })
|
||||
})
|
||||
}
|
||||
@@ -53,9 +31,8 @@ export function opencode2Discoveries(
|
||||
issues: AiVaultScanIssue[]
|
||||
): Promise<SessionFileDiscovery>[] {
|
||||
return opencodeStorageDirs(options, wslHomeDirs).map(async (storageDir, index) => {
|
||||
const { v2Paths } = splitDatabasePaths(
|
||||
await opencodeDbPathsForSource(options, wslHomeDirs, storageDir, index, issues)
|
||||
)
|
||||
// Current releases share opencode.db with v1; the worker checks for v2 tables.
|
||||
const v2Paths = await opencodeDbPathsForSource(options, wslHomeDirs, storageDir, index, issues)
|
||||
return v2Paths.length > 0
|
||||
? discoverOpenCode2Sessions(storageDir, v2Paths, limit, issues)
|
||||
: emptyOpenCode2Discovery(storageDir)
|
||||
@@ -80,8 +57,7 @@ async function opencodeDbPathsForSource(
|
||||
issues: AiVaultScanIssue[]
|
||||
): Promise<readonly string[]> {
|
||||
if (options.opencodeDbPaths) {
|
||||
const split = splitDatabasePaths(sourceIndex === 0 ? options.opencodeDbPaths : [])
|
||||
return [...split.v1Paths, ...split.v2Paths]
|
||||
return sourceIndex === 0 ? options.opencodeDbPaths : []
|
||||
}
|
||||
// Why: custom OpenCode storage roots still keep SQLite DBs in the parent data dir.
|
||||
if (sourceIndex === 0 && options.opencodeStorageDir) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import Database from '../sqlite/sync-database'
|
||||
import { buildOpenCodeSqliteCandidatePath } from './session-scanner-opencode-sqlite-paths'
|
||||
import { listOpenCode2SqliteSessions } from './session-scanner-opencode2-sqlite-list'
|
||||
import { parseOpenCode2SqliteSession } from './session-scanner-opencode2-sqlite'
|
||||
import { withFullFirstUserPromptCapture } from './session-scanner-first-user-prompt-capture'
|
||||
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
|
||||
|
||||
// Why: the opencode2 (beta) channel-scoped DB schema differs from v1 —
|
||||
@@ -209,7 +210,6 @@ describe('parseOpenCode2SqliteSession', () => {
|
||||
timeCreated: 1_777_634_000_500,
|
||||
data: JSON.stringify({
|
||||
id: 'msg_1',
|
||||
type: 'user',
|
||||
text: 'Add login flow',
|
||||
time: { created: 1_777_634_000_500 }
|
||||
})
|
||||
@@ -229,17 +229,20 @@ describe('parseOpenCode2SqliteSession', () => {
|
||||
})
|
||||
db.close()
|
||||
|
||||
const session = await parseOpenCode2SqliteSession({
|
||||
dbPath: path,
|
||||
sessionId: 'session_1',
|
||||
platform: 'darwin'
|
||||
})
|
||||
const session = await withFullFirstUserPromptCapture(() =>
|
||||
parseOpenCode2SqliteSession({
|
||||
dbPath: path,
|
||||
sessionId: 'session_1',
|
||||
platform: 'darwin'
|
||||
})
|
||||
)
|
||||
|
||||
expect(session).not.toBeNull()
|
||||
expect(session!.agent).toBe('opencode2')
|
||||
expect(session!.sessionId).toBe('session_1')
|
||||
expect(session!.filePath).toBe(path)
|
||||
expect(session!.title).toBe('Fix login')
|
||||
expect(session?.firstUserPrompt).toBe('Add login flow')
|
||||
expect(session!.cwd).toBe('/repo')
|
||||
expect(session!.model).toBe('glm-5.2')
|
||||
expect(session!.totalTokens).toBe(35)
|
||||
@@ -256,7 +259,9 @@ describe('parseOpenCode2SqliteSession', () => {
|
||||
timestamp: new Date(1_777_634_000_900).toISOString()
|
||||
}
|
||||
])
|
||||
expect(session!.resumeCommand).toBe("cd '/repo' && opencode2 --session 'session_1'")
|
||||
expect(session!.resumeCommand).toBe(
|
||||
"cd '/repo' && opencode2 --standalone --session 'session_1'"
|
||||
)
|
||||
})
|
||||
|
||||
it('extracts assistant text from content arrays and falls back to raw model ids', async () => {
|
||||
|
||||
@@ -205,7 +205,6 @@ function readFirstUserPromptFromDb(db: SyncDatabase, sessionId: string): string
|
||||
`SELECT data FROM ${OPENCODE2_MESSAGE_TABLE}
|
||||
WHERE session_id = ?
|
||||
AND type = 'user'
|
||||
AND data LIKE '%"type":"user"%'
|
||||
ORDER BY time_created ASC, seq ASC
|
||||
LIMIT 1`
|
||||
)
|
||||
|
||||
@@ -421,7 +421,7 @@ describe('scanAiVaultSessions', () => {
|
||||
"cd '/tmp/opencode' && opencode --session 'opencode-session'"
|
||||
)
|
||||
expect(commandByAgent.get('opencode2')).toBe(
|
||||
"cd '/tmp/opencode2' && opencode2 --session 'opencode2-session'"
|
||||
"cd '/tmp/opencode2' && opencode2 --standalone --session 'opencode2-session'"
|
||||
)
|
||||
expect(commandByAgent.get('grok')).toBe("cd '/tmp/grok' && grok --resume 'grok-session'")
|
||||
expect(commandByAgent.get('hermes')).toBe(
|
||||
|
||||
@@ -30,7 +30,11 @@ describe('OpenCode status plugin module contract', () => {
|
||||
dispose?: () => Promise<void>
|
||||
}
|
||||
type PluginModule = {
|
||||
default?: { id?: unknown; server?: (ctx: unknown) => Promise<PluginHooks> }
|
||||
default?: {
|
||||
id?: unknown
|
||||
server?: (ctx: unknown) => Promise<PluginHooks>
|
||||
setup?: (ctx: unknown) => Promise<() => Promise<void>>
|
||||
}
|
||||
OrcaOpenCodeStatusPlugin?: (ctx: unknown) => Promise<PluginHooks>
|
||||
}
|
||||
|
||||
@@ -158,6 +162,54 @@ describe('OpenCode status plugin module contract', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('subscribes through the OpenCode 2 setup API and disposes its registrations', async () => {
|
||||
process.env.ORCA_PANE_KEY = 'tab-1:leaf-1'
|
||||
const posts: unknown[] = []
|
||||
globalThis.fetch = vi.fn(async (_input, init) => {
|
||||
posts.push(JSON.parse(String(init?.body)))
|
||||
return new Response('{}', { status: 200 })
|
||||
})
|
||||
const dispose = vi.fn()
|
||||
let subscriptionSignal: AbortSignal | undefined
|
||||
const module = await loadPluginModule(_internals.getOpenCode2PluginSource())
|
||||
expect(module.default?.setup).toBeTypeOf('function')
|
||||
const cleanup = await module.default?.setup?.({
|
||||
session: {
|
||||
get: async ({ sessionID }: { sessionID: string }) => ({ data: { id: sessionID } }),
|
||||
hook: async () => ({ dispose })
|
||||
},
|
||||
event: {
|
||||
subscribe: async function* ({ signal }: { signal: AbortSignal }) {
|
||||
subscriptionSignal = signal
|
||||
yield { type: 'session.created', data: { sessionID: 'ses_root' } }
|
||||
yield {
|
||||
type: 'session.execution.started',
|
||||
data: { sessionID: 'ses_root' }
|
||||
}
|
||||
yield {
|
||||
type: 'session.execution.succeeded',
|
||||
data: { sessionID: 'ses_root' }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
expect(posts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({ hook_event_name: 'SessionBusy' })
|
||||
}),
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({ hook_event_name: 'SessionIdle' })
|
||||
})
|
||||
])
|
||||
)
|
||||
})
|
||||
await cleanup?.()
|
||||
expect(dispose).toHaveBeenCalledOnce()
|
||||
expect(subscriptionSignal?.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('turns OpenCode 2 step lifecycle events into working and done hooks', async () => {
|
||||
process.env.ORCA_PANE_KEY = 'tab-1:leaf-1'
|
||||
const posts: { body: Record<string, unknown> }[] = []
|
||||
|
||||
@@ -1,30 +1,14 @@
|
||||
import {
|
||||
getOpenCode2SetupSource,
|
||||
getOpenCode2EventNormalizationSource
|
||||
} from '../opencode2/status-plugin-setup-source'
|
||||
|
||||
export function getStatusPluginFactorySource(options: {
|
||||
emitSessionStart: boolean
|
||||
emitNextEvents?: boolean
|
||||
}): string[] {
|
||||
return [
|
||||
...(options.emitNextEvents
|
||||
? [
|
||||
'',
|
||||
'function normalizeNextLifecycleEvent(event) {',
|
||||
' if (!event || typeof event.type !== "string") return event;',
|
||||
' const properties = event.properties || {};',
|
||||
' if (event.type === "permission.v2.asked") return { ...event, type: "permission.asked", properties: { ...properties, id: properties.id, permission: properties.action, patterns: properties.resources } };',
|
||||
' if (event.type === "permission.v2.replied") return { ...event, type: "permission.replied", properties: { ...properties } };',
|
||||
' if (event.type === "question.v2.asked") return { ...event, type: "question.asked", properties: { ...properties } };',
|
||||
' if (event.type === "question.v2.replied") return { ...event, type: "question.replied", properties: { ...properties } };',
|
||||
' if (event.type === "question.v2.rejected") return { ...event, type: "question.rejected", properties: { ...properties } };',
|
||||
' if (event.type === "session.next.step.started" || event.type === "session.next.tool.called" || event.type === "session.next.tool.progress" || event.type === "session.next.retried") {',
|
||||
' return { ...event, type: "session.status", properties: { ...properties, status: { type: "busy" } } };',
|
||||
' }',
|
||||
' if (event.type === "session.next.step.ended" || event.type === "session.next.step.failed") {',
|
||||
' return { ...event, type: "session.status", properties: { ...properties, status: { type: "idle" } } };',
|
||||
' }',
|
||||
' return event;',
|
||||
'}',
|
||||
''
|
||||
]
|
||||
: []),
|
||||
...(options.emitNextEvents ? getOpenCode2EventNormalizationSource() : []),
|
||||
'// Why: accept the factory argument as an optional opaque parameter instead',
|
||||
'// of destructuring (`async ({ client }) => …`). OpenCode can invoke the',
|
||||
'// plugin factory with undefined during startup, which makes the',
|
||||
@@ -290,10 +274,17 @@ export function getStatusPluginFactorySource(options: {
|
||||
' },',
|
||||
' };',
|
||||
'};',
|
||||
'// Why: OpenCode also resolves plugins through the module default export, which must expose server(); keep the named factory too.',
|
||||
...(options.emitNextEvents ? getOpenCode2SetupSource() : []),
|
||||
'',
|
||||
'// Why: OpenCode also resolves plugins through the module default export, and that',
|
||||
'// loader rejects the module unless the default exposes `server()` ("must default',
|
||||
'// export an object with server()"). `setup()` does not satisfy it. Keep the named',
|
||||
'// export so the factory-based loader still finds the same instance.',
|
||||
'export default {',
|
||||
' id: "orca-opencode-status",',
|
||||
' server: OrcaOpenCodeStatusPlugin,',
|
||||
'};'
|
||||
...(options.emitNextEvents ? [' setup: setupOpenCode2Status,'] : []),
|
||||
'};',
|
||||
''
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
// OpenCode 2 uses the same supported server-plugin loader as OpenCode. The
|
||||
// variant is kept under the opencode module so config overlays and plugin
|
||||
// filenames stay isolated from the legacy agent.
|
||||
// Share overlay management while keeping the v2 plugin and config isolated.
|
||||
export { openCode2HookService as openCode2ConfigHookService } from '../opencode/hook-service'
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
export function getOpenCode2SetupSource(): string[] {
|
||||
return String.raw`
|
||||
async function setupOpenCode2Status(ctx) {
|
||||
const controller = new AbortController();
|
||||
const client = { session: { get: (input, options) => ctx.session.get(input, options) } };
|
||||
const hooks = await OrcaOpenCodeStatusPlugin({ client });
|
||||
const promptRegistration = await ctx.session.hook("prompt", async (properties) => {
|
||||
await hooks.event({ event: { type: "session.next.prompt.admitted", properties } });
|
||||
});
|
||||
const consume = async () => {
|
||||
for await (const input of ctx.event.subscribe({ signal: controller.signal })) {
|
||||
if (controller.signal.aborted) break;
|
||||
let type = input.type;
|
||||
let properties = input.data;
|
||||
if (type === "session.created") {
|
||||
properties = { info: { ...properties, id: properties.sessionID } };
|
||||
} else if (type === "session.execution.started") {
|
||||
type = "session.status";
|
||||
properties = { ...properties, status: { type: "busy" } };
|
||||
} else if (type === "session.execution.succeeded" || type === "session.execution.failed" || type === "session.execution.interrupted") {
|
||||
type = "session.status";
|
||||
properties = { ...properties, status: { type: "idle" } };
|
||||
} else if (type === "permission.asked") {
|
||||
properties = { ...properties, permission: properties.action, patterns: properties.resources };
|
||||
} else if (type === "form.created") {
|
||||
type = "question.asked";
|
||||
const form = properties.form;
|
||||
properties = {
|
||||
...form,
|
||||
questions: form.fields.map((field) => ({
|
||||
header: field.title || form.title,
|
||||
question: field.description || field.title || form.title,
|
||||
options: (field.options || []).map((option) => ({ label: option.label || option.value, description: option.description || "" })),
|
||||
multiple: field.type === "multiselect",
|
||||
})),
|
||||
};
|
||||
} else if (type === "form.replied" || type === "form.cancelled") {
|
||||
type = type === "form.replied" ? "question.replied" : "question.rejected";
|
||||
properties = { ...properties, requestID: properties.id };
|
||||
} else if (type === "session.text.started" || type === "session.text.delta" || type === "session.text.ended") {
|
||||
type = type.replace("session.", "session.next.");
|
||||
}
|
||||
await hooks.event({ event: { type, properties } });
|
||||
}
|
||||
};
|
||||
const consuming = consume().catch((error) => {
|
||||
if (!controller.signal.aborted) console.warn("[orca-hook] event subscription failed:", error.message);
|
||||
});
|
||||
return async () => {
|
||||
controller.abort();
|
||||
await promptRegistration.dispose();
|
||||
await consuming;
|
||||
await hooks.dispose();
|
||||
};
|
||||
}
|
||||
`.split('\n')
|
||||
}
|
||||
|
||||
export function getOpenCode2EventNormalizationSource(): string[] {
|
||||
return [
|
||||
'',
|
||||
'function normalizeNextLifecycleEvent(event) {',
|
||||
' if (!event || typeof event.type !== "string") return event;',
|
||||
' const properties = event.properties || {};',
|
||||
' if (event.type === "permission.v2.asked") return { ...event, type: "permission.asked", properties: { ...properties, id: properties.id, permission: properties.action, patterns: properties.resources } };',
|
||||
' if (event.type === "permission.v2.replied") return { ...event, type: "permission.replied", properties: { ...properties } };',
|
||||
' if (event.type === "question.v2.asked") return { ...event, type: "question.asked", properties: { ...properties } };',
|
||||
' if (event.type === "question.v2.replied") return { ...event, type: "question.replied", properties: { ...properties } };',
|
||||
' if (event.type === "question.v2.rejected") return { ...event, type: "question.rejected", properties: { ...properties } };',
|
||||
' if (event.type === "session.next.step.started" || event.type === "session.next.tool.called" || event.type === "session.next.tool.progress" || event.type === "session.next.retried") {',
|
||||
' return { ...event, type: "session.status", properties: { ...properties, status: { type: "busy" } } };',
|
||||
' }',
|
||||
' if (event.type === "session.next.step.ended" || event.type === "session.next.step.failed") {',
|
||||
' return { ...event, type: "session.status", properties: { ...properties, status: { type: "idle" } } };',
|
||||
' }',
|
||||
' return event;',
|
||||
'}',
|
||||
''
|
||||
]
|
||||
}
|
||||
@@ -263,7 +263,9 @@ export function getAgentResumeArgv(
|
||||
case 'opencode':
|
||||
return providerSession.key === 'session_id' ? ['opencode', '--session', id] : null
|
||||
case 'opencode2':
|
||||
return providerSession.key === 'session_id' ? ['opencode2', '--session', id] : null
|
||||
return providerSession.key === 'session_id'
|
||||
? ['opencode2', '--standalone', '--session', id]
|
||||
: null
|
||||
case 'pi':
|
||||
return providerSession.key === 'session_id' && providerSession.transcriptPath
|
||||
? ['pi', '--session', providerSession.transcriptPath]
|
||||
|
||||
@@ -200,8 +200,9 @@ function buildAgentResumeInvocation(
|
||||
return `${baseCommand} resume ${sessionArg}`
|
||||
case 'rovo':
|
||||
return `${baseCommand} rovodev run --restore ${sessionArg}`
|
||||
case 'opencode':
|
||||
case 'opencode2':
|
||||
return `${baseCommand} --standalone --session ${sessionArg}`
|
||||
case 'opencode':
|
||||
case 'pi':
|
||||
// Why: Kimi Code resumes with `kimi --session <id>` (alias `-S`). Sessions
|
||||
// are work-dir-scoped, so the cwd prefix from buildAiVaultResumeCommand is
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
// Why: the opencode2 beta stores sessions in a channel-scoped SQLite DB
|
||||
// (opencode-next.db on the default channel, opencode-local.db on the dev
|
||||
// channel) while opencode v1 uses opencode.db plus stale sibling copies. Both
|
||||
// match the `opencode*.db` glob used by Orca's v1 scanners, so callers must
|
||||
// classify before parsing: the v2 schema (session_v2/session_message) is
|
||||
// explicitly unstable in beta and must never reach the v1 parsers.
|
||||
// Beta-only database names; current v2 releases share opencode.db with v1.
|
||||
export const OPENCODE_V2_DATABASE_NAME_RE = /^opencode-(?:next|local)\.db$/i
|
||||
|
||||
export function isOpenCodeV2DatabaseName(name: string): boolean {
|
||||
|
||||
@@ -132,7 +132,8 @@ const TUI_AGENT_CONFIG_SOURCE: Record<TuiAgent, TuiAgentConfigSource> = {
|
||||
// Its @opentui composer keeps the same cursor-gated paste signal.
|
||||
opencode2: {
|
||||
detectCmd: 'opencode2',
|
||||
launchCmd: 'opencode2',
|
||||
// The private server inherits this pane's hook endpoint and identity.
|
||||
launchCmd: 'opencode2 --standalone',
|
||||
expectedProcess: 'opencode2',
|
||||
promptInjectionMode: 'flag-prompt',
|
||||
draftPasteReadySignal: 'render-cursor-after-bracketed-paste'
|
||||
|
||||
Reference in New Issue
Block a user