ACP event subscription architecture causes cross-session pollution and duplicate events #3589

Closed
opened 2026-02-16 17:40:46 -05:00 by yindo · 0 comments
Owner

Originally created by @noamzbr on GitHub (Dec 16, 2025).

Originally assigned to: @rekram1-node on GitHub.

Description

The ACP adapter creates a new event subscription on every newSession() and loadSession() call, rather than using a single subscription like the TUI/Desktop does. This causes two bugs:

  1. Cross-session pollution - Events from session B are sent to session A
  2. Duplicate events - Loading the same session N times causes N× duplicate events

OpenCode version

v1.0.162

Steps to reproduce

Case 1 - Duplicate events from loadSession

Load the same session multiple times, send one prompt. Events are duplicated N× (one per subscription).

// repro-duplicates.ts - Run: bun repro-duplicates.ts
import { spawn } from 'node:child_process'
import { Readable, Writable } from 'node:stream'
import { ndJsonStream, ClientSideConnection } from '@agentclientprotocol/sdk'

const events: string[] = []
await Bun.write('/tmp/ws/test.txt', 'hello')

const proc = spawn('opencode', ['acp'], { stdio: ['pipe', 'pipe', 'inherit'], cwd: '/tmp/ws' })
const conn = new ClientSideConnection(() => ({
  async sessionUpdate(p) { events.push(p.update?.sessionUpdate) },
  async requestPermission(p) { return { outcome: { outcome: 'selected', optionId: p.options[0]?.optionId } } },
  async readTextFile() {}, async writeTextFile() {}, async createTerminal() {},
  async terminalOutput() {}, async killTerminal() {}, async releaseTerminal() { return {} },
}), ndJsonStream(Writable.toWeb(proc.stdin!), Readable.toWeb(proc.stdout!)))

await conn.initialize({ protocolVersion: 1, clientCapabilities: {}, clientInfo: { name: 't', version: '1' } })

const { sessionId } = await conn.newSession({ cwd: '/tmp/ws', mcpServers: [] })
for (let i = 0; i < 4; i++) await conn.loadSession({ sessionId, cwd: '/tmp/ws', mcpServers: [] })
// Now have 5 subscriptions

events.length = 0
await conn.prompt({ sessionId, prompt: [{ type: 'text', text: 'Read test.txt' }] })
await new Promise(r => setTimeout(r, 2000))

console.log('tool_call events:', events.filter(e => e === 'tool_call').length)
proc.kill()

Expected: tool_call events: 1
Actual: tool_call events: 5 (one per subscription)

Case 2 - Cross-session pollution

Create two sessions in the same directory, prompt only session B. Session A incorrectly receives B's events.

// repro-cross-session.ts - Run: bun repro-cross-session.ts
import { spawn } from 'node:child_process'
import { Readable, Writable } from 'node:stream'
import { ndJsonStream, ClientSideConnection } from '@agentclientprotocol/sdk'

let idA = '', idB = ''
const eventsA: string[] = [], eventsB: string[] = []

const proc = spawn('opencode', ['acp'], { stdio: ['pipe', 'pipe', 'inherit'], cwd: '/tmp/ws' })
const conn = new ClientSideConnection(() => ({
  async sessionUpdate(p) {
    if (p.sessionId === idA) eventsA.push(p.update?.sessionUpdate)
    else if (p.sessionId === idB) eventsB.push(p.update?.sessionUpdate)
  },
  async requestPermission(p) { return { outcome: { outcome: 'selected', optionId: p.options[0]?.optionId } } },
  async readTextFile() {}, async writeTextFile() {}, async createTerminal() {},
  async terminalOutput() {}, async killTerminal() {}, async releaseTerminal() { return {} },
}), ndJsonStream(Writable.toWeb(proc.stdin!), Readable.toWeb(proc.stdout!)))

await conn.initialize({ protocolVersion: 1, clientCapabilities: {}, clientInfo: { name: 't', version: '1' } })

idA = (await conn.newSession({ cwd: '/tmp/ws', mcpServers: [] })).sessionId
idB = (await conn.newSession({ cwd: '/tmp/ws', mcpServers: [] })).sessionId
eventsA.length = 0; eventsB.length = 0

await conn.prompt({ sessionId: idB, prompt: [{ type: 'text', text: 'hi' }] })
await new Promise(r => setTimeout(r, 2000))

console.log('Session A events:', eventsA.length)  // BUG: Should be 0
console.log('Session B events:', eventsB.length)
proc.kill()

Expected: Session A events: 0
Actual: Session A events: 3 (receives session B's events)

Screenshot and/or share link

No response

Operating System

macOS 15.6.1

Terminal

zsh

Originally created by @noamzbr on GitHub (Dec 16, 2025). Originally assigned to: @rekram1-node on GitHub. ### Description The ACP adapter creates a new event subscription on every newSession() and loadSession() call, rather than using a single subscription like the TUI/Desktop does. This causes two bugs: 1. Cross-session pollution - Events from session B are sent to session A 2. Duplicate events - Loading the same session N times causes N× duplicate events ### OpenCode version v1.0.162 ### Steps to reproduce ### Case 1 - Duplicate events from loadSession Load the same session multiple times, send one prompt. Events are duplicated N× (one per subscription). ``` // repro-duplicates.ts - Run: bun repro-duplicates.ts import { spawn } from 'node:child_process' import { Readable, Writable } from 'node:stream' import { ndJsonStream, ClientSideConnection } from '@agentclientprotocol/sdk' const events: string[] = [] await Bun.write('/tmp/ws/test.txt', 'hello') const proc = spawn('opencode', ['acp'], { stdio: ['pipe', 'pipe', 'inherit'], cwd: '/tmp/ws' }) const conn = new ClientSideConnection(() => ({ async sessionUpdate(p) { events.push(p.update?.sessionUpdate) }, async requestPermission(p) { return { outcome: { outcome: 'selected', optionId: p.options[0]?.optionId } } }, async readTextFile() {}, async writeTextFile() {}, async createTerminal() {}, async terminalOutput() {}, async killTerminal() {}, async releaseTerminal() { return {} }, }), ndJsonStream(Writable.toWeb(proc.stdin!), Readable.toWeb(proc.stdout!))) await conn.initialize({ protocolVersion: 1, clientCapabilities: {}, clientInfo: { name: 't', version: '1' } }) const { sessionId } = await conn.newSession({ cwd: '/tmp/ws', mcpServers: [] }) for (let i = 0; i < 4; i++) await conn.loadSession({ sessionId, cwd: '/tmp/ws', mcpServers: [] }) // Now have 5 subscriptions events.length = 0 await conn.prompt({ sessionId, prompt: [{ type: 'text', text: 'Read test.txt' }] }) await new Promise(r => setTimeout(r, 2000)) console.log('tool_call events:', events.filter(e => e === 'tool_call').length) proc.kill() ``` Expected: tool_call events: 1 Actual: tool_call events: 5 (one per subscription) ### Case 2 - Cross-session pollution Create two sessions in the same directory, prompt only session B. Session A incorrectly receives B's events. ``` // repro-cross-session.ts - Run: bun repro-cross-session.ts import { spawn } from 'node:child_process' import { Readable, Writable } from 'node:stream' import { ndJsonStream, ClientSideConnection } from '@agentclientprotocol/sdk' let idA = '', idB = '' const eventsA: string[] = [], eventsB: string[] = [] const proc = spawn('opencode', ['acp'], { stdio: ['pipe', 'pipe', 'inherit'], cwd: '/tmp/ws' }) const conn = new ClientSideConnection(() => ({ async sessionUpdate(p) { if (p.sessionId === idA) eventsA.push(p.update?.sessionUpdate) else if (p.sessionId === idB) eventsB.push(p.update?.sessionUpdate) }, async requestPermission(p) { return { outcome: { outcome: 'selected', optionId: p.options[0]?.optionId } } }, async readTextFile() {}, async writeTextFile() {}, async createTerminal() {}, async terminalOutput() {}, async killTerminal() {}, async releaseTerminal() { return {} }, }), ndJsonStream(Writable.toWeb(proc.stdin!), Readable.toWeb(proc.stdout!))) await conn.initialize({ protocolVersion: 1, clientCapabilities: {}, clientInfo: { name: 't', version: '1' } }) idA = (await conn.newSession({ cwd: '/tmp/ws', mcpServers: [] })).sessionId idB = (await conn.newSession({ cwd: '/tmp/ws', mcpServers: [] })).sessionId eventsA.length = 0; eventsB.length = 0 await conn.prompt({ sessionId: idB, prompt: [{ type: 'text', text: 'hi' }] }) await new Promise(r => setTimeout(r, 2000)) console.log('Session A events:', eventsA.length) // BUG: Should be 0 console.log('Session B events:', eventsB.length) proc.kill() ``` Expected: Session A events: 0 Actual: Session A events: 3 (receives session B's events) ### Screenshot and/or share link _No response_ ### Operating System macOS 15.6.1 ### Terminal zsh
yindo added the bugperf labels 2026-02-16 17:40:46 -05:00
yindo closed this issue 2026-02-16 17:40:46 -05:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: anomalyco/opencode#3589