From 0b8f8bc196d00e84fbd8b6f8c4c62ee352109589 Mon Sep 17 00:00:00 2001 From: Ryan Vogel Date: Thu, 2 Apr 2026 15:48:02 +0000 Subject: [PATCH] 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. --- packages/opencode/src/server/push-relay.ts | 16 ++- packages/opencode/src/session/prompt.ts | 5 +- .../opencode/test/server/push-relay.test.ts | 104 ++++++++++++++++++ 3 files changed, 118 insertions(+), 7 deletions(-) create mode 100644 packages/opencode/test/server/push-relay.test.ts diff --git a/packages/opencode/src/server/push-relay.ts b/packages/opencode/src/server/push-relay.ts index e8bc08c3f4..a5e0f7faa5 100644 --- a/packages/opencode/src/server/push-relay.ts +++ b/packages/opencode/src/server/push-relay.ts @@ -55,6 +55,15 @@ function str(input: unknown) { return typeof input === "string" && input.length > 0 ? input : undefined } +function shouldNotifyError(input: unknown) { + if (!obj(input)) return true + const name = str(input.name) + if (!name) return true + if (name === "ContextOverflowError") return false + if (name === "MessageAbortedError") return false + return true +} + function norm(input: string) { return input.replace(/\/+$/, "") } @@ -169,15 +178,10 @@ function map(event: Event): { type: Type; sessionID: string } | undefined { if (event.type === "session.error") { const sessionID = str(event.properties.sessionID) if (!sessionID) return + if (!shouldNotifyError(event.properties.error)) return return { type: "error", sessionID } } - if (event.type === "session.idle") { - const sessionID = str(event.properties.sessionID) - if (!sessionID) return - return { type: "complete", sessionID } - } - if (event.type !== "session.status") return const sessionID = str(event.properties.sessionID) if (!sessionID) return diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index dbf815bd6d..17f22a189b 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -141,7 +141,10 @@ export namespace SessionPrompt { const s = yield* InstanceState.get(cache) const runner = s.runners.get(sessionID) if (!runner || !runner.busy) { - yield* status.set(sessionID, { type: "idle" }) + const current = yield* status.get(sessionID) + if (current.type !== "idle") { + yield* status.set(sessionID, { type: "idle" }) + } return } yield* runner.cancel diff --git a/packages/opencode/test/server/push-relay.test.ts b/packages/opencode/test/server/push-relay.test.ts new file mode 100644 index 0000000000..57ed158cc6 --- /dev/null +++ b/packages/opencode/test/server/push-relay.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test" +import { GlobalBus } from "../../src/bus/global" +import { PushRelay } from "../../src/server/push-relay" + +let originalFetch: typeof fetch +let fetchMock: ReturnType + +function emit(type: string, properties: unknown) { + GlobalBus.emit("event", { + payload: { + type, + properties, + }, + }) +} + +async function waitForCalls(count: number) { + for (let i = 0; i < 50; i++) { + if (fetchMock.mock.calls.length >= count) return + await new Promise((resolve) => setTimeout(resolve, 10)) + } + expect(fetchMock.mock.calls.length).toBe(count) +} + +function callBody(index = 0) { + const init = fetchMock.mock.calls[index]?.[1] as RequestInit | undefined + if (!init?.body) return + return JSON.parse(String(init.body)) as { + eventType: "complete" | "permission" | "error" + sessionID: string + } +} + +beforeEach(() => { + originalFetch = globalThis.fetch + fetchMock = mock(() => Promise.resolve(new Response("ok", { status: 200 }))) + globalThis.fetch = fetchMock as unknown as typeof fetch + + PushRelay.start({ + relayURL: "https://relay.example.com", + relaySecret: "test-secret", + hostname: "127.0.0.1", + port: 4096, + }) +}) + +afterEach(() => { + PushRelay.stop() + globalThis.fetch = originalFetch +}) + +describe("push relay event mapping", () => { + test("relays completion from session.status idle", async () => { + emit("session.status", { + sessionID: "ses_status_idle", + status: { type: "idle" }, + }) + + await waitForCalls(1) + expect(callBody()?.eventType).toBe("complete") + }) + + test("ignores deprecated session.idle events", async () => { + emit("session.idle", { + sessionID: "ses_deprecated_idle", + }) + + await new Promise((resolve) => setTimeout(resolve, 40)) + expect(fetchMock.mock.calls.length).toBe(0) + }) + + test("ignores non-actionable session errors", async () => { + emit("session.error", { + sessionID: "ses_aborted", + error: { name: "MessageAbortedError", data: { message: "Aborted" } }, + }) + emit("session.error", { + sessionID: "ses_overflow", + error: { name: "ContextOverflowError", data: { message: "Too long" } }, + }) + + await new Promise((resolve) => setTimeout(resolve, 40)) + expect(fetchMock.mock.calls.length).toBe(0) + }) + + test("relays actionable session errors", async () => { + emit("session.error", { + sessionID: "ses_unknown_error", + error: { name: "UnknownError", data: { message: "boom" } }, + }) + + await waitForCalls(1) + expect(callBody()?.eventType).toBe("error") + }) + + test("relays permission prompts", async () => { + emit("permission.asked", { + sessionID: "ses_permission", + }) + + await waitForCalls(1) + expect(callBody()?.eventType).toBe("permission") + }) +})