Skip to content

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

@noamzbr

Description

@noamzbr

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

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingperfIndicates a performance issue or need for optimization

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions