From 2bdb4a4900bf28c47aeb75cca0fa46b2d236494a Mon Sep 17 00:00:00 2001 From: ajspig Date: Tue, 29 Sep 2026 14:29:44 -0400 Subject: [PATCH 1/4] fix(v2): scope event handling to the plugin instance's location OpenCode 2 runs one plugin instance per location but `event.subscribe()` is server-wide, so every instance saw every session's events and each assistant reply was hydrated and captured once per open directory: with TUIs in demo and demo2, a turn in demo2 logged "Honcho session initialized" twice (rootDir demo and demo2) and "Honcho captured assistant message" twice for one messageId, and the reply landed in both per-directory Honcho sessions. Under the background service the extra instance rooted at the service's default location wrote a stray session. Act only on sessions of this instance's location. Session events carry the location they were published from in their envelope; the ones that do not (session.execution.*) are resolved from what earlier events said, or once through session.get, and remembered until session.deleted. A session that reaches this instance's location-scoped prompt hook is its own as well. Directories are compared after resolving symlinks and trailing separators. The OPENCODE_HONCHO_TRACE_EVENTS line now says which instance saw the event and whether it acted. Test: two instances with different locations over one shared event stream, covering labelled events, unlabelled turn boundaries resolved via session.get, ownership via the prompt hook, and deletion. Co-Authored-By: Claude Fable 5.1 --- src/v2/runtime.ts | 65 ++++++++++++- tests/v2-wire.test.js | 207 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 255 insertions(+), 17 deletions(-) diff --git a/src/v2/runtime.ts b/src/v2/runtime.ts index 8e7bdba..0eba2d1 100644 --- a/src/v2/runtime.ts +++ b/src/v2/runtime.ts @@ -1,3 +1,5 @@ +import { realpathSync } from "node:fs" +import path from "node:path" import { z } from "zod" import { createHonchoCore, ensureHonchoSkillInstalled, type HostAdapter, type HostLogLevel } from "../index.js" import { isRecord, timestampToIso } from "../core.js" @@ -25,6 +27,28 @@ const eventData = (event: V2Event): Record => { } const str = (value: unknown) => (typeof value === "string" && value ? value : null) +/** A directory in the form locations are compared in: absolute, symlinks resolved, no trailing separator. */ +const canonicalDirectories = new Map() +const canonicalDirectory = (directory: string) => { + let canonical = canonicalDirectories.get(directory) + if (canonical === undefined) { + const resolved = path.resolve(directory) + try { + canonical = realpathSync.native(resolved) + } catch { + canonical = resolved + } + canonicalDirectories.set(directory, canonical) + } + return canonical +} + +/** The location an event was published from, when its envelope names one. */ +const eventLocationDirectory = (event: V2Event) => { + const location: unknown = isRecord(event) ? event.location : undefined + return isRecord(location) ? str(location.directory) : null +} + /** * Logger for the v2 path. Always stderr: the OpenCode 2 server can run with stdout as its RPC * transport (`serve --stdio`), so a plugin must never write to stdout. @@ -63,7 +87,35 @@ export const setup = async (ctx: PluginContext) => { // prompt hook's edits become the persisted user text, so it rides in the request context instead. const pendingRecall = new Map() + // OpenCode 2 runs one plugin instance per location, but `event.subscribe()` is server-wide: + // every instance sees every session's events, and acting on all of them wrote each reply into + // one Honcho session per open location. Only sessions in this location are ours. An event + // names the location it was published from; when one does not, the session's location is + // looked up once through the session API and remembered until the session is deleted. The + // session hooks are location-scoped, so a session that reaches them is ours as well. + const here = canonicalDirectory(ctx.location.directory) + const sessionDirectories = new Map() + const ownsSession = async (event: V2Event, sessionID: string) => { + const published = eventLocationDirectory(event) + if (published) { + const directory = canonicalDirectory(published) + sessionDirectories.set(sessionID, directory) + return directory === here + } + let directory = sessionDirectories.get(sessionID) + if (directory === undefined) { + try { + directory = canonicalDirectory((await ctx.session.get({ sessionID })).location.directory) + } catch { + return false // Gone already, or not visible from this location: nothing of ours to act on. + } + sessionDirectories.set(sessionID, directory) + } + return directory === here + } + await ctx.session.hook("prompt", async (event) => { + sessionDirectories.set(event.sessionID, here) const text = typeof event.prompt.text === "string" ? event.prompt.text.trim() : "" if (!text) return const block = await core.captureUserPrompt( @@ -115,7 +167,18 @@ export const setup = async (ctx: PluginContext) => { const data = eventData(event) const sessionID = str(data.sessionID) if (!sessionID) return - if (process.env.OPENCODE_HONCHO_TRACE_EVENTS) await core.log("debug", "event", { type: event.type, sessionId: sessionID }) + const owned = await ownsSession(event, sessionID) + if (event.type === "session.deleted") sessionDirectories.delete(sessionID) + if (process.env.OPENCODE_HONCHO_TRACE_EVENTS) { + await core.log("debug", "event", { + type: event.type, + sessionId: sessionID, + location: eventLocationDirectory(event), + directory: ctx.location.directory, + owned, + }) + } + if (!owned) return switch (event.type) { case "session.created": { // Best effort, never blocks startup, attempted even when Honcho is not configured. diff --git a/tests/v2-wire.test.js b/tests/v2-wire.test.js index 357818a..fa790d7 100644 --- a/tests/v2-wire.test.js +++ b/tests/v2-wire.test.js @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { mkdtemp, writeFile } from "node:fs/promises" +import { mkdir, mkdtemp, symlink, writeFile } from "node:fs/promises" import os from "node:os" import path from "node:path" @@ -7,32 +7,44 @@ import path from "node:path" // v2 `setup()` against a recording context. No Honcho traffic: config has no API key, so the // core skips network work but still registers every hook and tool. -const fakeContext = () => { +// `subscribe` can be shared between contexts to model one server-wide event stream feeding several +// plugin instances; `sessions` is what `session.get` answers with, keyed by session id. +const fakeContext = ({ directory = process.cwd(), subscribe, sessions = {} } = {}) => { const hooks = { session: [], tool: [], shell: [] } const tools = [] const subscribers = [] + const sessionGets = [] const hook = (bucket) => async (name, callback) => { bucket.push({ name, callback }) return { dispose: async () => {} } } const ctx = { app: { name: "opencode", version: "2.0.16", channel: "latest" }, - location: { directory: process.cwd(), project: { id: "prj", directory: process.cwd(), canonical: process.cwd() } }, + location: { directory, project: { id: "prj", directory, canonical: directory } }, options: {}, event: { - subscribe: ({ signal } = {}) => ({ - [Symbol.asyncIterator]() { - return { - next: () => - new Promise((resolve) => { - subscribers.push(resolve) - signal?.addEventListener("abort", () => resolve({ done: true }), { once: true }) - }), - } - }, - }), + subscribe: + subscribe ?? + (({ signal } = {}) => ({ + [Symbol.asyncIterator]() { + return { + next: () => + new Promise((resolve) => { + subscribers.push(resolve) + signal?.addEventListener("abort", () => resolve({ done: true }), { once: true }) + }), + } + }, + })), + }, + session: { + hook: hook(hooks.session), + get: async ({ sessionID }) => { + sessionGets.push(sessionID) + if (!sessions[sessionID]) throw new Error(`session ${sessionID} not found`) + return sessions[sessionID] + }, }, - session: { hook: hook(hooks.session) }, shell: { hook: hook(hooks.shell) }, tool: { hook: hook(hooks.tool), @@ -47,7 +59,7 @@ const fakeContext = () => { while (subscribers.length === 0) await new Promise((resolve) => setTimeout(resolve, 5)) subscribers.shift()({ value: { type, data, created: Date.now() }, done: false }) } - return { ctx, hooks, tools, find, emit } + return { ctx, hooks, tools, find, emit, sessionGets } } describe("OpenCode 2 entrypoints", () => { @@ -154,3 +166,166 @@ describe("OpenCode 2 cleanup", () => { } }) }) + +describe("OpenCode 2 event scoping", () => { + // One server, one event stream, one plugin instance per location. Each instance must act only on + // the sessions of its own location: session events name the location they were published from, + // turn-boundary events (`session.execution.*`) do not and are resolved through the session API. + test("two instances over one event stream only act on their own location's sessions", async () => { + const ENV = [ + "HONCHO_API_KEY", + "HONCHO_URL", + "HONCHO_BASE_URL", + "HONCHO_WORKSPACE", + "HONCHO_WORKSPACE_ID", + "HONCHO_PEER_NAME", + "HONCHO_AI_PEER", + "OPENCODE_CONFIG_DIR", + "OPENCODE_HONCHO_TRACE_EVENTS", + ] + const savedEnv = Object.fromEntries(ENV.map((key) => [key, process.env[key]])) + for (const key of ENV) delete process.env[key] + const savedError = console.error + const lines = [] + console.error = (...args) => lines.push(args.map(String).join(" ")) + const cleanups = [] + try { + const root = await mkdtemp(path.join(os.tmpdir(), "honcho-v2-scope-")) + process.env.OPENCODE_CONFIG_DIR = path.join(root, "opencode") + process.env.OPENCODE_HONCHO_TRACE_EVENTS = "1" + const dirA = path.join(root, "a") + const dirB = path.join(root, "b") + const linkA = path.join(root, "a-link") + await mkdir(dirA) + await mkdir(dirB) + await symlink(dirA, linkA) + // No API key and the default (remote) baseUrl: every runtime operation stops at the + // "missing an API key" warning, which names the workspace, i.e. the instance that tried. + const configFor = async (name) => { + const file = path.join(root, `${name}.json`) + await writeFile(file, JSON.stringify({ peerName: "wire", hosts: { opencode: { workspace: `ws-${name}` } } })) + return file + } + + const waiting = [] + const stream = { + [Symbol.asyncIterator]() { + return { next: () => new Promise((resolve) => waiting.push(resolve)) } + }, + } + const settle = async () => { + while (waiting.length < 2) await new Promise((resolve) => setTimeout(resolve, 5)) + } + const emit = async (event) => { + await settle() + for (const resolve of waiting.splice(0)) resolve({ value: event, done: false }) + await settle() + } + const sessions = { ses_a: { id: "ses_a", location: { directory: dirA } }, ses_b: { id: "ses_b", location: { directory: dirB } } } + // Instance A is addressed through a symlink with a trailing slash; events carry the real path. + const a = fakeContext({ directory: `${linkA}/`, subscribe: () => stream, sessions }) + const b = fakeContext({ directory: dirB, subscribe: () => stream, sessions }) + a.ctx.options = { configPath: await configFor("a") } + b.ctx.options = { configPath: await configFor("b") } + const mod = await import("../dist/server.js") + cleanups.push(await mod.default.setup(a.ctx), await mod.default.setup(b.ctx)) + + let seq = 0 + const event = (type, data, location) => ({ + id: `evt_${++seq}`, + created: Date.now(), + type, + ...(location ? { location: { directory: location } } : {}), + data, + }) + const turn = (sessionID, messageID, location) => [ + event("session.step.started", { sessionID, assistantMessageID: messageID, model: { providerID: "anthropic", id: "claude" } }, location), + event("session.text.ended", { sessionID, assistantMessageID: messageID, ordinal: 0, text: "pong" }, location), + event("session.step.ended", { sessionID, assistantMessageID: messageID, finish: "stop" }, location), + event("session.execution.succeeded", { sessionID }), // never carries a location + ] + const parse = (line) => { + const match = /^\[opencode-honcho\] (\w+): (.*?) (\{.*\})$/.exec(line) + return match && { level: match[1], message: match[2], extra: JSON.parse(match[3]) } + } + const owned = (directory) => + lines + .map(parse) + .filter((entry) => entry?.message === "event" && entry.extra.directory === directory) + .map((entry) => [entry.extra.type, entry.extra.sessionId, entry.extra.owned]) + const attempts = (workspace) => + lines.map(parse).filter((entry) => entry?.level === "warn" && entry.extra.workspaceId === workspace).length + const reset = () => lines.splice(0) + + // Session A: created and answered in location A. Only A hydrates and captures. + await emit(event("session.created", { sessionID: "ses_a", projectID: "prj", location: { directory: dirA } }, dirA)) + for (const e of turn("ses_a", "msg_a1", dirA)) await emit(e) + expect(owned(`${linkA}/`)).toEqual([ + ["session.created", "ses_a", true], + ["session.step.started", "ses_a", true], + ["session.text.ended", "ses_a", true], + ["session.step.ended", "ses_a", true], + ["session.execution.succeeded", "ses_a", true], + ]) + expect(owned(dirB).map((entry) => entry[2])).toEqual([false, false, false, false, false]) + expect(attempts("ws-a")).toBeGreaterThan(0) + expect(attempts("ws-b")).toBe(0) + // The unlabelled turn boundary was resolved from what the labelled events said: no lookup. + expect(a.sessionGets).toEqual([]) + expect(b.sessionGets).toEqual([]) + reset() + + // Session B, with no location on any event: resolved once through session.get, then remembered. + await emit(event("session.created", { sessionID: "ses_b", projectID: "prj", location: { directory: dirB } })) + for (const e of turn("ses_b", "msg_b1")) await emit(e) + expect(owned(`${linkA}/`).map((entry) => entry[2])).toEqual([false, false, false, false, false]) + expect(owned(dirB).map((entry) => entry[2])).toEqual([true, true, true, true, true]) + expect(attempts("ws-a")).toBe(0) + expect(attempts("ws-b")).toBeGreaterThan(0) + expect(a.sessionGets).toEqual(["ses_b"]) + expect(b.sessionGets).toEqual(["ses_b"]) + reset() + + // A session that reached this instance's (location-scoped) prompt hook is its own, even when + // nothing else says where it lives. + await a.find(a.hooks.session, "prompt")({ sessionID: "ses_c", messageID: "msg_c0", prompt: { text: "hello" }, delivery: {} }) + await emit(event("session.execution.succeeded", { sessionID: "ses_c" })) + expect(owned(`${linkA}/`)).toEqual([["session.execution.succeeded", "ses_c", true]]) + expect(owned(dirB)).toEqual([["session.execution.succeeded", "ses_c", false]]) + expect(a.sessionGets).toEqual(["ses_b"]) + expect(b.sessionGets).toEqual(["ses_b", "ses_c"]) // looked up, not found, not ours + reset() + + // Deletion is handled by the owner only, and forgets the session in both instances: the next + // unlabelled event for it needs a fresh lookup, which fails once the session is gone. + await emit(event("session.deleted", { sessionID: "ses_a" }, dirA)) + await emit(event("session.deleted", { sessionID: "ses_b" })) + expect(owned(`${linkA}/`)).toEqual([ + ["session.deleted", "ses_a", true], + ["session.deleted", "ses_b", false], + ]) + expect(owned(dirB)).toEqual([ + ["session.deleted", "ses_a", false], + ["session.deleted", "ses_b", true], + ]) + delete sessions.ses_a + delete sessions.ses_b + reset() + await emit(event("session.execution.succeeded", { sessionID: "ses_a" })) + await emit(event("session.execution.succeeded", { sessionID: "ses_b" })) + expect(owned(`${linkA}/`).map((entry) => entry[2])).toEqual([false, false]) + expect(owned(dirB).map((entry) => entry[2])).toEqual([false, false]) + expect(a.sessionGets).toEqual(["ses_b", "ses_a", "ses_b"]) + expect(b.sessionGets).toEqual(["ses_b", "ses_c", "ses_a", "ses_b"]) + expect(attempts("ws-a")).toBe(0) + expect(attempts("ws-b")).toBe(0) + } finally { + for (const cleanup of cleanups) cleanup() + console.error = savedError + for (const key of ENV) { + if (savedEnv[key] === undefined) delete process.env[key] + else process.env[key] = savedEnv[key] + } + } + }) +}) From dfe4bd053bef99797866490e7ff2666acc31b01b Mon Sep 17 00:00:00 2001 From: ajspig Date: Fri, 2 Oct 2026 09:55:43 -0400 Subject: [PATCH 2/4] refactor(v2): simplify location gate, drop session lookup and path canonicalization Every session event the plugin acts on carries the location it was published from, and OpenCode identifies a location by that directory string, so compare it as reported. Events without a location (session.execution.*) follow the directory recorded for their session; an unknown session is ignored, which is safe because pending assistant text only exists after a located event. Shrink the scoping test to match. Co-Authored-By: Claude Sonnet 5.5 --- src/v2/runtime.ts | 48 ++-------- tests/v2-wire.test.js | 205 ++++++++++-------------------------------- 2 files changed, 50 insertions(+), 203 deletions(-) diff --git a/src/v2/runtime.ts b/src/v2/runtime.ts index 0eba2d1..7d6fac0 100644 --- a/src/v2/runtime.ts +++ b/src/v2/runtime.ts @@ -1,5 +1,3 @@ -import { realpathSync } from "node:fs" -import path from "node:path" import { z } from "zod" import { createHonchoCore, ensureHonchoSkillInstalled, type HostAdapter, type HostLogLevel } from "../index.js" import { isRecord, timestampToIso } from "../core.js" @@ -27,23 +25,6 @@ const eventData = (event: V2Event): Record => { } const str = (value: unknown) => (typeof value === "string" && value ? value : null) -/** A directory in the form locations are compared in: absolute, symlinks resolved, no trailing separator. */ -const canonicalDirectories = new Map() -const canonicalDirectory = (directory: string) => { - let canonical = canonicalDirectories.get(directory) - if (canonical === undefined) { - const resolved = path.resolve(directory) - try { - canonical = realpathSync.native(resolved) - } catch { - canonical = resolved - } - canonicalDirectories.set(directory, canonical) - } - return canonical -} - -/** The location an event was published from, when its envelope names one. */ const eventLocationDirectory = (event: V2Event) => { const location: unknown = isRecord(event) ? event.location : undefined return isRecord(location) ? str(location.directory) : null @@ -87,31 +68,12 @@ export const setup = async (ctx: PluginContext) => { // prompt hook's edits become the persisted user text, so it rides in the request context instead. const pendingRecall = new Map() - // OpenCode 2 runs one plugin instance per location, but `event.subscribe()` is server-wide: - // every instance sees every session's events, and acting on all of them wrote each reply into - // one Honcho session per open location. Only sessions in this location are ours. An event - // names the location it was published from; when one does not, the session's location is - // looked up once through the session API and remembered until the session is deleted. The - // session hooks are location-scoped, so a session that reaches them is ours as well. - const here = canonicalDirectory(ctx.location.directory) + const here = ctx.location.directory const sessionDirectories = new Map() - const ownsSession = async (event: V2Event, sessionID: string) => { + const ownsSession = (event: V2Event, sessionID: string) => { const published = eventLocationDirectory(event) - if (published) { - const directory = canonicalDirectory(published) - sessionDirectories.set(sessionID, directory) - return directory === here - } - let directory = sessionDirectories.get(sessionID) - if (directory === undefined) { - try { - directory = canonicalDirectory((await ctx.session.get({ sessionID })).location.directory) - } catch { - return false // Gone already, or not visible from this location: nothing of ours to act on. - } - sessionDirectories.set(sessionID, directory) - } - return directory === here + if (published) sessionDirectories.set(sessionID, published) + return (published ?? sessionDirectories.get(sessionID)) === here } await ctx.session.hook("prompt", async (event) => { @@ -167,7 +129,7 @@ export const setup = async (ctx: PluginContext) => { const data = eventData(event) const sessionID = str(data.sessionID) if (!sessionID) return - const owned = await ownsSession(event, sessionID) + const owned = ownsSession(event, sessionID) if (event.type === "session.deleted") sessionDirectories.delete(sessionID) if (process.env.OPENCODE_HONCHO_TRACE_EVENTS) { await core.log("debug", "event", { diff --git a/tests/v2-wire.test.js b/tests/v2-wire.test.js index fa790d7..38e67c6 100644 --- a/tests/v2-wire.test.js +++ b/tests/v2-wire.test.js @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { mkdir, mkdtemp, symlink, writeFile } from "node:fs/promises" +import { mkdir, mkdtemp, writeFile } from "node:fs/promises" import os from "node:os" import path from "node:path" @@ -7,13 +7,11 @@ import path from "node:path" // v2 `setup()` against a recording context. No Honcho traffic: config has no API key, so the // core skips network work but still registers every hook and tool. -// `subscribe` can be shared between contexts to model one server-wide event stream feeding several -// plugin instances; `sessions` is what `session.get` answers with, keyed by session id. -const fakeContext = ({ directory = process.cwd(), subscribe, sessions = {} } = {}) => { +// A shared `subscribe` models one server-wide stream feeding several instances. +const fakeContext = ({ directory = process.cwd(), subscribe } = {}) => { const hooks = { session: [], tool: [], shell: [] } const tools = [] const subscribers = [] - const sessionGets = [] const hook = (bucket) => async (name, callback) => { bucket.push({ name, callback }) return { dispose: async () => {} } @@ -39,11 +37,6 @@ const fakeContext = ({ directory = process.cwd(), subscribe, sessions = {} } = { }, session: { hook: hook(hooks.session), - get: async ({ sessionID }) => { - sessionGets.push(sessionID) - if (!sessions[sessionID]) throw new Error(`session ${sessionID} not found`) - return sessions[sessionID] - }, }, shell: { hook: hook(hooks.shell) }, tool: { @@ -59,7 +52,7 @@ const fakeContext = ({ directory = process.cwd(), subscribe, sessions = {} } = { while (subscribers.length === 0) await new Promise((resolve) => setTimeout(resolve, 5)) subscribers.shift()({ value: { type, data, created: Date.now() }, done: false }) } - return { ctx, hooks, tools, find, emit, sessionGets } + return { ctx, hooks, tools, find, emit } } describe("OpenCode 2 entrypoints", () => { @@ -168,163 +161,55 @@ describe("OpenCode 2 cleanup", () => { }) describe("OpenCode 2 event scoping", () => { - // One server, one event stream, one plugin instance per location. Each instance must act only on - // the sessions of its own location: session events name the location they were published from, - // turn-boundary events (`session.execution.*`) do not and are resolved through the session API. - test("two instances over one event stream only act on their own location's sessions", async () => { - const ENV = [ - "HONCHO_API_KEY", - "HONCHO_URL", - "HONCHO_BASE_URL", - "HONCHO_WORKSPACE", - "HONCHO_WORKSPACE_ID", - "HONCHO_PEER_NAME", - "HONCHO_AI_PEER", - "OPENCODE_CONFIG_DIR", - "OPENCODE_HONCHO_TRACE_EVENTS", - ] - const savedEnv = Object.fromEntries(ENV.map((key) => [key, process.env[key]])) - for (const key of ENV) delete process.env[key] + test("an instance only acts on events from its own location", async () => { + const keys = ["HONCHO_API_KEY", "HONCHO_URL", "HONCHO_BASE_URL", "OPENCODE_CONFIG_DIR", "OPENCODE_HONCHO_TRACE_EVENTS"] + const saved = Object.fromEntries(keys.map((key) => [key, process.env[key]])) + const root = await mkdtemp(path.join(os.tmpdir(), "honcho-v2-scope-")) + const configPath = path.join(root, "config.json") + await writeFile(configPath, JSON.stringify({ peerName: "wire" })) + for (const key of keys) delete process.env[key] + process.env.OPENCODE_CONFIG_DIR = path.join(root, "opencode") + process.env.OPENCODE_HONCHO_TRACE_EVENTS = "1" const savedError = console.error const lines = [] console.error = (...args) => lines.push(args.map(String).join(" ")) - const cleanups = [] + const waiting = [] + const stream = { [Symbol.asyncIterator]: () => ({ next: () => new Promise((resolve) => waiting.push(resolve)) }) } + const a = fakeContext({ directory: "/work/a", subscribe: () => stream }) + const b = fakeContext({ directory: "/work/b", subscribe: () => stream }) + a.ctx.options = { configPath } + b.ctx.options = { configPath } + const mod = await import("../dist/server.js") + const cleanups = [await mod.default.setup(a.ctx), await mod.default.setup(b.ctx)] + const emit = async (type, sessionID, location) => { + while (waiting.length < 2) await new Promise((resolve) => setTimeout(resolve, 5)) + const event = { id: type, created: Date.now(), type, ...(location ? { location: { directory: location } } : {}), data: { sessionID } } + for (const resolve of waiting.splice(0)) resolve({ value: event, done: false }) + while (waiting.length < 2) await new Promise((resolve) => setTimeout(resolve, 5)) + } + const ownedBy = () => + lines + .map((line) => /event (\{.*\})$/.exec(line)) + .filter(Boolean) + .map((match) => JSON.parse(match[1])) + .filter((e) => e.owned) + .map((e) => `${e.directory}:${e.type}:${e.sessionId}`) try { - const root = await mkdtemp(path.join(os.tmpdir(), "honcho-v2-scope-")) - process.env.OPENCODE_CONFIG_DIR = path.join(root, "opencode") - process.env.OPENCODE_HONCHO_TRACE_EVENTS = "1" - const dirA = path.join(root, "a") - const dirB = path.join(root, "b") - const linkA = path.join(root, "a-link") - await mkdir(dirA) - await mkdir(dirB) - await symlink(dirA, linkA) - // No API key and the default (remote) baseUrl: every runtime operation stops at the - // "missing an API key" warning, which names the workspace, i.e. the instance that tried. - const configFor = async (name) => { - const file = path.join(root, `${name}.json`) - await writeFile(file, JSON.stringify({ peerName: "wire", hosts: { opencode: { workspace: `ws-${name}` } } })) - return file - } - - const waiting = [] - const stream = { - [Symbol.asyncIterator]() { - return { next: () => new Promise((resolve) => waiting.push(resolve)) } - }, - } - const settle = async () => { - while (waiting.length < 2) await new Promise((resolve) => setTimeout(resolve, 5)) - } - const emit = async (event) => { - await settle() - for (const resolve of waiting.splice(0)) resolve({ value: event, done: false }) - await settle() - } - const sessions = { ses_a: { id: "ses_a", location: { directory: dirA } }, ses_b: { id: "ses_b", location: { directory: dirB } } } - // Instance A is addressed through a symlink with a trailing slash; events carry the real path. - const a = fakeContext({ directory: `${linkA}/`, subscribe: () => stream, sessions }) - const b = fakeContext({ directory: dirB, subscribe: () => stream, sessions }) - a.ctx.options = { configPath: await configFor("a") } - b.ctx.options = { configPath: await configFor("b") } - const mod = await import("../dist/server.js") - cleanups.push(await mod.default.setup(a.ctx), await mod.default.setup(b.ctx)) - - let seq = 0 - const event = (type, data, location) => ({ - id: `evt_${++seq}`, - created: Date.now(), - type, - ...(location ? { location: { directory: location } } : {}), - data, - }) - const turn = (sessionID, messageID, location) => [ - event("session.step.started", { sessionID, assistantMessageID: messageID, model: { providerID: "anthropic", id: "claude" } }, location), - event("session.text.ended", { sessionID, assistantMessageID: messageID, ordinal: 0, text: "pong" }, location), - event("session.step.ended", { sessionID, assistantMessageID: messageID, finish: "stop" }, location), - event("session.execution.succeeded", { sessionID }), // never carries a location - ] - const parse = (line) => { - const match = /^\[opencode-honcho\] (\w+): (.*?) (\{.*\})$/.exec(line) - return match && { level: match[1], message: match[2], extra: JSON.parse(match[3]) } - } - const owned = (directory) => - lines - .map(parse) - .filter((entry) => entry?.message === "event" && entry.extra.directory === directory) - .map((entry) => [entry.extra.type, entry.extra.sessionId, entry.extra.owned]) - const attempts = (workspace) => - lines.map(parse).filter((entry) => entry?.level === "warn" && entry.extra.workspaceId === workspace).length - const reset = () => lines.splice(0) - - // Session A: created and answered in location A. Only A hydrates and captures. - await emit(event("session.created", { sessionID: "ses_a", projectID: "prj", location: { directory: dirA } }, dirA)) - for (const e of turn("ses_a", "msg_a1", dirA)) await emit(e) - expect(owned(`${linkA}/`)).toEqual([ - ["session.created", "ses_a", true], - ["session.step.started", "ses_a", true], - ["session.text.ended", "ses_a", true], - ["session.step.ended", "ses_a", true], - ["session.execution.succeeded", "ses_a", true], - ]) - expect(owned(dirB).map((entry) => entry[2])).toEqual([false, false, false, false, false]) - expect(attempts("ws-a")).toBeGreaterThan(0) - expect(attempts("ws-b")).toBe(0) - // The unlabelled turn boundary was resolved from what the labelled events said: no lookup. - expect(a.sessionGets).toEqual([]) - expect(b.sessionGets).toEqual([]) - reset() - - // Session B, with no location on any event: resolved once through session.get, then remembered. - await emit(event("session.created", { sessionID: "ses_b", projectID: "prj", location: { directory: dirB } })) - for (const e of turn("ses_b", "msg_b1")) await emit(e) - expect(owned(`${linkA}/`).map((entry) => entry[2])).toEqual([false, false, false, false, false]) - expect(owned(dirB).map((entry) => entry[2])).toEqual([true, true, true, true, true]) - expect(attempts("ws-a")).toBe(0) - expect(attempts("ws-b")).toBeGreaterThan(0) - expect(a.sessionGets).toEqual(["ses_b"]) - expect(b.sessionGets).toEqual(["ses_b"]) - reset() - - // A session that reached this instance's (location-scoped) prompt hook is its own, even when - // nothing else says where it lives. - await a.find(a.hooks.session, "prompt")({ sessionID: "ses_c", messageID: "msg_c0", prompt: { text: "hello" }, delivery: {} }) - await emit(event("session.execution.succeeded", { sessionID: "ses_c" })) - expect(owned(`${linkA}/`)).toEqual([["session.execution.succeeded", "ses_c", true]]) - expect(owned(dirB)).toEqual([["session.execution.succeeded", "ses_c", false]]) - expect(a.sessionGets).toEqual(["ses_b"]) - expect(b.sessionGets).toEqual(["ses_b", "ses_c"]) // looked up, not found, not ours - reset() - - // Deletion is handled by the owner only, and forgets the session in both instances: the next - // unlabelled event for it needs a fresh lookup, which fails once the session is gone. - await emit(event("session.deleted", { sessionID: "ses_a" }, dirA)) - await emit(event("session.deleted", { sessionID: "ses_b" })) - expect(owned(`${linkA}/`)).toEqual([ - ["session.deleted", "ses_a", true], - ["session.deleted", "ses_b", false], - ]) - expect(owned(dirB)).toEqual([ - ["session.deleted", "ses_a", false], - ["session.deleted", "ses_b", true], + await emit("session.created", "ses_a", "/work/a") + await emit("session.execution.succeeded", "ses_a") // carries no location: follows the session + await emit("session.created", "ses_b", "/work/b") + await emit("session.execution.succeeded", "ses_unknown") + expect(ownedBy()).toEqual([ + "/work/a:session.created:ses_a", + "/work/a:session.execution.succeeded:ses_a", + "/work/b:session.created:ses_b", ]) - delete sessions.ses_a - delete sessions.ses_b - reset() - await emit(event("session.execution.succeeded", { sessionID: "ses_a" })) - await emit(event("session.execution.succeeded", { sessionID: "ses_b" })) - expect(owned(`${linkA}/`).map((entry) => entry[2])).toEqual([false, false]) - expect(owned(dirB).map((entry) => entry[2])).toEqual([false, false]) - expect(a.sessionGets).toEqual(["ses_b", "ses_a", "ses_b"]) - expect(b.sessionGets).toEqual(["ses_b", "ses_c", "ses_a", "ses_b"]) - expect(attempts("ws-a")).toBe(0) - expect(attempts("ws-b")).toBe(0) } finally { for (const cleanup of cleanups) cleanup() console.error = savedError - for (const key of ENV) { - if (savedEnv[key] === undefined) delete process.env[key] - else process.env[key] = savedEnv[key] + for (const key of keys) { + if (saved[key] === undefined) delete process.env[key] + else process.env[key] = saved[key] } } }) From c5b6c4504db188e0ee08eeb1b8d1d7d79db6009a Mon Sep 17 00:00:00 2001 From: ajspig Date: Fri, 2 Oct 2026 09:59:55 -0400 Subject: [PATCH 3/4] test(v2): clean up scoping test even when setup rejects Create the plugin instances inside the try block so console.error and the environment are restored, and any instance already started is cleaned up, if a later setup() call fails. Co-Authored-By: Claude Sonnet 5.5 --- tests/v2-wire.test.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/v2-wire.test.js b/tests/v2-wire.test.js index 38e67c6..d5dfcf0 100644 --- a/tests/v2-wire.test.js +++ b/tests/v2-wire.test.js @@ -180,7 +180,7 @@ describe("OpenCode 2 event scoping", () => { a.ctx.options = { configPath } b.ctx.options = { configPath } const mod = await import("../dist/server.js") - const cleanups = [await mod.default.setup(a.ctx), await mod.default.setup(b.ctx)] + const cleanups = [] const emit = async (type, sessionID, location) => { while (waiting.length < 2) await new Promise((resolve) => setTimeout(resolve, 5)) const event = { id: type, created: Date.now(), type, ...(location ? { location: { directory: location } } : {}), data: { sessionID } } @@ -195,6 +195,8 @@ describe("OpenCode 2 event scoping", () => { .filter((e) => e.owned) .map((e) => `${e.directory}:${e.type}:${e.sessionId}`) try { + cleanups.push(await mod.default.setup(a.ctx)) + cleanups.push(await mod.default.setup(b.ctx)) await emit("session.created", "ses_a", "/work/a") await emit("session.execution.succeeded", "ses_a") // carries no location: follows the session await emit("session.created", "ses_b", "/work/b") From 81dd82d786e8e8c31d14ed7ca0e758bfb3da1dba Mon Sep 17 00:00:00 2001 From: ajspig Date: Fri, 2 Oct 2026 10:41:45 -0400 Subject: [PATCH 4/4] test(v2): carry event location in cleanup test after rebase The cleanup test emits an event with no location for an unknown session, which the location gate now drops. Let emit take a location and pass the instance's own directory. Co-Authored-By: Claude Sonnet 5.5 --- tests/v2-wire.test.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/v2-wire.test.js b/tests/v2-wire.test.js index d5dfcf0..d3351f9 100644 --- a/tests/v2-wire.test.js +++ b/tests/v2-wire.test.js @@ -48,9 +48,10 @@ const fakeContext = ({ directory = process.cwd(), subscribe } = {}) => { }, } const find = (bucket, name) => bucket.find((entry) => entry.name === name)?.callback - const emit = async (type, data) => { + const emit = async (type, data, location) => { while (subscribers.length === 0) await new Promise((resolve) => setTimeout(resolve, 5)) - subscribers.shift()({ value: { type, data, created: Date.now() }, done: false }) + const event = { type, data, created: Date.now(), ...(location ? { location: { directory: location } } : {}) } + subscribers.shift()({ value: event, done: false }) } return { ctx, hooks, tools, find, emit } } @@ -143,7 +144,7 @@ describe("OpenCode 2 cleanup", () => { const { ctx, emit } = fakeContext() ctx.options = { configPath } const cleanup = await (await import("../dist/server.js")).default.setup(ctx) - await emit("session.step.ended", { sessionID: "ses_a", assistantMessageID: "msg_a" }) + await emit("session.step.ended", { sessionID: "ses_a", assistantMessageID: "msg_a" }, ctx.location.directory) while (requests === 0) await new Promise((resolve) => setTimeout(resolve, 5)) let settled = false