feat(web): add a web render-intent card for web_search and web_fetch results
web_search and web_fetch returned only model-facing text, whose markdown source
list is lossy (title-or-hostname label, snippet and date concatenated), so a
client could not recover the structured sources. Add a card:'web' result view
with a kind discriminant ('search' carrying structured sources + answer +
truncated, 'fetch' carrying url + statusCode + truncated), projected through
each tool's output.presentationMeta and read back in presentResult. A UI
without the web card falls back to content; the TUI is unchanged. The web
consumer is a follow-up.
This commit is contained in:
@@ -9,7 +9,7 @@ import type { Context } from 'cordis'
|
||||
import TurndownService from 'turndown'
|
||||
import { gfm } from '@joplin/turndown-plugin-gfm'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView, JsonValue, ToolResult, WebFetchResultView } from '@deepseek-ai/dsh-tools'
|
||||
import type { WebFetchBody, WebFetchResult } from '@deepseek-ai/dsh-web'
|
||||
import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
@@ -278,6 +278,78 @@ export function presentFetchCall(args: { url: string }): GenericCallView {
|
||||
return { card: 'generic', title: args.url, kind: 'fetch', rawInput: args.url }
|
||||
}
|
||||
|
||||
/**
|
||||
* The `web_fetch` tool's private `tool/result` `meta` payload: the fetch summary
|
||||
* a UI cannot recover from the model-facing render text without reparsing its
|
||||
* header line. Attached opaquely (as `JsonValue`) on the tool result and
|
||||
* persisted with the session log, so `presentResult` reproduces the fetch card
|
||||
* on replay. The body itself is already markdown in the result content, so it is
|
||||
* not duplicated here.
|
||||
*/
|
||||
export interface WebFetchMeta {
|
||||
/** The final URL after allowed redirects. */
|
||||
url: string
|
||||
/** HTTP status code of the fetched response. */
|
||||
statusCode: number
|
||||
/** True when the provider or the output cap cut the content. */
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/** The `web_fetch` canonical output value projected into presentation meta. */
|
||||
type WebFetchValue = {
|
||||
url: string
|
||||
statusCode: number
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a validated `web_fetch` output value into its replayable presentation
|
||||
* meta ({@link WebFetchMeta} as opaque JSON).
|
||||
*
|
||||
* @param value - the canonical `web_fetch` output value.
|
||||
* @returns the URL, status code, and truncation flag.
|
||||
*/
|
||||
export function fetchMetaFromValue(value: WebFetchValue): JsonValue {
|
||||
return { url: value.url, statusCode: value.statusCode, truncated: value.truncated }
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow opaque live or replayed result metadata to a {@link WebFetchMeta}.
|
||||
* Malformed metadata returns `undefined` so presentation can fall back to the
|
||||
* generic card instead of throwing during replay.
|
||||
*
|
||||
* @param meta - result metadata.
|
||||
* @returns the validated fetch meta, or `undefined` for absent or malformed data.
|
||||
*/
|
||||
export function fetchMetaFromResult(meta: unknown): WebFetchMeta | undefined {
|
||||
if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined
|
||||
const { url, statusCode, truncated } = meta as Record<string, unknown>
|
||||
if (typeof url !== 'string' || typeof statusCode !== 'number' || typeof truncated !== 'boolean') return undefined
|
||||
return { url, statusCode, truncated }
|
||||
}
|
||||
|
||||
/**
|
||||
* Completed-call presentation: a `web` fetch card carrying the retrieval summary
|
||||
* from `meta` alongside the already-markdown body as fallback content.
|
||||
*
|
||||
* @param result - the final model-facing tool result; `meta` carries the summary.
|
||||
* @returns the fetch result view, or `undefined` (generic card) on failure or
|
||||
* malformed meta.
|
||||
*/
|
||||
export function presentFetchResult(result: ToolResult): WebFetchResultView | undefined {
|
||||
if (result.isError) return undefined
|
||||
const meta = fetchMetaFromResult(result.meta)
|
||||
if (meta === undefined) return undefined
|
||||
return {
|
||||
card: 'web',
|
||||
kind: 'fetch',
|
||||
url: meta.url,
|
||||
statusCode: meta.statusCode,
|
||||
truncated: meta.truncated,
|
||||
content: result.content,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the `web_fetch` tool and its system-prompt guidance.
|
||||
*
|
||||
@@ -333,6 +405,7 @@ export function applyWebFetchTool(ctx: Context, timeoutMs: number, maxOutputChar
|
||||
},
|
||||
},
|
||||
render: (_args, value) => [{ type: 'text', text: formatFetchOutput(value, maxOutputChars) }],
|
||||
presentationMeta: (_args, value) => fetchMetaFromValue(value),
|
||||
},
|
||||
timeoutMs,
|
||||
// Provider reads do not mutate parent-agent state.
|
||||
@@ -351,5 +424,6 @@ export function applyWebFetchTool(ctx: Context, timeoutMs: number, maxOutputChar
|
||||
}
|
||||
},
|
||||
presentCall: presentFetchCall,
|
||||
presentResult: (_args, result) => presentFetchResult(result),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -12,8 +12,10 @@ import type {} from '@deepseek-ai/dsh-web'
|
||||
import { applyWebSearchTool, WEB_SEARCH_MAX_RESULTS } from './search.ts'
|
||||
import { applyWebFetchTool } from './fetch.ts'
|
||||
|
||||
export { WEB_SEARCH_MAX_RESULTS, applyWebSearchTool, formatSearchOutput, parseSearchArgs, presentSearchCall } from './search.ts'
|
||||
export { applyWebFetchTool, formatFetchOutput, parseFetchArgs, presentFetchCall } from './fetch.ts'
|
||||
export { WEB_SEARCH_MAX_RESULTS, applyWebSearchTool, formatSearchOutput, parseSearchArgs, presentSearchCall, presentSearchResult, searchMetaFromValue, searchMetaFromResult } from './search.ts'
|
||||
export type { WebSearchMeta } from './search.ts'
|
||||
export { applyWebFetchTool, formatFetchOutput, parseFetchArgs, presentFetchCall, presentFetchResult, fetchMetaFromValue, fetchMetaFromResult } from './fetch.ts'
|
||||
export type { WebFetchMeta } from './fetch.ts'
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'tool-web'
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView, JsonValue, ToolResult, WebSearchResultView, WebSource } from '@deepseek-ai/dsh-tools'
|
||||
import type { WebSearchResult } from '@deepseek-ai/dsh-web'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
@@ -84,6 +84,106 @@ export function presentSearchCall(args: { query: string }): GenericCallView {
|
||||
return { card: 'generic', title: args.query, kind: 'search', rawInput: args.query }
|
||||
}
|
||||
|
||||
/**
|
||||
* The `web_search` tool's private `tool/result` `meta` payload: the structured
|
||||
* sources, the optional provider answer, and the truncation flag. Attached
|
||||
* opaquely (as `JsonValue`) on the tool result and persisted with the session
|
||||
* log, so `presentResult` reproduces the search card on replay. The render text
|
||||
* is lossy — its markdown source list collapses each source's title, snippet,
|
||||
* and date into one free-text line labelled by title OR hostname — so reparsing
|
||||
* that text cannot recover the per-source fields; this projection is the only
|
||||
* faithful route to them.
|
||||
*/
|
||||
export interface WebSearchMeta {
|
||||
/** The faithful structured sources, in result order. */
|
||||
sources: WebSource[]
|
||||
/** True when the tool cut the source list to its result cap. */
|
||||
truncated: boolean
|
||||
/** The provider-generated answer text, when any. */
|
||||
answer?: string
|
||||
}
|
||||
|
||||
/** The `web_search` canonical output value projected into presentation meta. */
|
||||
type WebSearchValue = {
|
||||
content?: string
|
||||
sources: readonly WebSource[]
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a validated `web_search` output value into its replayable
|
||||
* presentation meta ({@link WebSearchMeta} as opaque JSON).
|
||||
*
|
||||
* @param value - the canonical `web_search` output value.
|
||||
* @returns the structured sources, the truncation flag, and the answer when present.
|
||||
*/
|
||||
export function searchMetaFromValue(value: WebSearchValue): JsonValue {
|
||||
return {
|
||||
sources: value.sources.map(source => ({
|
||||
url: source.url,
|
||||
...source.title !== undefined ? { title: source.title } : {},
|
||||
...source.snippet !== undefined ? { snippet: source.snippet } : {},
|
||||
...source.publishedAt !== undefined ? { publishedAt: source.publishedAt } : {},
|
||||
})),
|
||||
truncated: value.truncated,
|
||||
...value.content !== undefined ? { answer: value.content } : {},
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether `value` is a valid {@link WebSource} (defensive narrowing from opaque `meta`). */
|
||||
function isWebSource(value: unknown): value is WebSource {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
||||
const { url, title, snippet, publishedAt } = value as Record<string, unknown>
|
||||
return typeof url === 'string'
|
||||
&& (title === undefined || typeof title === 'string')
|
||||
&& (snippet === undefined || typeof snippet === 'string')
|
||||
&& (publishedAt === undefined || typeof publishedAt === 'string')
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow opaque live or replayed result metadata to a {@link WebSearchMeta}.
|
||||
* Malformed metadata returns `undefined` so presentation can fall back to the
|
||||
* generic card instead of throwing during replay.
|
||||
*
|
||||
* @param meta - result metadata.
|
||||
* @returns the validated search meta, or `undefined` for absent or malformed data.
|
||||
*/
|
||||
export function searchMetaFromResult(meta: unknown): WebSearchMeta | undefined {
|
||||
if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined
|
||||
const { sources, truncated, answer } = meta as Record<string, unknown>
|
||||
if (!Array.isArray(sources) || !sources.every(isWebSource)) return undefined
|
||||
if (typeof truncated !== 'boolean') return undefined
|
||||
if (answer !== undefined && typeof answer !== 'string') return undefined
|
||||
return {
|
||||
sources,
|
||||
truncated,
|
||||
...answer !== undefined ? { answer } : {},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Completed-call presentation: a `web` search card carrying the faithful
|
||||
* structured sources from `meta` alongside the model-facing text as fallback
|
||||
* content.
|
||||
*
|
||||
* @param result - the final model-facing tool result; `meta` carries the sources.
|
||||
* @returns the search result view, or `undefined` (generic card) on failure or
|
||||
* malformed meta.
|
||||
*/
|
||||
export function presentSearchResult(result: ToolResult): WebSearchResultView | undefined {
|
||||
if (result.isError) return undefined
|
||||
const meta = searchMetaFromResult(result.meta)
|
||||
if (meta === undefined) return undefined
|
||||
return {
|
||||
card: 'web',
|
||||
kind: 'search',
|
||||
sources: meta.sources,
|
||||
truncated: meta.truncated,
|
||||
...meta.answer !== undefined ? { answer: meta.answer } : {},
|
||||
content: result.content,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the `web_search` tool and its system-prompt guidance.
|
||||
*
|
||||
@@ -131,6 +231,7 @@ export function applyWebSearchTool(ctx: Context, maxResults: number, timeoutMs:
|
||||
},
|
||||
},
|
||||
render: (_args, value) => [{ type: 'text', text: formatSearchOutput(value) }],
|
||||
presentationMeta: (_args, value) => searchMetaFromValue(value),
|
||||
},
|
||||
timeoutMs,
|
||||
// Provider reads do not mutate parent-agent state.
|
||||
@@ -153,5 +254,6 @@ export function applyWebSearchTool(ctx: Context, maxResults: number, timeoutMs:
|
||||
}
|
||||
},
|
||||
presentCall: presentSearchCall,
|
||||
presentResult: (_args, result) => presentSearchResult(result),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -14,8 +14,16 @@ import {
|
||||
parseFetchArgs,
|
||||
presentSearchCall,
|
||||
presentFetchCall,
|
||||
presentSearchResult,
|
||||
presentFetchResult,
|
||||
searchMetaFromValue,
|
||||
searchMetaFromResult,
|
||||
fetchMetaFromValue,
|
||||
fetchMetaFromResult,
|
||||
WEB_SEARCH_MAX_RESULTS,
|
||||
} from '@deepseek-ai/dsh-tool-web'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { ToolResult } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
@@ -91,6 +99,96 @@ describe('search formatting', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/** Build a completed non-error tool result with the given meta and text content. */
|
||||
function toolResult(meta: unknown, text = 'body', isError = false): ToolResult {
|
||||
const content: ContentBlock[] = [{ type: 'text', text }]
|
||||
return { content, isError, ...meta !== undefined ? { meta: meta as never } : {} }
|
||||
}
|
||||
|
||||
describe('web_search presentation meta and result view', () => {
|
||||
it('projects sources, answer, and truncation into meta, omitting absent optional fields', () => {
|
||||
const meta = searchMetaFromValue({
|
||||
content: 'an answer', truncated: true,
|
||||
sources: [
|
||||
{ url: 'https://a.test/x', title: 'A', snippet: 'about a', publishedAt: '2026-01-01' },
|
||||
{ url: 'https://b.test/y' },
|
||||
],
|
||||
})
|
||||
expect(meta).toEqual({
|
||||
answer: 'an answer',
|
||||
truncated: true,
|
||||
sources: [
|
||||
{ url: 'https://a.test/x', title: 'A', snippet: 'about a', publishedAt: '2026-01-01' },
|
||||
{ url: 'https://b.test/y' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('omits answer from meta when the provider returned none', () => {
|
||||
const meta = searchMetaFromValue({ truncated: false, sources: [{ url: 'https://a.test' }] })
|
||||
expect(meta).toEqual({ truncated: false, sources: [{ url: 'https://a.test' }] })
|
||||
})
|
||||
|
||||
it('round-trips projected meta back to a typed search meta', () => {
|
||||
const value = {
|
||||
content: 'ans', truncated: false,
|
||||
sources: [{ url: 'https://a.test', title: 'A', snippet: 's', publishedAt: '2026-01-01' }],
|
||||
}
|
||||
expect(searchMetaFromResult(searchMetaFromValue(value))).toEqual({
|
||||
answer: 'ans', truncated: false,
|
||||
sources: [{ url: 'https://a.test', title: 'A', snippet: 's', publishedAt: '2026-01-01' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('presents a completed search as a web/search card carrying the structured sources and fallback content', () => {
|
||||
const meta = searchMetaFromValue({
|
||||
content: 'an answer', truncated: true,
|
||||
sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }],
|
||||
})
|
||||
expect(presentSearchResult(toolResult(meta, 'rendered'))).toEqual({
|
||||
card: 'web',
|
||||
kind: 'search',
|
||||
answer: 'an answer',
|
||||
truncated: true,
|
||||
sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }],
|
||||
content: [{ type: 'text', text: 'rendered' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('omits the answer from the view when meta carries none', () => {
|
||||
const meta = searchMetaFromValue({ truncated: false, sources: [{ url: 'https://a.test' }] })
|
||||
const view = presentSearchResult(toolResult(meta))
|
||||
expect(view).toBeDefined()
|
||||
expect(view && 'answer' in view).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to the generic card on an error result', () => {
|
||||
const meta = searchMetaFromValue({ truncated: false, sources: [{ url: 'https://a.test' }] })
|
||||
expect(presentSearchResult(toolResult(meta, 'body', true))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to the generic card on absent or malformed meta', () => {
|
||||
expect(presentSearchResult(toolResult(undefined))).toBeUndefined()
|
||||
expect(searchMetaFromResult(undefined)).toBeUndefined()
|
||||
expect(searchMetaFromResult(null)).toBeUndefined()
|
||||
expect(searchMetaFromResult('nope')).toBeUndefined()
|
||||
expect(searchMetaFromResult([])).toBeUndefined()
|
||||
expect(searchMetaFromResult({})).toBeUndefined()
|
||||
expect(searchMetaFromResult({ sources: 'x', truncated: false })).toBeUndefined()
|
||||
expect(searchMetaFromResult({ sources: [], truncated: 'no' })).toBeUndefined()
|
||||
expect(searchMetaFromResult({ sources: [], truncated: false, answer: 1 })).toBeUndefined()
|
||||
expect(searchMetaFromResult({ sources: [null], truncated: false })).toBeUndefined()
|
||||
expect(searchMetaFromResult({ sources: [{ url: 1 }], truncated: false })).toBeUndefined()
|
||||
expect(searchMetaFromResult({ sources: [{ url: 'u', title: 2 }], truncated: false })).toBeUndefined()
|
||||
expect(searchMetaFromResult({ sources: [{ url: 'u', snippet: 2 }], truncated: false })).toBeUndefined()
|
||||
expect(searchMetaFromResult({ sources: [{ url: 'u', publishedAt: 2 }], truncated: false })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('accepts an empty source list as valid meta', () => {
|
||||
expect(searchMetaFromResult({ sources: [], truncated: false })).toEqual({ sources: [], truncated: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetch formatting', () => {
|
||||
const NO_CAP = 1_000_000
|
||||
const HEADER = 'Fetched https://a.test (HTTP 200)\n\n'
|
||||
@@ -259,6 +357,42 @@ describe('fetch formatting', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('web_fetch presentation meta and result view', () => {
|
||||
it('projects url, status, and truncation into meta', () => {
|
||||
expect(fetchMetaFromValue({ url: 'https://a.test', statusCode: 404, truncated: true }))
|
||||
.toEqual({ url: 'https://a.test', statusCode: 404, truncated: true })
|
||||
})
|
||||
|
||||
it('presents a completed fetch as a web/fetch card carrying the summary and the markdown body as fallback content', () => {
|
||||
const meta = fetchMetaFromValue({ url: 'https://a.test', statusCode: 200, truncated: false })
|
||||
expect(presentFetchResult(toolResult(meta, '# Title'))).toEqual({
|
||||
card: 'web',
|
||||
kind: 'fetch',
|
||||
url: 'https://a.test',
|
||||
statusCode: 200,
|
||||
truncated: false,
|
||||
content: [{ type: 'text', text: '# Title' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the generic card on an error result', () => {
|
||||
const meta = fetchMetaFromValue({ url: 'https://a.test', statusCode: 200, truncated: false })
|
||||
expect(presentFetchResult(toolResult(meta, 'body', true))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to the generic card on absent or malformed meta', () => {
|
||||
expect(presentFetchResult(toolResult(undefined))).toBeUndefined()
|
||||
expect(fetchMetaFromResult(undefined)).toBeUndefined()
|
||||
expect(fetchMetaFromResult(null)).toBeUndefined()
|
||||
expect(fetchMetaFromResult('nope')).toBeUndefined()
|
||||
expect(fetchMetaFromResult([])).toBeUndefined()
|
||||
expect(fetchMetaFromResult({})).toBeUndefined()
|
||||
expect(fetchMetaFromResult({ url: 1, statusCode: 200, truncated: false })).toBeUndefined()
|
||||
expect(fetchMetaFromResult({ url: 'u', statusCode: 'x', truncated: false })).toBeUndefined()
|
||||
expect(fetchMetaFromResult({ url: 'u', statusCode: 200, truncated: 'no' })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool-web registration', () => {
|
||||
it('registers both tools by default', async () => {
|
||||
const { fiber, ctx } = await mountTools()
|
||||
@@ -323,6 +457,38 @@ describe('tool-web execution through the real registry', () => {
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('projects the search sources into the tool result meta and derives its web/search view', async () => {
|
||||
const result: WebSearchResult = {
|
||||
content: 'answer', truncated: true,
|
||||
sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }],
|
||||
}
|
||||
const { ctx, fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: searchProvider(result) })
|
||||
const out = await call('web_search', { query: 'q' })
|
||||
expect(out.meta).toEqual({
|
||||
answer: 'answer', truncated: true,
|
||||
sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }],
|
||||
})
|
||||
const view = ctx.tools.get('web_search')?.presentResult?.({ query: 'q' }, { content: out.content, isError: out.isError, ...out.meta !== undefined ? { meta: out.meta } : {} })
|
||||
expect(view).toMatchObject({ card: 'web', kind: 'search', truncated: true, answer: 'answer' })
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('projects the fetch summary into the tool result meta and derives its web/fetch view', async () => {
|
||||
const fetchProvider = {
|
||||
id: 'stub-fetch',
|
||||
available: () => available,
|
||||
fetch: (request: { url: string }) => Promise.resolve({
|
||||
url: request.url, statusCode: 200, body: { kind: 'text' as const, content: 'ok' }, truncated: true,
|
||||
}),
|
||||
}
|
||||
const { ctx, fiber, call } = await mountTools({ webConfig: { fetchProvider: 'stub-fetch' }, fetchProvider })
|
||||
const out = await call('web_fetch', { url: 'https://a.test' })
|
||||
expect(out.meta).toEqual({ url: 'https://a.test', statusCode: 200, truncated: true })
|
||||
const view = ctx.tools.get('web_fetch')?.presentResult?.({ url: 'https://a.test' }, { content: out.content, isError: out.isError, ...out.meta !== undefined ? { meta: out.meta } : {} })
|
||||
expect(view).toMatchObject({ card: 'web', kind: 'fetch', url: 'https://a.test', statusCode: 200, truncated: true })
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('surfaces a structured WebError when no provider is available', async () => {
|
||||
const { fiber, call } = await mountTools()
|
||||
const out = await call('web_search', { query: 'q' })
|
||||
|
||||
Reference in New Issue
Block a user