From 4fdeb5aac25cb1d7b964b0cfd1aa5f353eb5fa64 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 10 May 2026 10:34:37 +0000 Subject: [PATCH 1/2] test(server): isolate #26671 to TUI client-side event filter prompt_async forks publish GlobalBus events with the correct directory/workspace (proving Effect.forkIn preserves InstanceRef per #26586). The TUI's useEvent filter, however, returns early in the workspace branch when event.workspace is undefined, never falling through to directory matching. Two tests document the boundary: the live request asserts the publishing path is correct, the synthetic filter test pins the dropped-event shape so the fix can flip a single branch. https://claude.ai/code/session_011VwDFG5W32dnu1qnT3SGr1 --- .../server/httpapi-promptasync-events.test.ts | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 packages/opencode/test/server/httpapi-promptasync-events.test.ts diff --git a/packages/opencode/test/server/httpapi-promptasync-events.test.ts b/packages/opencode/test/server/httpapi-promptasync-events.test.ts new file mode 100644 index 0000000000..088ba6e276 --- /dev/null +++ b/packages/opencode/test/server/httpapi-promptasync-events.test.ts @@ -0,0 +1,166 @@ +/* + * Regression coverage for issue #26671 — TUI does not live-render messages when + * an external HTTP client POSTs to /session/{id}/prompt_async on the same + * server, even though the web UI renders them correctly. + * + * The hypothesis under test: prompt_async forks via Effect.forkIn(scope), and + * the forked work emits GlobalBus events (the same bus the SSE /event endpoint + * forwards). Per #26586, Effect.forkIn preserves InstanceRef/WorkspaceRef + * across the fork, so events emitted inside the fork should carry the + * request's directory and workspace. + * + * Test 1 (live request): mount the in-process server, create a session in a + * tmp directory (no workspace), POST /session/{id}/prompt_async, and assert + * that some GlobalBus event scoped to the session fires with + * { directory: , workspace: undefined }. If this fails, the bug is + * in event publishing. If it passes, publishing is correct and the bug lives + * in the TUI's client-side filter. + * + * Test 2 (synthetic filter): replicate the TUI useEvent filter shape from + * packages/opencode/src/cli/cmd/tui/context/event.ts and prove the filter + * drops events when the TUI has an active workspace but the inbound event has + * workspace=undefined — even when the directory matches. That early `return` + * in the workspace branch is the smoking gun. + */ + +import { afterEach, describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { Flag } from "@opencode-ai/core/flag/flag" +import * as Log from "@opencode-ai/core/util/log" +import { WithInstance } from "../../src/project/with-instance" +import { Server } from "../../src/server/server" +import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" +import { Session } from "@/session/session" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { waitGlobalBusEventPromise } from "./global-bus" + +void Log.init({ print: false }) + +const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES + +function app() { + return Server.Default().app +} + +function pathFor(path: string, params: Record) { + return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), path) +} + +function runSession(fx: Effect.Effect) { + return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer))) +} + +function createSession(directory: string, input?: Session.CreateInput) { + return WithInstance.provide({ + directory, + fn: () => runSession(Session.Service.use((svc) => svc.create(input))), + }) +} + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces + await disposeAllInstances() + await resetDatabase() +}) + +describe("session prompt_async events (issue #26671)", () => { + test("forks publish GlobalBus events with the request's directory/workspace", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const session = await createSession(tmp.path, { title: "external prompt" }) + + // Subscribe BEFORE posting so we don't miss the first emit. The + // GlobalBus.on inside Effect.callback registers synchronously when the + // outer Promise is created, so kicking the request after this line is + // safe. + const eventPromise = waitGlobalBusEventPromise({ + timeout: 8_000, + message: "no GlobalBus event observed for prompt_async", + // Match the first non-housekeeping event scoped to this session. The + // exact event type depends on how far the prompt gets before failing + // (no provider configured): the user-message persistence emits sync + // events, and any subsequent failure emits Session.Event.Error. + predicate: (event) => { + if (event.payload.type === "server.heartbeat") return false + if (event.payload.type === "server.connected") return false + // Attached payload always serialises the sessionID for session events. + return JSON.stringify(event.payload).includes(session.id) + }, + }) + + const promptAsyncPath = pathFor(SessionPaths.promptAsync, { sessionID: session.id }) + const response = await app().request(promptAsyncPath, { + method: "POST", + headers: { + "x-opencode-directory": tmp.path, + "content-type": "application/json", + }, + body: JSON.stringify({ + parts: [{ type: "text", text: "hello from external POST" }], + }), + }) + + // prompt_async returns 204 immediately; the work continues in the fork. + expect(response.status).toBe(204) + + const event = await eventPromise + expect(event.directory).toBe(tmp.path) + expect(event.workspace).toBeUndefined() + }) + + test("TUI useEvent filter drops events when active workspace is set but event.workspace is undefined", () => { + // Mirrors the filter at packages/opencode/src/cli/cmd/tui/context/event.ts + // exactly so we can document the behaviour that #26671 is observing. + type IncomingEvent = { + directory: string | undefined + workspace: string | undefined + payload: { type: string } + } + function tuiFilter(input: { + event: IncomingEvent + activeWorkspace: string | undefined + activeDirectory: string + }): boolean { + const { event, activeWorkspace, activeDirectory } = input + if (event.payload.type === "sync") return false + if (event.directory === "global") return true + + if (activeWorkspace) { + return event.workspace === activeWorkspace + } + return event.directory === activeDirectory + } + + // (a) Directory mode: matching directory is forwarded. + expect( + tuiFilter({ + event: { directory: "/proj", workspace: undefined, payload: { type: "session.next.message.created" } }, + activeWorkspace: undefined, + activeDirectory: "/proj", + }), + ).toBe(true) + + // (b) Workspace mode: matching workspace is forwarded. + expect( + tuiFilter({ + event: { directory: "/proj", workspace: "W1", payload: { type: "session.next.message.created" } }, + activeWorkspace: "W1", + activeDirectory: "/proj", + }), + ).toBe(true) + + // (c) THE BUG SHAPE FOR #26671: + // TUI is in workspace mode, the inbound event has workspace=undefined + // (because the session has no workspaceID and the external POST didn't + // carry workspace context), but the directory still matches. The filter + // bails out in the workspace branch without ever consulting directory, + // so the event is dropped and the TUI never re-renders. + expect( + tuiFilter({ + event: { directory: "/proj", workspace: undefined, payload: { type: "session.next.message.created" } }, + activeWorkspace: "W1", + activeDirectory: "/proj", + }), + ).toBe(false) + }) +}) From 7aa35180ab56445cfd3e565d0b14dace99ff4b90 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 10 May 2026 10:58:29 +0000 Subject: [PATCH 2/2] fix(tui): forward directory-scoped events when TUI is in workspace mode (#26671) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TUI's useEvent filter returned early in the workspace branch when the inbound event's workspace did not match the TUI's active workspace — including the common case where event.workspace was undefined because the session has no workspaceID. The early return meant directory-scoped events from external POSTs (web UI, SDK, curl) targeting a session running in the same directory were silently dropped, even though the server published them correctly with the right directory. Treat events without a workspace label as directory-scoped and fall through to the directory comparator so the TUI re-renders them. Also adds an explicit return after the global-event handler so a global event isn't forwarded twice. Test cases (a)-(c) in httpapi-promptasync-events.test.ts now all forward; (d) keeps cross-workspace events dropped. https://claude.ai/code/session_011VwDFG5W32dnu1qnT3SGr1 --- .../opencode/src/cli/cmd/tui/context/event.ts | 8 +++-- .../server/httpapi-promptasync-events.test.ts | 36 ++++++++++++------- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/event.ts b/packages/opencode/src/cli/cmd/tui/context/event.ts index 156f9c9476..9dd1bbff2d 100644 --- a/packages/opencode/src/cli/cmd/tui/context/event.ts +++ b/packages/opencode/src/cli/cmd/tui/context/event.ts @@ -15,13 +15,17 @@ export function useEvent() { // Special hack for truly global events if (event.directory === "global") { handler(event.payload) + return } - if (project.workspace.current()) { + // Workspace-scoped events match on workspace identity. Events without a + // workspace label fall through to the directory check — a session with + // no workspaceID can be live in the same directory as the TUI even when + // the TUI itself is attached to a workspace (#26671). + if (event.workspace !== undefined) { if (event.workspace === project.workspace.current()) { handler(event.payload) } - return } diff --git a/packages/opencode/test/server/httpapi-promptasync-events.test.ts b/packages/opencode/test/server/httpapi-promptasync-events.test.ts index 088ba6e276..742ac07271 100644 --- a/packages/opencode/test/server/httpapi-promptasync-events.test.ts +++ b/packages/opencode/test/server/httpapi-promptasync-events.test.ts @@ -17,10 +17,10 @@ * in the TUI's client-side filter. * * Test 2 (synthetic filter): replicate the TUI useEvent filter shape from - * packages/opencode/src/cli/cmd/tui/context/event.ts and prove the filter - * drops events when the TUI has an active workspace but the inbound event has - * workspace=undefined — even when the directory matches. That early `return` - * in the workspace branch is the smoking gun. + * packages/opencode/src/cli/cmd/tui/context/event.ts and lock in the fixed + * behaviour: events without a workspace label fall through to a directory + * comparison even when the TUI itself is attached to a workspace, so a + * directory-mode session driven by an external POST is forwarded correctly. */ import { afterEach, describe, expect, test } from "bun:test" @@ -108,9 +108,10 @@ describe("session prompt_async events (issue #26671)", () => { expect(event.workspace).toBeUndefined() }) - test("TUI useEvent filter drops events when active workspace is set but event.workspace is undefined", () => { - // Mirrors the filter at packages/opencode/src/cli/cmd/tui/context/event.ts - // exactly so we can document the behaviour that #26671 is observing. + test("TUI useEvent filter forwards directory-scoped events even when the TUI has an active workspace", () => { + // Mirrors the (fixed) filter at packages/opencode/src/cli/cmd/tui/context/event.ts. + // If someone reverts the fix back to an early-return in the workspace + // branch, case (c) below catches it. type IncomingEvent = { directory: string | undefined workspace: string | undefined @@ -125,7 +126,7 @@ describe("session prompt_async events (issue #26671)", () => { if (event.payload.type === "sync") return false if (event.directory === "global") return true - if (activeWorkspace) { + if (event.workspace !== undefined) { return event.workspace === activeWorkspace } return event.directory === activeDirectory @@ -149,18 +150,27 @@ describe("session prompt_async events (issue #26671)", () => { }), ).toBe(true) - // (c) THE BUG SHAPE FOR #26671: + // (c) THE FIX FOR #26671: // TUI is in workspace mode, the inbound event has workspace=undefined - // (because the session has no workspaceID and the external POST didn't - // carry workspace context), but the directory still matches. The filter - // bails out in the workspace branch without ever consulting directory, - // so the event is dropped and the TUI never re-renders. + // (the session has no workspaceID and the external POST didn't carry + // workspace context), and the directory matches. The fixed filter + // recognises that an event without a workspace label is directory- + // scoped and consults the directory comparator, so this is forwarded. expect( tuiFilter({ event: { directory: "/proj", workspace: undefined, payload: { type: "session.next.message.created" } }, activeWorkspace: "W1", activeDirectory: "/proj", }), + ).toBe(true) + + // (d) Cross-workspace events stay dropped. + expect( + tuiFilter({ + event: { directory: "/proj", workspace: "W2", payload: { type: "session.next.message.created" } }, + activeWorkspace: "W1", + activeDirectory: "/proj", + }), ).toBe(false) }) })