Skip to content

Commit fabaeda

Browse files
merge: bring EventSubscribeQuery interest decode into ownership stack
Also derive EventFeed.Diagnostics from the protocol Schema and lock the generation boundary so prior-generation event IDs are not delivered. Co-authored-by: Cursor <cursoragent@cursor.com>
2 parents 2173286 + 7232360 commit fabaeda

4 files changed

Lines changed: 132 additions & 21 deletions

File tree

packages/server/src/event-feed.ts

Lines changed: 53 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
export * as EventFeed from "./event-feed"
22

33
import { EventV2 } from "@opencode-ai/core/event"
4-
import { isOpenCodeEvent, OpenCodeEvent, sessionIDOf } from "@opencode-ai/protocol/groups/event"
5-
import { Cause, Context, Effect, Layer, Queue, Schema, Scope, Stream } from "effect"
4+
import { EventFeedDiagnostics } from "@opencode-ai/protocol/groups/debug"
5+
import {
6+
EventSubscribeQuery,
7+
isOpenCodeEvent,
8+
OpenCodeEvent,
9+
sessionIDOf,
10+
} from "@opencode-ai/protocol/groups/event"
11+
import { Cause, Context, Effect, Layer, Option, Queue, Schema, Scope, Stream } from "effect"
612

713
export const SubscriberCapacity = 4_096
814

@@ -38,14 +44,7 @@ export type Interest = {
3844
}
3945

4046
/** Content-free counters for leak detection — never includes event payloads. */
41-
export type Diagnostics = {
42-
readonly active: number
43-
readonly opens: number
44-
readonly closes: number
45-
readonly serializedEvents: number
46-
readonly serializedBytes: number
47-
readonly overflows: number
48-
}
47+
export type Diagnostics = typeof EventFeedDiagnostics.Type
4948

5049
export interface Interface {
5150
readonly subscribe: (interest?: Interest) => Effect.Effect<Stream.Stream<string, Error>, never, Scope.Scope>
@@ -81,25 +80,59 @@ export function matchesInterest(event: EventV2.Payload, interest?: Interest): bo
8180
return true
8281
}
8382

84-
export function interestFromQuery(query: URLSearchParams): Interest | undefined {
85-
const directory = query.get("location[directory]") ?? undefined
86-
const workspace = query.get("location[workspace]") ?? undefined
87-
const sessions = query
83+
const decodeSubscribeQuery = Schema.decodeUnknownOption(EventSubscribeQuery)
84+
85+
/** Map an already-decoded subscribe query into feed interest (HttpApi `ctx.query`). */
86+
export function interestFromSubscribeQuery(query: typeof EventSubscribeQuery.Type): Interest | undefined {
87+
const sessions = normalizeSessions(query.session)
88+
if (query.location === undefined && sessions.length === 0) return undefined
89+
return {
90+
...(query.location
91+
? {
92+
location: {
93+
directory: query.location.directory,
94+
...(query.location.workspace !== undefined ? { workspace: query.location.workspace } : {}),
95+
},
96+
}
97+
: {}),
98+
...(sessions.length > 0 ? { sessions } : {}),
99+
}
100+
}
101+
102+
/** Parse deepObject / repeated `session` search params through `EventSubscribeQuery`. */
103+
export function interestFromQuery(params: URLSearchParams): Interest | undefined {
104+
const decoded = Option.getOrUndefined(decodeSubscribeQuery(subscribeQueryCandidate(params)))
105+
if (decoded === undefined) return undefined
106+
return interestFromSubscribeQuery(decoded)
107+
}
108+
109+
function subscribeQueryCandidate(params: URLSearchParams) {
110+
const directory = params.get("location[directory]") ?? undefined
111+
const workspace = params.get("location[workspace]") ?? undefined
112+
const sessions = params
88113
.getAll("session")
89114
.flatMap((value) => value.split(","))
90115
.map((value) => value.trim())
91116
.filter((value) => value.length > 0)
92-
// Workspace interest requires a directory; alone it is an invalid location scope.
93-
if (directory === undefined && workspace !== undefined) return undefined
94-
if (directory === undefined && sessions.length === 0) return undefined
95117
return {
96-
...(directory !== undefined
97-
? { location: { directory, ...(workspace !== undefined ? { workspace } : {}) } }
118+
...(directory !== undefined || workspace !== undefined
119+
? {
120+
location: {
121+
...(directory !== undefined ? { directory } : {}),
122+
...(workspace !== undefined ? { workspace } : {}),
123+
},
124+
}
98125
: {}),
99-
...(sessions.length > 0 ? { sessions } : {}),
126+
...(sessions.length === 0 ? {} : { session: sessions.length === 1 ? sessions[0] : sessions }),
100127
}
101128
}
102129

130+
function normalizeSessions(session: typeof EventSubscribeQuery.Type.session): string[] {
131+
if (session === undefined) return []
132+
if (typeof session === "string") return session.trim() === "" ? [] : [session]
133+
return session.map((value) => value.trim()).filter((value) => value.length > 0)
134+
}
135+
103136
type Subscriber = {
104137
readonly queue: Queue.Queue<string, Error>
105138
readonly interest?: Interest

packages/server/src/handlers/event.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ export const EventHandler = HttpApiBuilder.group(Api, "server.event", (handlers)
3333
return handlers.handleRaw("event.subscribe", (ctx) =>
3434
Effect.gen(function* () {
3535
yield* bridgeClientDisconnect(ctx.request)
36-
const interest = EventFeed.interestFromQuery(new URL(ctx.request.url, "http://localhost").searchParams)
36+
// handleRaw still Schema-decodes query; map into feed interest without re-parsing the URL.
37+
const interest = EventFeed.interestFromSubscribeQuery(ctx.query)
3738
const connected = {
3839
id: EventV2.ID.create(),
3940
type: "server.connected",

packages/server/test/event-feed.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,20 @@ describe("EventFeed", () => {
265265
expect(EventFeed.interestFromQuery(params)).toBeUndefined()
266266
})
267267

268+
test("maps a decoded EventSubscribeQuery into feed interest", () => {
269+
expect(EventFeed.interestFromSubscribeQuery({})).toBeUndefined()
270+
expect(
271+
EventFeed.interestFromSubscribeQuery({
272+
location: { directory: "/tmp/project", workspace: "ws_1" },
273+
session: ["ses_a", "ses_b"],
274+
}),
275+
).toEqual({
276+
location: { directory: "/tmp/project", workspace: "ws_1" },
277+
sessions: ["ses_a", "ses_b"],
278+
})
279+
expect(EventFeed.interestFromSubscribeQuery({ session: "ses_a" })).toEqual({ sessions: ["ses_a"] })
280+
})
281+
268282
test("matchesInterest delivers global types to location-scoped subscribers", () => {
269283
expect(
270284
EventFeed.matchesInterest(

packages/tui/test/cli/tui/client-ownership.test.tsx

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/** @jsxImportSource @opentui/solid */
22
import { describe, expect, test } from "bun:test"
3+
import type { OpenCodeEvent } from "@opencode-ai/client"
34
import { testRender } from "@opentui/solid"
45
import { onMount } from "solid-js"
56
import { ClientProvider, useClient } from "../../../src/context/client"
@@ -71,6 +72,59 @@ describe("ClientProvider stream ownership", () => {
7172
}
7273
})
7374

75+
test("event IDs from a prior generation are not delivered after interest reconnect", async () => {
76+
const events = createEventStream()
77+
const calls = createFetch((url, request) => {
78+
if (url.pathname !== "/api/event") return undefined
79+
return events.v2(request.signal)
80+
}, events)
81+
82+
const seen: Array<{ id: string; generation: number }> = []
83+
let client!: ReturnType<typeof useClient>
84+
let done!: () => void
85+
const ready = new Promise<void>((resolve) => {
86+
done = resolve
87+
})
88+
89+
const app = await testRender(() => (
90+
<TestTuiContexts>
91+
<ClientProvider api={createApi(calls.fetch)} interest={{ location: { directory } }}>
92+
<Probe
93+
onReady={(ctx) => {
94+
client = ctx
95+
client.event.on("vcs.branch.updated", (event) => {
96+
seen.push({ id: event.id, generation: client.connection.internal.generation() })
97+
})
98+
done()
99+
}}
100+
/>
101+
</ClientProvider>
102+
</TestTuiContexts>
103+
))
104+
105+
try {
106+
await ready
107+
await wait(() => client.connection.status() === "connected")
108+
const generation = client.connection.internal.generation()
109+
events.emit(vcs("before"))
110+
await wait(() => seen.some((entry) => entry.id === "evt_vcs_before"))
111+
112+
client.event.scope({ location: { directory }, sessions: ["ses_next"] })
113+
await wait(
114+
() =>
115+
client.connection.status() === "connected" && client.connection.internal.generation() > generation,
116+
)
117+
const next = client.connection.internal.generation()
118+
events.emit(vcs("after"))
119+
await wait(() => seen.some((entry) => entry.id === "evt_vcs_after"))
120+
121+
expect(seen.filter((entry) => entry.id === "evt_vcs_before")).toEqual([{ id: "evt_vcs_before", generation }])
122+
expect(seen.filter((entry) => entry.id === "evt_vcs_after")).toEqual([{ id: "evt_vcs_after", generation: next }])
123+
} finally {
124+
app.renderer.destroy()
125+
}
126+
})
127+
74128
test("teardown leaves zero active event streams", async () => {
75129
const events = createEventStream()
76130
let concurrent = 0
@@ -120,3 +174,12 @@ function Probe(props: { onReady: (client: ReturnType<typeof useClient>) => void
120174
onMount(() => props.onReady(client))
121175
return <box />
122176
}
177+
178+
function vcs(branch: string): OpenCodeEvent {
179+
return {
180+
id: `evt_vcs_${branch}`,
181+
created: 0,
182+
type: "vcs.branch.updated",
183+
data: { branch },
184+
}
185+
}

0 commit comments

Comments
 (0)