Skip to content

Commit 0b8f8bc

Browse files
author
Ryan Vogel
committed
fix: reduce noisy push relay notifications
Only send completion pushes from session.status idle and suppress aborted/overflow errors. Avoid emitting redundant idle state on no-op cancel so users don't get duplicate notifications.
1 parent 4d30ad1 commit 0b8f8bc

3 files changed

Lines changed: 118 additions & 7 deletions

File tree

packages/opencode/src/server/push-relay.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,15 @@ function str(input: unknown) {
5555
return typeof input === "string" && input.length > 0 ? input : undefined
5656
}
5757

58+
function shouldNotifyError(input: unknown) {
59+
if (!obj(input)) return true
60+
const name = str(input.name)
61+
if (!name) return true
62+
if (name === "ContextOverflowError") return false
63+
if (name === "MessageAbortedError") return false
64+
return true
65+
}
66+
5867
function norm(input: string) {
5968
return input.replace(/\/+$/, "")
6069
}
@@ -169,15 +178,10 @@ function map(event: Event): { type: Type; sessionID: string } | undefined {
169178
if (event.type === "session.error") {
170179
const sessionID = str(event.properties.sessionID)
171180
if (!sessionID) return
181+
if (!shouldNotifyError(event.properties.error)) return
172182
return { type: "error", sessionID }
173183
}
174184

175-
if (event.type === "session.idle") {
176-
const sessionID = str(event.properties.sessionID)
177-
if (!sessionID) return
178-
return { type: "complete", sessionID }
179-
}
180-
181185
if (event.type !== "session.status") return
182186
const sessionID = str(event.properties.sessionID)
183187
if (!sessionID) return

packages/opencode/src/session/prompt.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,10 @@ export namespace SessionPrompt {
141141
const s = yield* InstanceState.get(cache)
142142
const runner = s.runners.get(sessionID)
143143
if (!runner || !runner.busy) {
144-
yield* status.set(sessionID, { type: "idle" })
144+
const current = yield* status.get(sessionID)
145+
if (current.type !== "idle") {
146+
yield* status.set(sessionID, { type: "idle" })
147+
}
145148
return
146149
}
147150
yield* runner.cancel
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
2+
import { GlobalBus } from "../../src/bus/global"
3+
import { PushRelay } from "../../src/server/push-relay"
4+
5+
let originalFetch: typeof fetch
6+
let fetchMock: ReturnType<typeof mock>
7+
8+
function emit(type: string, properties: unknown) {
9+
GlobalBus.emit("event", {
10+
payload: {
11+
type,
12+
properties,
13+
},
14+
})
15+
}
16+
17+
async function waitForCalls(count: number) {
18+
for (let i = 0; i < 50; i++) {
19+
if (fetchMock.mock.calls.length >= count) return
20+
await new Promise((resolve) => setTimeout(resolve, 10))
21+
}
22+
expect(fetchMock.mock.calls.length).toBe(count)
23+
}
24+
25+
function callBody(index = 0) {
26+
const init = fetchMock.mock.calls[index]?.[1] as RequestInit | undefined
27+
if (!init?.body) return
28+
return JSON.parse(String(init.body)) as {
29+
eventType: "complete" | "permission" | "error"
30+
sessionID: string
31+
}
32+
}
33+
34+
beforeEach(() => {
35+
originalFetch = globalThis.fetch
36+
fetchMock = mock(() => Promise.resolve(new Response("ok", { status: 200 })))
37+
globalThis.fetch = fetchMock as unknown as typeof fetch
38+
39+
PushRelay.start({
40+
relayURL: "https://relay.example.com",
41+
relaySecret: "test-secret",
42+
hostname: "127.0.0.1",
43+
port: 4096,
44+
})
45+
})
46+
47+
afterEach(() => {
48+
PushRelay.stop()
49+
globalThis.fetch = originalFetch
50+
})
51+
52+
describe("push relay event mapping", () => {
53+
test("relays completion from session.status idle", async () => {
54+
emit("session.status", {
55+
sessionID: "ses_status_idle",
56+
status: { type: "idle" },
57+
})
58+
59+
await waitForCalls(1)
60+
expect(callBody()?.eventType).toBe("complete")
61+
})
62+
63+
test("ignores deprecated session.idle events", async () => {
64+
emit("session.idle", {
65+
sessionID: "ses_deprecated_idle",
66+
})
67+
68+
await new Promise((resolve) => setTimeout(resolve, 40))
69+
expect(fetchMock.mock.calls.length).toBe(0)
70+
})
71+
72+
test("ignores non-actionable session errors", async () => {
73+
emit("session.error", {
74+
sessionID: "ses_aborted",
75+
error: { name: "MessageAbortedError", data: { message: "Aborted" } },
76+
})
77+
emit("session.error", {
78+
sessionID: "ses_overflow",
79+
error: { name: "ContextOverflowError", data: { message: "Too long" } },
80+
})
81+
82+
await new Promise((resolve) => setTimeout(resolve, 40))
83+
expect(fetchMock.mock.calls.length).toBe(0)
84+
})
85+
86+
test("relays actionable session errors", async () => {
87+
emit("session.error", {
88+
sessionID: "ses_unknown_error",
89+
error: { name: "UnknownError", data: { message: "boom" } },
90+
})
91+
92+
await waitForCalls(1)
93+
expect(callBody()?.eventType).toBe("error")
94+
})
95+
96+
test("relays permission prompts", async () => {
97+
emit("permission.asked", {
98+
sessionID: "ses_permission",
99+
})
100+
101+
await waitForCalls(1)
102+
expect(callBody()?.eventType).toBe("permission")
103+
})
104+
})

0 commit comments

Comments
 (0)