Skip to content

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

Closed

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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