Merge PR #224 updates into prose cleanup
This commit is contained in:
@@ -36,8 +36,10 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
})
|
||||
|
||||
it('after an ACP-only HMR dispose, a late session/new creates no orphan agent (closed guard)', async () => {
|
||||
// Dispose JUST the bridge's fiber (an HMR reload) while agents/agent-loop stay up and the
|
||||
// transport is still live.
|
||||
// Dispose JUST the bridge's fiber (an HMR reload) while agents/agent-loop
|
||||
// stay up and the transport is still live. A late session/new must hit the
|
||||
// `closed` guard and reject — NOT create an agent the disposed bridge can no
|
||||
// longer stream or settle. Verify the world: no agent appeared.
|
||||
const harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const before = harness.ctx.agents.list().length
|
||||
@@ -49,9 +51,14 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
})
|
||||
|
||||
it('an agent created through the bridge is unregistered when ONLY the bridge fiber is disposed', async () => {
|
||||
// The factory (`ctx.agents.create`) is reached through the bridge's traceable service
|
||||
// proxy, so `AgentLoop.start`'s `this.ctx.effect(...)` registration binds to the CALLER
|
||||
// context — the bridge fiber — not the AgentLoop fiber.
|
||||
// The factory (`ctx.agents.create`) is reached through the bridge's
|
||||
// traceable service proxy, so `AgentLoop.start`'s `this.ctx.effect(...)`
|
||||
// registration binds to the CALLER context — the bridge fiber — not the
|
||||
// AgentLoop fiber. Disposing JUST the bridge fiber (an ACP-only HMR reload)
|
||||
// must therefore reclaim the agent's registry entry, even though agents/
|
||||
// agent-loop stay up. This pins the fiber-ownership the bridge's teardown
|
||||
// doc comment relies on; if a refactor rebinds the registration to the
|
||||
// AgentLoop fiber, the agent would survive bridge dispose and this fails.
|
||||
const harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
@@ -63,8 +70,10 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
})
|
||||
|
||||
it('no agent is created by a session/new after the bridge has closed (closed guard)', async () => {
|
||||
// After teardown (here a client disconnect sets `closed`), a late `session/new` must not
|
||||
// create an orphan agent the bridge can no longer drive/settle.
|
||||
// After teardown (here a client disconnect sets `closed`), a late
|
||||
// `session/new` must NOT create an orphan agent the bridge can no longer
|
||||
// drive/settle. The transport is gone so the RPC rejects; assert the world:
|
||||
// no new agent appeared in the registry.
|
||||
const harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const before = harness.ctx.agents.list().length
|
||||
@@ -76,7 +85,10 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
})
|
||||
|
||||
it('a client disconnect mid-prompt disposes the session (no registered agent left)', async () => {
|
||||
// The ACP transport closes (editor quits) while a turn runs.
|
||||
// The ACP transport closes (editor quits) while a turn runs. The bridge must
|
||||
// settle the in-flight prompt cancelled and DISPOSE the agent (the session's
|
||||
// per-agent AgentHandle teardown) rather than leaving an orphaned running —
|
||||
// or even idled-but-still-registered — agent whose updates are swallowed.
|
||||
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
@@ -94,7 +106,14 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
// The agent's loop has stopped: status `disposed`.
|
||||
expect(agent.status).toBe('disposed')
|
||||
|
||||
// Await bridge quiescence without disposing root agent and session services.
|
||||
// Await the bridge teardown to completion WITHOUT tearing down the root
|
||||
// agents/sessions services (so we can still query them). acpFiber.dispose()
|
||||
// invokes the SAME memoized quiesce() the disconnect started and awaits its
|
||||
// promise — which resolves only after every rec.dispose() (loop exit +
|
||||
// session removal) has finished, closing the whenIdle()/owned.dispose()
|
||||
// microtask race. The AgentHandle dispose has run: the agent is unregistered
|
||||
// and its session removed from the store, not merely idled (the old
|
||||
// behavior). The services live on the root ctx, so they survive this.
|
||||
await harness.acpFiber.dispose()
|
||||
expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined()
|
||||
expect(harness.ctx.sessions.get(SessionId(sessionId))).toBeUndefined()
|
||||
@@ -103,6 +122,9 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
|
||||
it('a client disconnect racing fiber dispose both reach quiescence (shared teardown)', async () => {
|
||||
// conn.closed teardown and ctx.fiber.dispose() can fire near-simultaneously.
|
||||
// They must share one teardown promise: dispose() must NOT return before the
|
||||
// disconnect teardown's whenIdle() has settled (a `record === undefined`-only
|
||||
// guard would let the second caller return early mid-teardown).
|
||||
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
@@ -135,10 +157,14 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
})
|
||||
|
||||
it('the final turn closing events are persisted across an AgentHandle dispose (durability)', async () => {
|
||||
// The teardown-ORDER guarantee: a per-agent dispose must stop the loop, AWAIT its exit (so
|
||||
// the loop's final `turn/end` + `session/flush` fire through the still-attached
|
||||
// `session.onAppend` → `session/event`), and only THEN detach onAppend + remove the
|
||||
// session.
|
||||
// The teardown-ORDER guarantee: a per-agent dispose must stop the loop,
|
||||
// AWAIT its exit (so the loop's final `turn/end` + `session/flush` fire
|
||||
// through the still-attached store observer → `session/event`), and only
|
||||
// THEN remove its publication hooks and session entry. If the order were inverted
|
||||
// (detach first), the closing events would never reach persistence. Drive a
|
||||
// CLEAN turn to completion, dispose JUST the bridge, then re-load the
|
||||
// persisted log from disk and assert the closing turn/end is on disk — the
|
||||
// world, not the agent's self-report.
|
||||
const harness = await makeBridgeHarness({ storageDir, script: [textResponse('done')] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
@@ -160,8 +186,18 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
})
|
||||
|
||||
it('a turn aborted BY the dispose still flushes its closing turn/end to disk (durability, mid-turn)', async () => {
|
||||
// The teardown-order contract only earns its keep when the closing events are produced BY
|
||||
// the dispose itself.
|
||||
// The teardown-order contract only earns its keep when the closing events are
|
||||
// produced BY the dispose itself. Here the model stream HANGS, so the turn is
|
||||
// still open when teardown runs: the composite agent effect stops the loop,
|
||||
// the loop unwinds and appends `turn/end {disposed}` + runs its final
|
||||
// `session/flush` — all while the store-owned publication hooks are still attached (the session
|
||||
// detach is the LAST disposer in the same effect's LIFO chain) — and only
|
||||
// THEN is the session detached. If the order were inverted (or the session
|
||||
// were a racing SIBLING effect), the abort-produced `turn/end` would never
|
||||
// reach disk and a re-load would instead show crash-recovery's synthetic
|
||||
// `interrupted` closer. Re-load from disk and assert the REAL `disposed`
|
||||
// reason landed — proving the loop's own closing event was captured, not a
|
||||
// recovered substitute.
|
||||
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
@@ -187,8 +223,11 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
})
|
||||
|
||||
it('per-session AgentHandle dispose leaves sibling agents untouched', async () => {
|
||||
// The factory returns a per-agent AgentHandle whose dispose() tears down EXACTLY that agent
|
||||
// + its session — RFC 011 isolation.
|
||||
// The factory returns a per-agent AgentHandle whose dispose() tears down
|
||||
// EXACTLY that agent + its session — RFC 011 isolation. Create two agents
|
||||
// directly through the registry factory (the same path the ACP bridge uses),
|
||||
// dispose one handle, and assert the other survives, registered and
|
||||
// queryable, with its session still in the store.
|
||||
const harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||
const handleA = await harness.ctx.agents.create({
|
||||
agentId: AgentId('sib-a'), sessionId: SessionId('sib-a'), agentOptions: { model: 'mock' },
|
||||
@@ -212,8 +251,14 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
})
|
||||
|
||||
it('a throwing agent/disposed listener does not prevent session removal (composite-effect containment)', async () => {
|
||||
// The AgentHandle teardown folds session-detach, register, and loop-stop into one composite
|
||||
// effect whose disposers run as a `.then()` chain.
|
||||
// The AgentHandle teardown folds session-detach, register, and loop-stop
|
||||
// into ONE composite effect whose disposers run as a `.then()` chain. The
|
||||
// register disposer emits `agent/disposed`; if a listener throws and the
|
||||
// emit is UNCONTAINED, the rejected chain skips the LATER session-detach
|
||||
// disposer — stranding the session in the store with its publication hooks attached (a
|
||||
// leak AND a durability hole, since the new design relies on detach
|
||||
// running). The emit must be contained. Register a throwing listener, drive
|
||||
// a clean turn, dispose, and assert the session was STILL removed.
|
||||
const harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] })
|
||||
harness.ctx.on('agent/disposed', () => { throw new Error('boom disposed listener') })
|
||||
const handle = await harness.ctx.agents.create({
|
||||
@@ -231,10 +276,11 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
})
|
||||
|
||||
it('concurrent AgentHandle dispose() calls all await the SAME teardown (memoized)', async () => {
|
||||
// The handle's dispose() must memoize: the underlying cordis effect disposer is
|
||||
// single-shot, so a second dispose() while the first is mid-teardown would otherwise
|
||||
// resolve IMMEDIATELY (effect epoch already cleared) — before the first call's await
|
||||
// agent.done + final flush finished.
|
||||
// The handle's dispose() must memoize: the underlying cordis effect disposer
|
||||
// is single-shot, so a second dispose() while the first is mid-teardown would
|
||||
// otherwise resolve IMMEDIATELY (effect epoch already cleared) — before the
|
||||
// first call's await agent.done + final flush finished. Every caller must
|
||||
// observe the same quiescence boundary.
|
||||
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
|
||||
const handle = await harness.ctx.agents.create({
|
||||
agentId: AgentId('conc-a'), sessionId: SessionId('conc-a'), agentOptions: { model: 'mock' },
|
||||
|
||||
@@ -19,7 +19,9 @@ describe('acp bridge — demux & config edges', () => {
|
||||
})
|
||||
|
||||
it('ignores events from an agent the bridge does not own (strict id demux)', async () => {
|
||||
// A second agent created directly on the registry (not via the bridge) runs a turn.
|
||||
// A second agent created directly on the registry (NOT via the bridge) runs
|
||||
// a turn. Its session events must NOT produce ACP updates and
|
||||
// must not settle anything — the bridge demuxes strictly by its own id.
|
||||
harness = await makeBridgeHarness({ storageDir, script: [textResponse('foreign')] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
@@ -40,8 +40,9 @@ describe('acp bridge — turn outcomes', () => {
|
||||
})
|
||||
|
||||
it('rejects the prompt RPC when a turn fails (no misleading end_turn)', async () => {
|
||||
// ACP has no "error" stop reason; a failed turn must surface as a rejected session/prompt,
|
||||
// not a normal end_turn that hides the failure from the client.
|
||||
// ACP has no "error" stop reason; a failed turn must surface as a rejected
|
||||
// session/prompt, not a normal end_turn that hides the failure from the
|
||||
// client. The bridge rejects via the turn/end{error} log record.
|
||||
harness = await makeBridgeHarness({ storageDir, script: [errorResponse('provider boom')] })
|
||||
const sessionId = await newSession(harness)
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
|
||||
@@ -79,9 +80,12 @@ describe('acp bridge — turn outcomes', () => {
|
||||
})
|
||||
|
||||
it('the REAL bash tool drives the tool-call UI end-to-end: command title + description block + console output', async () => {
|
||||
// Use the SHIPPING tool (dsh-tool-bash + dsh-bash-local), not an inline stand-in, so this
|
||||
// verifies the actual presentCall/presentResult the editor sees (docs/testing.md "prefer
|
||||
// the real implementation over a mock").
|
||||
// Use the SHIPPING tool (dsh-tool-bash + dsh-bash-local), not an inline
|
||||
// stand-in, so this verifies the actual presentCall/presentResult the editor
|
||||
// sees (docs/testing.md "prefer the real implementation over a mock").
|
||||
// The mock MODEL still scripts the tool call (no real LLM needed), but the
|
||||
// tool and executor are real: a real `echo` runs and its real output flows
|
||||
// back through the bridge.
|
||||
harness = await makeBridgeHarness({
|
||||
storageDir,
|
||||
withBash: true,
|
||||
@@ -120,8 +124,11 @@ describe('acp bridge — turn outcomes', () => {
|
||||
})
|
||||
|
||||
it('with the terminal_output capability ON, a real bash call renders as a TERMINAL card (content + _meta + exit)', async () => {
|
||||
// Drive the real bash tool, and advertise the Zed `_meta.terminal_output` capability in
|
||||
// initialize.
|
||||
// Drive the REAL bash tool, and advertise the Zed `_meta.terminal_output`
|
||||
// capability in initialize. The bridge must then emit the terminal CARD: the
|
||||
// description content block THEN a terminal content block + `_meta.terminal_info`
|
||||
// (cwd header) on the call, and `_meta.terminal_output`/`terminal_exit` on the
|
||||
// result — and OMIT the update's text content (it would clobber the card).
|
||||
harness = await makeBridgeHarness({
|
||||
storageDir,
|
||||
withBash: true,
|
||||
@@ -157,7 +164,11 @@ describe('acp bridge — turn outcomes', () => {
|
||||
})
|
||||
|
||||
it('the terminal capability is snapshotted per-session: a later initialize cannot desync a call/result', async () => {
|
||||
// The session is created with the capability ON.
|
||||
// The session is created with the capability ON. A SECOND initialize then
|
||||
// turns it OFF at the connection level — but this session keeps its snapshot,
|
||||
// so its bash call STILL renders as a terminal card (call + result agree).
|
||||
// Without the snapshot, the result path would re-read the now-OFF capability
|
||||
// and either clobber the card (content sent) or be inconsistent with the call.
|
||||
harness = await makeBridgeHarness({
|
||||
storageDir,
|
||||
withBash: true,
|
||||
@@ -181,9 +192,10 @@ describe('acp bridge — turn outcomes', () => {
|
||||
})
|
||||
|
||||
it('a throwing tool presenter does not break the turn: the bridge falls back generically', async () => {
|
||||
// A buggy tool whose presentCall throws must not fail the live turn — the bridge's
|
||||
// presenter contains the throw (logging via its onError sink) and falls back to the generic
|
||||
// title=name presentation.
|
||||
// A buggy tool whose presentCall throws must not fail the live turn — the
|
||||
// bridge's presenter contains the throw (logging via its onError sink) and
|
||||
// falls back to the generic title=name presentation. Exercises the real
|
||||
// bridge wiring of the per-session presenter's error sink.
|
||||
harness = await makeBridgeHarness({
|
||||
storageDir,
|
||||
script: [toolCallResponse('c1', 'kaboom', { x: 1 }), textResponse('done')],
|
||||
@@ -223,10 +235,9 @@ describe('acp bridge — turn outcomes', () => {
|
||||
expect(failed).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('settles via the log fallback when a prior session/event listener throws (starvation)', async () => {
|
||||
// A peer session/event listener that runs before the bridge's listener throws on turn/end
|
||||
// (prepend: true puts it first). cordis emit stops at the throw, so the bridge's
|
||||
// session/event listener never sees turn/end and cannot settle there.
|
||||
it('settles successfully when an earlier turn/end observer throws', async () => {
|
||||
// Session contains each post-commit observer failure, so a prepended peer
|
||||
// cannot starve the bridge's live turn/end delivery.
|
||||
harness = await makeBridgeHarness({ storageDir, script: [textResponse('answer')] })
|
||||
harness.ctx.on('session/event', (_s, event) => {
|
||||
if (event.type === 'turn/end') throw new Error('peer listener boom')
|
||||
@@ -236,9 +247,7 @@ describe('acp bridge — turn outcomes', () => {
|
||||
expect(res.stopReason).toBe('end_turn')
|
||||
})
|
||||
|
||||
it('log fallback REJECTS when the starved turn ended in error', async () => {
|
||||
// Same starvation as above, but the turn fails: the idle-fallback must
|
||||
// reject the RPC from the logged turn/end{error}, not resolve.
|
||||
it('still rejects a failed turn when an earlier turn/end observer throws', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, script: [errorResponse('starved boom')] })
|
||||
harness.ctx.on('session/event', (_s, event) => {
|
||||
if (event.type === 'turn/end') throw new Error('peer listener boom')
|
||||
@@ -248,21 +257,26 @@ describe('acp bridge — turn outcomes', () => {
|
||||
.rejects.toThrow(/turn failed: starved boom/)
|
||||
})
|
||||
|
||||
it('log fallback infers the owning turn when turn/START capture is starved', async () => {
|
||||
// A peer listener throws on turn/START (not turn/end): the bridge never captures
|
||||
// inflight.turn via the live stream.
|
||||
harness = await makeBridgeHarness({ storageDir, script: [textResponse('never runs')] })
|
||||
it('captures and settles the owning turn when an earlier turn-start observer throws', async () => {
|
||||
// Turn correlation still reaches the bridge after the throwing peer and
|
||||
// captures inflight.turn via the live stream. A throwing turn/start listener
|
||||
// Session contains post-commit callbacks independently.
|
||||
// The model request and normal turn outcome therefore still occur.
|
||||
harness = await makeBridgeHarness({ storageDir, script: [textResponse('answer')] })
|
||||
harness.ctx.on('session/event', (_s, event) => {
|
||||
if (event.type === 'turn/start') throw new Error('peer listener boom on start')
|
||||
}, { prepend: true })
|
||||
const sessionId = await newSession(harness)
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
|
||||
.rejects.toThrow(/turn failed:/)
|
||||
const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
expect(result.stopReason).toBe('end_turn')
|
||||
})
|
||||
|
||||
it('a between-turn injection does not settle the prompt early (message-trigger correlation)', async () => {
|
||||
// A plugin injects context (a one-shot injection-triggered turn) right after the prompt is
|
||||
// queued but before the prompt's own message turn runs.
|
||||
// A plugin injects context (a one-shot injection-triggered turn) right after
|
||||
// the prompt is queued but before the prompt's own message turn runs. The
|
||||
// bridge must NOT mistake the injection turn's turn/end for the prompt's —
|
||||
// it correlates only to message-triggered turns. The prompt settles on its
|
||||
// OWN turn with the real model answer.
|
||||
harness = await makeBridgeHarness({ storageDir, script: [textResponse('real answer')] })
|
||||
const sessionId = await newSession(harness)
|
||||
const agent = harness.ctx.agents.get(AgentId(sessionId))!
|
||||
@@ -310,9 +324,11 @@ describe('acp bridge — turn outcomes', () => {
|
||||
})
|
||||
|
||||
it('cancel right after prompt settles cancelled and leaves the agent idle, no leaked turn', async () => {
|
||||
// Over the async JSON-RPC transport the loop usually wakes before cancel arrives, so this
|
||||
// is a running/mid-step cancel (the synchronous pre-step DROP is unit-tested in
|
||||
// agent-loop/cancel.spec.ts).
|
||||
// Over the async JSON-RPC transport the loop usually wakes before cancel
|
||||
// arrives, so this is a running/mid-step cancel (the synchronous pre-step
|
||||
// DROP is unit-tested in agent-loop/cancel.spec.ts). The ACP-level guarantee:
|
||||
// the prompt settles cancelled, the agent reaches idle, and no second/leaked
|
||||
// turn runs afterward.
|
||||
harness = await makeBridgeHarness({ storageDir, script: [textResponse('answer'), textResponse('leaked')] })
|
||||
const sessionId = await newSession(harness)
|
||||
const promptDone = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
@@ -329,9 +345,10 @@ describe('acp bridge — turn outcomes', () => {
|
||||
})
|
||||
|
||||
it('idle session/cancel then session/prompt runs the prompt (no intervening whenIdle)', async () => {
|
||||
// The ACP bridge settles the cancel RPC synchronously and accepts the next prompt WITHOUT
|
||||
// awaiting quiescence — so this drives cancel→prompt with NO whenIdle() between, the
|
||||
// production race.
|
||||
// The ACP bridge settles the cancel RPC synchronously and accepts the next
|
||||
// prompt WITHOUT awaiting quiescence — so this drives cancel→prompt with NO
|
||||
// whenIdle() between, the production race. An idle cancel must be a no-op that
|
||||
// does NOT drop the following prompt.
|
||||
harness = await makeBridgeHarness({ storageDir, script: [textResponse('real answer')] })
|
||||
const sessionId = await newSession(harness)
|
||||
// Cancel while idle (no prompt in flight) — a no-op.
|
||||
@@ -367,8 +384,10 @@ describe('acp bridge — turn outcomes', () => {
|
||||
})
|
||||
|
||||
it('a cancelled turn\'s late turn/end does not settle the NEXT prompt', async () => {
|
||||
// Regression: prompt A runs; cancel settles A and frees the slot; A's aborted turn/end is
|
||||
// still pending in the loop.
|
||||
// Regression: prompt A runs; cancel settles A and frees the slot; A's
|
||||
// aborted turn/end is still pending in the loop. Prompt B is sent before
|
||||
// A's turn/end arrives. A's late turn/end (an EARLIER turn number) must NOT
|
||||
// settle B — B owns a later turn. B then completes on its OWN turn/end.
|
||||
harness = await makeBridgeHarness({ storageDir, script: ['hang', textResponse('B answer')] })
|
||||
const sessionId = await newSession(harness)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user