Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification
# Conflicts: # .agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml # .agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.i18n.yaml # .agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml # .agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml # docs/architecture.i18n.yaml # docs/architecture.md # docs/architecture.zh.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/core.md # docs/core-data-structures/core.zh.md # docs/event-producer-consumer.md # docs/module-graph.md # docs/persistence-catalog.md # examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # examples/acp-agent/tests/snapshots/skill-load/session.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl # examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl # examples/acp-agent/tests/snapshots/workspace-context/session.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl # examples/headless-agent/tests/snapshots/pty-tools/session.jsonl # examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl # packages/acp/acp/README.i18n.yaml # packages/acp/acp/src/index.ts # packages/client/connection/src/client/index.ts # packages/client/runtime/README.i18n.yaml # packages/client/runtime/README.md # packages/client/runtime/README.zh.md # packages/client/runtime/src/client/sessions/conversation.ts # packages/client/runtime/src/client/sessions/session.ts # packages/client/runtime/tests/queue-store.spec.ts # packages/client/ui-conversation/src/client/input/contract.ts # packages/client/ui-goal/README.i18n.yaml # packages/compact/compact-basic/src/index.ts # packages/context/time-context/tests/time-context.spec.ts # packages/context/tmux-context/tests/tmux-context.spec.ts # packages/context/workspace-context/tests/workspace-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-loop/README.i18n.yaml # packages/core/agent-loop/README.md # packages/core/agent-loop/README.zh.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent/README.i18n.yaml # packages/core/agent/README.md # packages/core/agent/README.zh.md # packages/core/agent/src/types.ts # packages/core/session/README.i18n.yaml # packages/core/session/README.md # packages/core/session/README.zh.md # packages/goal/command-goal/tests/command-goal.spec.ts # packages/goal/goal/tests/goal.spec.ts # packages/goal/tool-goal/README.i18n.yaml # packages/goal/tool-goal/README.md # packages/goal/tool-goal/README.zh.md # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/events.schema.ts # packages/host/apiproxy/src/api/events.ts # packages/host/apiproxy/src/api/rpc.ts # packages/host/apiproxy/tests/api-proxy-commands.spec.ts # packages/host/apiproxy/tests/api-proxy-projections.spec.ts # packages/host/apiproxy/tests/rpc-schemas.spec.ts # packages/pty/pty-local/tests/index.spec.ts # packages/pty/pty-local/tests/local.spec.ts # packages/pty/tool-pty/tests/loader-composition.spec.ts # packages/pty/tool-pty/tests/tools.spec.ts # packages/session-persistence/session-checkpoint-policy/src/index.ts # packages/session-persistence/session-persistence/tests/coordinator-contract.ts # packages/skill/tool-skill/tests/tool-skill.spec.ts # packages/subagent/subagent-inprocess/README.i18n.yaml # packages/subagent/subagent-inprocess/README.md # packages/subagent/subagent-inprocess/README.zh.md # packages/subagent/subagent-inprocess/src/index.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/support/acp-snapshot/src/harness.ts # packages/ui/tui/tests/tui.spec.ts
This commit is contained in:
@@ -40,6 +40,11 @@ const SYSTEM_PROMPT_SNAPSHOT = 'system-prompt.expected.md'
|
||||
/** The structured tool-schema snapshot beside its owning header pin. */
|
||||
const TOOL_SCHEMAS_SNAPSHOT = 'tool-schemas.expected.json'
|
||||
|
||||
/** Return the dedicated tool-schema sidecar for one child fixture index. */
|
||||
function childToolSchemasSnapshot(index: number): string {
|
||||
return `tool-schemas.${index}.expected.json`
|
||||
}
|
||||
|
||||
/** The optional full Windows-native stdout transcript. */
|
||||
const WINDOWS_STDOUT_SNAPSHOT = 'stdout.expected.windows.jsonl'
|
||||
|
||||
@@ -100,6 +105,13 @@ export interface Scenario {
|
||||
* declare the same {@link expectedHeaderChanges}; meaningless off a pin.
|
||||
*/
|
||||
toolSchemasSource?: string
|
||||
/**
|
||||
* Child fixture indices whose own schema sequence is pinned separately,
|
||||
* where `1` names `session.1.jsonl` and
|
||||
* `tool-schemas.1.expected.json`. The class pin still owns every other
|
||||
* request-header field.
|
||||
*/
|
||||
pinsChildToolSchemas?: readonly number[]
|
||||
/**
|
||||
* How many changed `request/header` snapshots this PINNING scenario's primary
|
||||
* fixture legitimately carries (default 0). Their full prompt text is kept in
|
||||
@@ -1008,6 +1020,8 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
|
||||
cwdAliases: result.cwdAliases,
|
||||
}
|
||||
|
||||
const childSchemaPins = new Set(scenario.pinsChildToolSchemas ?? [])
|
||||
|
||||
// Record writes live model fixtures; keyless refresh writes every comparable replayed
|
||||
// fixture. Pinning JSONL keeps prefixes but moves prompts and schemas into sidecars.
|
||||
const scrub = scenario.pinsHeader === true
|
||||
@@ -1080,6 +1094,18 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
|
||||
claimSharedSnapshot(schemaClaims, schemaPath, scenario.name, toolSchemasSnapshot)
|
||||
await writeFile(schemaPath, toolSchemasSnapshot)
|
||||
}
|
||||
for (const index of childSchemaPins) {
|
||||
const log = result.sessionLogs[index]
|
||||
expect(log, `${mode}: no child session log at index ${index} to snapshot schemas from`)
|
||||
.toBeDefined()
|
||||
const schemaSets = normalizedToolSchemas((log as HarvestedLog).content, ctx)
|
||||
expect(schemaSets.length, `${mode}: child ${index} produced no tool schemas to snapshot`)
|
||||
.toBeGreaterThan(0)
|
||||
await writeFile(join(dir, childToolSchemasSnapshot(index)), formatToolSchemasSnapshot(
|
||||
schemaSets[0] as unknown[],
|
||||
schemaSets.slice(1),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
for (const expected of stdoutExpectedVariants(scenario)) {
|
||||
@@ -1133,7 +1159,14 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
|
||||
header,
|
||||
pinnedSchemaSets[index] as unknown[],
|
||||
))
|
||||
const childPinnedSchemas = new Map<number, unknown[][]>()
|
||||
for (const index of childSchemaPins) {
|
||||
const sidecar = await readFile(join(dir, childToolSchemasSnapshot(index)), 'utf8')
|
||||
const parsed = parseToolSchemasSnapshot(sidecar)
|
||||
childPinnedSchemas.set(index, [parsed.initial, ...parsed.changes])
|
||||
}
|
||||
for (const [logIndex, log] of result.sessionLogs.entries()) {
|
||||
const childSchemas = childPinnedSchemas.get(logIndex)
|
||||
const expectedChanges = scenario.pinsHeader === true && logIndex === 0
|
||||
? scenario.expectedHeaderChanges ?? 0
|
||||
: 0
|
||||
@@ -1146,8 +1179,15 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
|
||||
.toBe(headers.length)
|
||||
expect(schemaSets.length, `session ${log.id}: every request/header must carry an array-valued tools field`)
|
||||
.toBe(headers.length)
|
||||
if (childSchemas !== undefined) {
|
||||
expect(childSchemas.length, `session ${log.id}: ${childToolSchemasSnapshot(logIndex)} has an unexpected tool-schema count`)
|
||||
.toBe(schemaSets.length)
|
||||
}
|
||||
for (const [k, header] of headers.entries()) {
|
||||
const expected = expectedChanges > 0 ? pinnedHeaders[k] : pinnedHeaders[0]
|
||||
const classPin = expectedChanges > 0 ? pinnedHeaders[k] : pinnedHeaders[0]
|
||||
const expected = childSchemas === undefined
|
||||
? classPin
|
||||
: { ...classPin as Record<string, unknown>, tools: childSchemas[k] }
|
||||
expect(header, `session ${log.id}: request/header #${k + 1} diverged from the pinned (${pinningScenario.name}) header`)
|
||||
.toEqual(expected)
|
||||
if (expectedChanges === 0) {
|
||||
@@ -1185,8 +1225,16 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
|
||||
|
||||
it('every registered scenario has its required fixture files', async () => {
|
||||
// Every scenario needs input, stdout, a primary session fixture, and matching optional sidecars.
|
||||
for (const { name, overridden, pinsNativeWindowsStdout } of scenarios) {
|
||||
for (const { name, overridden, pinsNativeWindowsStdout, pinsChildToolSchemas } of scenarios) {
|
||||
const dir = join(snapshotsDir, name)
|
||||
const declaredChildPins = new Set(pinsChildToolSchemas ?? [])
|
||||
const childSidecars = (await readdir(dir, { withFileTypes: true }))
|
||||
.filter(entry => entry.isFile())
|
||||
.map(entry => /^tool-schemas\.([1-9]\d*)\.expected\.json$/.exec(entry.name))
|
||||
.filter((match): match is RegExpExecArray => match !== null)
|
||||
.map(match => Number(match[1]))
|
||||
expect(new Set(childSidecars), `${name}: child tool-schema sidecars must match \`pinsChildToolSchemas\``)
|
||||
.toEqual(declaredChildPins)
|
||||
expect(existsSync(join(dir, 'input.json')), `${name}/input.json`).toBe(true)
|
||||
expect(existsSync(join(dir, 'stdout.expected.jsonl')), `${name}/stdout.expected.jsonl`).toBe(true)
|
||||
expect(
|
||||
@@ -1269,6 +1317,26 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
|
||||
assertUniqueSnapshotContents('tool-schema', schemas)
|
||||
})
|
||||
|
||||
it('every declared child tool-schema sidecar is canonical and names a real child', async () => {
|
||||
for (const scenario of scenarios) {
|
||||
const pins = scenario.pinsChildToolSchemas ?? []
|
||||
if (pins.length === 0) continue
|
||||
const dir = join(snapshotsDir, scenario.name)
|
||||
const files = await sessionFixtures(dir)
|
||||
for (const index of pins) {
|
||||
expect(files[index], `${scenario.name}: child schema pin ${index} must name an existing session.<n>.jsonl fixture`)
|
||||
.toBeDefined()
|
||||
const file = childToolSchemasSnapshot(index)
|
||||
const sidecar = await readFile(join(dir, file), 'utf8')
|
||||
const parsed = parseToolSchemasSnapshot(sidecar)
|
||||
expect(sidecar, `${scenario.name}/${file} must use canonical JSON formatting`)
|
||||
.toBe(formatToolSchemasSnapshot(parsed.initial, parsed.changes))
|
||||
expect(parsed.initial.length, `${scenario.name}/${file} must pin at least one schema`)
|
||||
.toBeGreaterThan(0)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('every committed JSONL has valid tool results and canonical fixture storage', async () => {
|
||||
// Prompts and schemas always leave JSONL. Header pins retain prefixes;
|
||||
// every other fixture tokenizes those too. Portable cwd tokens never
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
]},
|
||||
{ "file": "b/child/session.jsonl", "lines": [
|
||||
{ "type": "session", "id": "eeeeeeee-1111-4222-8333-444444444444", "createdAt": 300, "cwd": "{{CWD}}", "parentSession": "{{SID}}", "delegationDepth": 1 },
|
||||
{ "type": "request/header", "seq": 0, "time": 6, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } }
|
||||
{ "type": "request/header", "seq": 0, "time": 6, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "child-only", "description": "Child D", "parameters": { "type": "object" } }] }, "reason": "initial" } }
|
||||
]}
|
||||
]
|
||||
}
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"initial": [
|
||||
{
|
||||
"name": "child-only",
|
||||
"description": "Child D",
|
||||
"parameters": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
],
|
||||
"changes": []
|
||||
}
|
||||
@@ -791,6 +791,83 @@ describe('runScenario', () => {
|
||||
)).rejects.toThrow(/did not persist turn\/end within 20ms/)
|
||||
})
|
||||
|
||||
it('waitForSubagentTurnEnd requires a closed child work turn', { timeout: 20_000 }, async () => {
|
||||
const closed = await scenario({
|
||||
prompt: 'hang-until-cancel',
|
||||
persistLogsOnCancel: true,
|
||||
logs: [
|
||||
{
|
||||
file: 'project/main/session.jsonl',
|
||||
lines: [
|
||||
{ type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
|
||||
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'aborted' } } },
|
||||
],
|
||||
},
|
||||
{
|
||||
file: 'project/child/session.jsonl',
|
||||
lines: [
|
||||
{ type: 'session', version: 0, id: 'child-1', createdAt: 2, parentSession: '{{SID}}', delegationDepth: 1 },
|
||||
{ type: 'subagent/descriptor', seq: 0, time: 1, data: {} },
|
||||
{ type: 'turn/start', seq: 1, time: 2, data: { turn: 1 } },
|
||||
{ type: 'request/header', seq: 2, time: 3, data: { header: {}, reason: 'initial' } },
|
||||
{ type: 'turn/end', seq: 3, time: 4, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
const result = await runScenario(
|
||||
{
|
||||
steps: [
|
||||
...boot,
|
||||
{ op: 'promptAndCancel', text: 'hang' },
|
||||
{ op: 'waitForSubagentTurnEnd' },
|
||||
],
|
||||
},
|
||||
{ agent: AGENT, mode: 'replay', fixtureFile: closed.fixtureFile },
|
||||
)
|
||||
expect(result.sessionLogs[1]?.parentSession).toBe(result.sessionId)
|
||||
|
||||
const seedOnly = await scenario({
|
||||
prompt: 'hang-until-cancel',
|
||||
persistLogsOnCancel: true,
|
||||
logs: [
|
||||
{
|
||||
file: 'project/main/session.jsonl',
|
||||
lines: [
|
||||
{ type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
|
||||
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'aborted' } } },
|
||||
],
|
||||
},
|
||||
{
|
||||
file: 'project/child/session.jsonl',
|
||||
lines: [
|
||||
{ type: 'session', version: 0, id: 'child-1', createdAt: 2, parentSession: '{{SID}}', delegationDepth: 1 },
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' } } },
|
||||
{ type: 'request/header', seq: 1, time: 2, data: { header: {}, reason: 'initial' } },
|
||||
{ type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
{ type: 'subagent/descriptor', seq: 3, time: 4, data: {} },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
await expect(runScenario(
|
||||
{
|
||||
steps: [
|
||||
...boot,
|
||||
{ op: 'promptAndCancel', text: 'hang' },
|
||||
{ op: 'waitForSubagentTurnEnd', timeoutMs: 20 },
|
||||
],
|
||||
},
|
||||
{ agent: AGENT, mode: 'replay', fixtureFile: seedOnly.fixtureFile },
|
||||
)).rejects.toThrow(/subagent child #1 did not persist a closed work turn within 20ms/)
|
||||
|
||||
const missing = await scenario({})
|
||||
await expect(runScenario(
|
||||
{ steps: [...boot, { op: 'waitForSubagentTurnEnd', child: 2, timeoutMs: 20 }] },
|
||||
{ agent: AGENT, mode: 'replay', fixtureFile: missing.fixtureFile },
|
||||
)).rejects.toThrow(/subagent child #2 did not persist a closed work turn within 20ms/)
|
||||
})
|
||||
|
||||
it('waitForTitleAfterTurnEnd times out when the title precedes the boundary', { timeout: 20_000 }, async () => {
|
||||
const { fixtureFile } = await scenario({
|
||||
prompt: 'hang-until-cancel',
|
||||
@@ -914,6 +991,19 @@ describe('runScenario', () => {
|
||||
expect(result.sessionId).toBeDefined()
|
||||
})
|
||||
|
||||
it('waitForFile holds the next input step behind cwd-relative readiness', { timeout: 20_000 }, async () => {
|
||||
const { dir, fixtureFile } = await scenario({})
|
||||
const workspaceDir = join(dir, 'workspace')
|
||||
const { mkdir } = await import('node:fs/promises')
|
||||
await mkdir(workspaceDir, { recursive: true })
|
||||
await writeFile(join(workspaceDir, 'ready'), '')
|
||||
const result = await runScenario(
|
||||
{ steps: [...boot, { op: 'waitForFile', path: 'ready' }, { op: 'cancel' }] },
|
||||
{ agent: AGENT, mode: 'replay', fixtureFile, workspaceDir },
|
||||
)
|
||||
expect(result.sessionId).toBeDefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ op: 'prompt', text: 'x' }, /prompt before newSession/],
|
||||
[{ op: 'promptAndWaitForAgentMessage', text: 'x', waitForText: 'later' }, /promptAndWaitForAgentMessage before newSession/],
|
||||
|
||||
@@ -78,6 +78,7 @@ const REPLAY_SCENARIOS: Scenario[] = [
|
||||
env: { DSH_PERMISSION_MODE: 'never' },
|
||||
configPath: AGENT.configPath,
|
||||
workspaceParent: tmpdir(),
|
||||
pinsChildToolSchemas: [1],
|
||||
prepareWorkspace: (cwd) => {
|
||||
writeFileSync(join(cwd, 'seed.txt'), 'prepared at runtime')
|
||||
},
|
||||
@@ -89,7 +90,7 @@ const REPLAY_SCENARIOS: Scenario[] = [
|
||||
|
||||
const RECORD_SCENARIOS: Scenario[] = [
|
||||
{ name: 'rec-pin', hasModelTurn: true, recorded: true, pinsHeader: true },
|
||||
{ name: 'rec-child', hasModelTurn: true, recorded: true },
|
||||
{ name: 'rec-child', hasModelTurn: true, recorded: true, pinsChildToolSchemas: [1] },
|
||||
// recorded:false in record mode → registered but skipped (never re-recorded).
|
||||
{ name: 'rec-skip', hasModelTurn: true, recorded: false, overridden: true },
|
||||
]
|
||||
@@ -118,6 +119,7 @@ function staleRefreshFixtures(dir: string): void {
|
||||
writeFileSync(join(dir, 'plain-turn', 'stdout.expected.jsonl'), 'stale stdout\n')
|
||||
writeFileSync(join(dir, 'pin-turn', 'system-prompt.expected.md'), 'STALE PROMPT\n')
|
||||
writeFileSync(join(dir, 'pin-turn', 'tool-schemas.expected.json'), '{"initial":[{"name":"stale"}],"changes":[]}\n')
|
||||
writeFileSync(join(dir, 'plain-turn', 'tool-schemas.1.expected.json'), '{"initial":[{"name":"stale-child"}],"changes":[]}\n')
|
||||
|
||||
const plainBehaviorFile = join(dir, 'plain-turn', 'behavior.json')
|
||||
const plainBehavior = JSON.parse(readFileSync(plainBehaviorFile, 'utf8')) as Record<string, unknown>
|
||||
@@ -180,6 +182,9 @@ describe('defineAcpSnapshotSuite: refresh write-back', () => {
|
||||
const schemas = readFileSync(join(refreshDir, 'pin-turn', 'tool-schemas.expected.json'), 'utf8')
|
||||
expect(schemas).toContain('"description": "D1"')
|
||||
expect(schemas).not.toContain('"name":"stale"')
|
||||
const childSchemas = readFileSync(join(refreshDir, 'plain-turn', 'tool-schemas.1.expected.json'), 'utf8')
|
||||
expect(childSchemas).toContain('"name": "child-only"')
|
||||
expect(childSchemas).not.toContain('stale-child')
|
||||
|
||||
const pinSession = readFileSync(join(refreshDir, 'pin-turn', 'session.jsonl'), 'utf8')
|
||||
expect(pinSession).toContain('"cwd":"{{cwd}}"')
|
||||
@@ -192,6 +197,8 @@ describe('defineAcpSnapshotSuite: record inventory write-back', () => {
|
||||
expect(fixture).toContain('"type":"session"')
|
||||
expect(fixture).toContain('"cwd":"{{cwd}}"')
|
||||
expect(() => readFileSync(join(recordDir, 'rec-child', 'session.2.jsonl'), 'utf8')).toThrow()
|
||||
expect(readFileSync(join(recordDir, 'rec-child', 'tool-schemas.1.expected.json'), 'utf8'))
|
||||
.toContain('"name": "t1"')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user