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.pull/19545/head
parent
4d30ad1e7c
commit
0b8f8bc196
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<typeof mock>
|
||||
|
||||
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")
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue