Merge pull request #653 from pikasTech/fix/v02-web-cli-agentrun-continuation
fix: align web cli agentrun continuation
This commit is contained in:
@@ -24,6 +24,11 @@ Cloud Workbench is the default user-facing frontend at
|
||||
API/edge 访问、source reports、本地 echo、mock replies 或 provider
|
||||
credential gaps 不能被写成真实 assistant reply;readiness 权威文档是
|
||||
[code-agent-chat-readiness.md](code-agent-chat-readiness.md).
|
||||
- Cloud Web 的 Code Agent 非视觉业务路径必须能被 `hwlab-cli client agent`
|
||||
复现。Web 发现 AgentRun、session continuation、trace/result 或 provider
|
||||
问题时,先用 CLI 通过同一 Cloud Web base URL、同一 `/v1/agent/chat`、同一
|
||||
`conversationId/sessionId/threadId/retryOf` 复现,再进入前端状态机或样式修复;
|
||||
不能用只覆盖首轮新会话的 CLI 验收替代 Web continuation 验收。
|
||||
|
||||
## Required Layout
|
||||
|
||||
@@ -150,6 +155,12 @@ workspace;除非 Pod 重建、Codex supervisor 重启、用户显式清空对
|
||||
可用时优先使用;server 内存 session 因 Pod/replica 切换丢失时,只要本地 session 未过期,
|
||||
刷新页面不得要求用户重新登录。
|
||||
|
||||
当后端使用 AgentRun v0.1 短连接 runner 时,Web 仍可把历史 `threadId` 作为会话连续性元数据
|
||||
提交,但 HWLAB adapter 不得把该历史 `threadId` 直接透传给新 runner 做 Codex app-server
|
||||
`thread/resume`。AgentRun runner Job 是新执行面,旧 Web/Codex thread 可能没有对应 rollout;
|
||||
adapter 应保留 `conversationId/sessionId` 和 redacted `requestedThreadId` 用于追踪,同时让新
|
||||
runner 建立自己的 app-server thread,避免 `no rollout found for thread id`。
|
||||
|
||||
恢复出的历史消息如果携带 `traceId`,前端必须先对 `/v1/agent/chat/result/<traceId>` 做
|
||||
终态归一,再回放 `/trace`。旧的 `running` 消息不能只因为 localStorage restore 被降级成
|
||||
`source` 后停留在“处理中”文本;如果后端 result 已经完成,消息卡片必须替换为真实
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
- 指挥官必须亲自掌握关键一手事实,不能只依赖 runner 的二手摘要。
|
||||
- 必须亲自查看 `http://74.48.78.17:17666/` 默认首屏、`/gate` 或内部诊断页、关键 PR diff、部署 revision、DEV 验收结果和失败证据。
|
||||
- 专题 issue 评论是长任务的上下文锚点。调查结论、细化方案、阶段进展、卡点、修复边界、验收命令和最终结论必须及时写入对应 GitHub issue 评论;不要只存在对话上下文、临时文件或本地记忆里。上下文压缩、换 agent、跨仓库修复或进入 CI/CD 前,应先读最新 issue 评论,再继续执行。
|
||||
- 一层 Code Queue 直接执行业务实现、修复、部署和验证是允许的基层指挥模式。
|
||||
- 不得把战略判断、是否偏离用户目标的判断、关键 PR 最终审查、关键部署证据审查、指挥官自我反省和指挥规则更新外派给 runner。
|
||||
- 不得为了 Gate 生成 Gate、为了报告生成报告、为了审查再派审查、为了管理继续拆管理;Gate、报告、预检和观测脚本只能作为真实上线、真实 E2E 或 blocker 解除的必要最小工具。
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
- 提供 WEB 等价的非视觉业务入口:登录鉴权、session 恢复、Device Pod 看板、Code Agent 对话、trace/result 轮询、logout 和工作台 live summary。
|
||||
- 只走 Cloud Web 同源 API surface;默认 base URL 是 `http://74.48.78.17:19666`,也可通过 `--base-url` 或 `HWLAB_CLIENT_BASE_URL` 指向其他 Cloud Web 入口。
|
||||
- Web/CLI 路径一致性优先于继续 Web 修复。Cloud Web 暴露 Code Agent、AgentRun、continuation、trace/result 或 provider 问题后,必须先能用 `hwlab-cli client agent send/result/trace/inspect` 对同一 base URL、同一 `/v1/agent/chat`、同一 `conversationId/sessionId/threadId/retryOf` 复现或解释,再继续修 Web 状态机。
|
||||
- 默认业务子命令不直连 Postgres、Kubernetes Service、Secret、device-pod 内部 Service、gateway RPC 或本地 fixture;需要鉴权的请求使用 `/auth/*` 返回的 cookie 或显式 `--cookie`。唯一例外是 `client gateway` 诊断族:它允许显式 `--api-base-url` 指向 Cloud API,用于短连接观测 gateway session、单次 shell invoke 和 transport 压测;该入口只验证底层传输稳定性,不替代 Web 用户流程授权,也不发布镜像或常驻服务。
|
||||
- Pod 内透传执行不放进 `hwlab-cli`;需要进入正在工作的 Code Agent/Cloud API pod 时,`hwlab-cli` 只查询并输出 UniDesk 标准 route,实际透传由 UniDesk `bun scripts/cli.ts ssh 'G14:k3s:hwlab-v02:pod:<pod>:<container>' ...` 完成。`pod:` 是 route 语法,`/` 只用于 pod 内文件系统路径。
|
||||
- `client runtime routes` 必须按当前运行 profile/lane 的数据生成 UniDesk `pod:` route;实现不得硬编码 `dev`、`v0.2`、`v0.3`、namespace 或 catalog path。新增版本只允许通过 `deploy.json.lanes[profile]` 声明 namespace、artifact catalog 和 service overrides,不为每个版本新增代码分支。
|
||||
@@ -17,6 +18,8 @@
|
||||
- 输出默认是 JSON;任何失败都要有 `ok:false`、`action`、`status`、HTTP 状态、route 和可定位错误,不允许无 stdout 成功。可能返回大对象的 `client` 子命令默认返回紧凑摘要,避免高频排障输出爆炸;需要完整响应体时显式加 `--full`。
|
||||
- `device-pod-cli`/`hwpod` 的 `job output` 默认也必须返回紧凑 JSON:保留 job/status/blocker/freshness/text/evidence 摘要,省略嵌套 gateway dispatch 和长命令;需要完整 payload 时显式加 `--full`。Code Agent 和人工不得用 `| head`、`grep` 或 shell 管道作为默认输出压缩方式,避免 stdout pipe、子进程信号转发或长输出造成 commandExecution 黑洞。
|
||||
- Code Agent 交互必须默认暴露 `traceId`、`resultUrl`、终态和 assistant 回复文本摘要;不能要求用户先拉全量 trace 再手工查找回复。
|
||||
- `client agent send` 是 Cloud Web Code Agent composer 的非视觉等价入口。它必须支持 `--conversation-id`、`--session-id`、`--thread-id` 和 `--retry-of`,并在输出中返回 redacted continuation 摘要,证明本次 CLI 请求是否覆盖 Web 的继续会话路径。
|
||||
- AgentRun v0.1 短连接 runner 不保证历史 Web/Codex `threadId` 可在新 runner Job 内 resume;CLI 仍应把 Web 提交的 `threadId` 原样送到 Cloud Web API,以便验证 adapter 是否正确把它降级为 `requestedThreadId` 元数据,而不是让 runner 执行旧 thread resume。
|
||||
- `client harness`、`client harness-ops` 和 `client harness-opt` 吸收 G14 harness-ops 的短连接业务能力:health、submit、result、trace、wait 和 audit。
|
||||
- harness 系列命令只调用 Cloud Web/Code Agent 同源 API,不创建镜像、Job、常驻服务,也不执行 hot-sync、kubectl cp 或硬编码 namespace/pod 的运行面写路径。
|
||||
- 旧 `hwlab-cli cicd`、fixture MVP gate 和 CLI 镜像/Job 口径属于废弃路径;开发中遇到这些旧门禁、旧测试或旧预检时直接删除,不再维护兼容。
|
||||
@@ -44,7 +47,7 @@
|
||||
| `hwlab-cli client gateway sessions` | `GET Cloud API /v1/gateway/sessions` | 显式 Cloud API 诊断入口,观察 gateway online/stale、inflight 和 capability;默认不带 Web cookie。 |
|
||||
| `hwlab-cli client gateway invoke` | `POST Cloud API /v1/rpc/hardware.invoke.shell` | 显式 Cloud API 诊断入口,执行一次 bounded shell dispatch 并返回结构化 dispatch 摘要。 |
|
||||
| `hwlab-cli client gateway pressure` | `POST Cloud API /v1/rpc/hardware.invoke.shell` | 显式 Cloud API 压测入口,真实验证大输出、长单行、stderr、timeout 和并发超容量不会造成黑洞。 |
|
||||
| `hwlab-cli client agent send` | `POST /v1/agent/chat` + `GET /result/{trace}` | 以 short connection 提交 Code Agent 消息并轮询结果,默认输出 assistant 回复文本摘要。 |
|
||||
| `hwlab-cli client agent send` | `POST /v1/agent/chat` + `GET /result/{trace}` | 以 short connection 提交 Code Agent 消息并轮询结果,默认输出 assistant 回复文本摘要;支持 `--conversation-id`、`--session-id`、`--thread-id`、`--retry-of` 复现 Web continuation。 |
|
||||
| `hwlab-cli client agent trace TRACE` | `GET /v1/agent/chat/trace/{trace}` | 回放 trace,默认输出状态、事件摘要和 assistant stream 文本。 |
|
||||
| `hwlab-cli client agent cancel TRACE` | `POST /v1/agent/chat/cancel` | 取消当前 Code Agent 请求。 |
|
||||
| `hwlab-cli client harness submit` | `POST /v1/agent/chat` | G14 harness-ops 的短连接提交入口,默认 provider profile 为 `deepseek`,返回 trace/result URL;`harness-ops` 和 `harness-opt` 是同义别名。 |
|
||||
|
||||
@@ -19,6 +19,7 @@ const DEFAULT_REPO_URL = "http://git-mirror-http.devops-infra.svc.cluster.local/
|
||||
const DEFAULT_RUNNER_NAMESPACE = "agentrun-v01";
|
||||
const DEFAULT_TIMEOUT_MS = 1_200_000;
|
||||
const AGENTRUN_BACKENDS = Object.freeze(["codex", "deepseek"]);
|
||||
const THREAD_CONTINUITY_POLICY = "hwlab-agentrun-v01-new-runner-thread";
|
||||
|
||||
export function codeAgentAgentRunAdapterEnabled(env = process.env) {
|
||||
const value = String(env.HWLAB_CODE_AGENT_ADAPTER ?? env.HWLAB_CODE_AGENT_PROVIDER ?? "").trim().toLowerCase();
|
||||
@@ -46,6 +47,8 @@ export function initialAgentRunChatResult({ params = {}, options = {}, traceId }
|
||||
capabilityLevel: "agentrun-v01-manual-dispatch",
|
||||
sessionMode: "agentrun-v01-session-ref",
|
||||
implementationType: "hwlab-cloud-api-agentrun-v01-adapter",
|
||||
requestedThreadId: safeOpaqueId(params.threadId) || null,
|
||||
threadContinuityPolicy: THREAD_CONTINUITY_POLICY,
|
||||
agentRun: {
|
||||
adapter: ADAPTER_ID,
|
||||
status: "pending-dispatch",
|
||||
@@ -323,11 +326,12 @@ function buildAgentRunCreateRunInput({ params, env, traceId, backendProfile }) {
|
||||
sessionRef: {
|
||||
sessionId: agentRunSessionId(params, traceId),
|
||||
...(safeConversationId(params.conversationId) ? { conversationId: safeConversationId(params.conversationId) } : {}),
|
||||
...(safeOpaqueId(params.threadId) ? { threadId: safeOpaqueId(params.threadId) } : {}),
|
||||
metadata: {
|
||||
adapter: ADAPTER_ID,
|
||||
hwlabTraceId: traceId,
|
||||
hwlabApi: "/v1/agent/chat",
|
||||
requestedThreadId: safeOpaqueId(params.threadId) || null,
|
||||
threadContinuityPolicy: THREAD_CONTINUITY_POLICY,
|
||||
valuesPrinted: false
|
||||
}
|
||||
},
|
||||
@@ -364,7 +368,9 @@ function buildAgentRunCommandInput({ params, traceId, backendProfile }) {
|
||||
traceId,
|
||||
conversationId: safeConversationId(params.conversationId) || null,
|
||||
sessionId: agentRunSessionId(params, traceId),
|
||||
threadId: safeOpaqueId(params.threadId) || null,
|
||||
threadId: null,
|
||||
requestedThreadId: safeOpaqueId(params.threadId) || null,
|
||||
threadContinuityPolicy: THREAD_CONTINUITY_POLICY,
|
||||
providerProfile: backendProfile,
|
||||
source: "hwlab-cloud-api",
|
||||
valuesPrinted: false
|
||||
|
||||
@@ -154,11 +154,17 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte
|
||||
assert.equal(body.backendProfile, "deepseek");
|
||||
assert.equal(body.resourceBundleRef.repoUrl, "http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/HWLAB.git");
|
||||
assert.equal(body.resourceBundleRef.commitId, "0123456789abcdef0123456789abcdef01234567");
|
||||
assert.equal(Object.hasOwn(body.sessionRef, "threadId"), false);
|
||||
assert.equal(body.sessionRef.metadata.requestedThreadId, "019e8078-db67-7750-a5d9-1a99f3abd445");
|
||||
assert.equal(body.sessionRef.metadata.threadContinuityPolicy, "hwlab-agentrun-v01-new-runner-thread");
|
||||
return send({ id: "run_hwlab_adapter", status: "pending", backendProfile: "deepseek", sessionRef: body.sessionRef, resourceBundleRef: body.resourceBundleRef });
|
||||
}
|
||||
if (request.method === "POST" && url.pathname === "/api/v1/runs/run_hwlab_adapter/commands") {
|
||||
assert.equal(body.type, "turn");
|
||||
assert.match(body.payload.prompt, /AgentRun adapter/u);
|
||||
assert.equal(body.payload.threadId, null);
|
||||
assert.equal(body.payload.requestedThreadId, "019e8078-db67-7750-a5d9-1a99f3abd445");
|
||||
assert.equal(body.payload.threadContinuityPolicy, "hwlab-agentrun-v01-new-runner-thread");
|
||||
return send({ id: "cmd_hwlab_adapter", runId: "run_hwlab_adapter", state: "pending", type: "turn", seq: 1 });
|
||||
}
|
||||
if (request.method === "POST" && url.pathname === "/api/v1/runs/run_hwlab_adapter/runner-jobs") {
|
||||
@@ -242,7 +248,13 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte
|
||||
const submit = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", "x-trace-id": traceId },
|
||||
body: JSON.stringify({ conversationId: "cnv_server-test-agentrun", message: "AgentRun adapter smoke" })
|
||||
body: JSON.stringify({
|
||||
conversationId: "cnv_server-test-agentrun",
|
||||
sessionId: "ses_server-test-agentrun",
|
||||
threadId: "019e8078-db67-7750-a5d9-1a99f3abd445",
|
||||
retryOf: "trc_previous-agentrun-web",
|
||||
message: "AgentRun adapter smoke"
|
||||
})
|
||||
});
|
||||
assert.equal(submit.status, 202);
|
||||
const accepted = await submit.json();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, readFile } from "node:fs/promises";
|
||||
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { test } from "bun:test";
|
||||
@@ -242,6 +242,54 @@ test("hwlab-cli client agent send submits async and polls result", async () => {
|
||||
assert.equal(result.payload.result.body.reply.content, "hi");
|
||||
});
|
||||
|
||||
test("hwlab-cli client agent send preserves Web continuation fields", async () => {
|
||||
const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-cli-client-agent-continuation-"));
|
||||
await writeFile(path.join(cwd, "prompt.txt"), "看看有什么device-pod能用?", "utf8");
|
||||
const calls: any[] = [];
|
||||
const result = await runHwlabCli([
|
||||
"client",
|
||||
"agent",
|
||||
"send",
|
||||
"--base-url",
|
||||
"http://web.test",
|
||||
"--cookie",
|
||||
"hwlab_session=session-a",
|
||||
"--message-file",
|
||||
"prompt.txt",
|
||||
"--trace-id",
|
||||
"trc_web_continue",
|
||||
"--conversation-id",
|
||||
"cnv_web_continue",
|
||||
"--session-id",
|
||||
"ses_web_continue",
|
||||
"--thread-id",
|
||||
"019e8078-db67-7750-a5d9-1a99f3abd445",
|
||||
"--retry-of",
|
||||
"trc_previous",
|
||||
"--no-wait"
|
||||
], {
|
||||
cwd,
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: String(url), init, body: init?.body ? JSON.parse(String(init.body)) : null });
|
||||
return new Response(JSON.stringify({ accepted: true, status: "running", traceId: "trc_web_continue", resultUrl: "/v1/agent/chat/result/trc_web_continue" }), { status: 202 });
|
||||
},
|
||||
stdinText: "fallback stdin",
|
||||
sleep: async () => {}
|
||||
});
|
||||
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.equal(calls[0].url, "http://web.test/v1/agent/chat");
|
||||
assert.equal(calls[0].body.message, "看看有什么device-pod能用?");
|
||||
assert.equal(calls[0].body.conversationId, "cnv_web_continue");
|
||||
assert.equal(calls[0].body.sessionId, "ses_web_continue");
|
||||
assert.equal(calls[0].body.threadId, "019e8078-db67-7750-a5d9-1a99f3abd445");
|
||||
assert.equal(calls[0].body.retryOf, "trc_previous");
|
||||
assert.equal(result.payload.continuation.webEquivalent, true);
|
||||
assert.equal(result.payload.continuation.threadId, "019e8078-db67-7750-a5d9-1a99f3abd445");
|
||||
assert.equal(result.payload.continuation.retryOf, "trc_previous");
|
||||
assert.equal(JSON.stringify(result.payload).includes("hwlab_session=session-a"), false);
|
||||
});
|
||||
|
||||
test("hwlab-cli client agent trace shows assistant stream text in compact output", async () => {
|
||||
const result = await runHwlabCli(["client", "agent", "trace", "trc_test", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-a"], {
|
||||
fetchImpl: async () => new Response(JSON.stringify({
|
||||
|
||||
@@ -94,7 +94,7 @@ function help() {
|
||||
"hwlab-cli client workbench summary --pod-id device-pod-71-freq",
|
||||
"hwlab-cli client request GET /v1/access/status [--full]",
|
||||
"hwlab-cli client rpc system.health [--full]",
|
||||
"hwlab-cli client agent send --message TEXT --provider-profile deepseek --timeout-ms 120000",
|
||||
"hwlab-cli client agent send --message TEXT|--message-file PATH [--conversation-id ID] [--session-id ID] [--thread-id ID] [--retry-of TRACE] --provider-profile deepseek --timeout-ms 120000",
|
||||
"hwlab-cli client agent result TRACE_ID",
|
||||
"hwlab-cli client agent trace TRACE_ID",
|
||||
"hwlab-cli client agent inspect --trace-id TRACE_ID",
|
||||
@@ -512,7 +512,7 @@ async function agentCommand(context: any) {
|
||||
}
|
||||
if (subcommand === "cancel") {
|
||||
const traceId = requiredText(context.rest[1] ?? context.parsed.traceId, "traceId");
|
||||
const response = await requestJson({ ...context, method: "POST", path: "/v1/agent/chat/cancel", body: clean({ traceId, conversationId: text(context.parsed.conversationId), sessionId: text(context.parsed.sessionId) }), extraHeaders: { "x-trace-id": traceId } });
|
||||
const response = await requestJson({ ...context, method: "POST", path: "/v1/agent/chat/cancel", body: clean({ traceId, conversationId: text(context.parsed.conversationId), sessionId: text(context.parsed.sessionId), threadId: text(context.parsed.threadId) }), extraHeaders: { "x-trace-id": traceId } });
|
||||
return responsePayload("client.agent.cancel", response, context, { route: route("POST", "/v1/agent/chat/cancel"), traceId });
|
||||
}
|
||||
throw cliError("unsupported_agent_command", `unsupported agent command: ${subcommand}`, { subcommand });
|
||||
@@ -722,15 +722,22 @@ async function harnessAudit(context: any) {
|
||||
|
||||
async function agentSend(context: any) {
|
||||
const { parsed } = context;
|
||||
const message = text(parsed.message ?? parsed.text) || text(context.stdinText);
|
||||
const message = await messageValue(context);
|
||||
if (!message) throw cliError("message_required", "client agent send requires --message or stdin text");
|
||||
const traceId = text(parsed.traceId) || makeId("trc");
|
||||
const conversationId = text(parsed.conversationId) || makeId("cnv");
|
||||
const continuation = agentContinuationSummary({
|
||||
conversationId,
|
||||
sessionId: text(parsed.sessionId),
|
||||
threadId: text(parsed.threadId),
|
||||
retryOf: text(parsed.retryOf)
|
||||
});
|
||||
const requestBody = clean({
|
||||
message,
|
||||
conversationId,
|
||||
sessionId: text(parsed.sessionId),
|
||||
threadId: text(parsed.threadId),
|
||||
retryOf: text(parsed.retryOf),
|
||||
traceId,
|
||||
providerProfile: text(parsed.providerProfile) || "deepseek",
|
||||
gatewayShellTimeoutMs: numberOption(parsed.gatewayShellTimeoutMs),
|
||||
@@ -750,10 +757,10 @@ async function agentSend(context: any) {
|
||||
}
|
||||
});
|
||||
if (!isHttpSuccess(accepted)) {
|
||||
return responsePayload("client.agent.send", accepted, context, { route: route("POST", "/v1/agent/chat"), traceId, conversationId });
|
||||
return responsePayload("client.agent.send", accepted, context, { route: route("POST", "/v1/agent/chat"), traceId, conversationId, continuation });
|
||||
}
|
||||
if (parsed.noWait === true) {
|
||||
return responsePayload("client.agent.send", accepted, context, { route: route("POST", "/v1/agent/chat"), traceId, conversationId, waited: false });
|
||||
return responsePayload("client.agent.send", accepted, context, { route: route("POST", "/v1/agent/chat"), traceId, conversationId, continuation, waited: false });
|
||||
}
|
||||
const result = await pollAgentResult(context, traceId, accepted.body);
|
||||
return ok("client.agent.send", {
|
||||
@@ -761,6 +768,7 @@ async function agentSend(context: any) {
|
||||
route: route("POST", "/v1/agent/chat"),
|
||||
traceId,
|
||||
conversationId,
|
||||
continuation,
|
||||
accepted: compactBody(accepted.body),
|
||||
result: result.response ? compactResponse(result.response) : null,
|
||||
polls: result.polls,
|
||||
@@ -1281,6 +1289,18 @@ function compactResponse(response: any) {
|
||||
return { httpStatus: response.status, route: route(response.method, response.path), body: compactApiBody(response.body) };
|
||||
}
|
||||
|
||||
function agentContinuationSummary(value: { conversationId?: string | null; sessionId?: string | null; threadId?: string | null; retryOf?: string | null }) {
|
||||
return pruneUndefined({
|
||||
webEquivalent: true,
|
||||
shortConnection: true,
|
||||
conversationId: text(value.conversationId),
|
||||
sessionId: text(value.sessionId),
|
||||
threadId: text(value.threadId),
|
||||
retryOf: text(value.retryOf),
|
||||
valuesPrinted: false
|
||||
});
|
||||
}
|
||||
|
||||
function responseBodyForCli(body: any, parsed: ParsedArgs) {
|
||||
return parsed.full === true ? body : compactApiBody(body);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user