fix(hooks): guard session-notification against missing ctx.$ (refs #3997)

This commit is contained in:
YeonGyu-Kim
2026-05-18 10:47:27 +09:00
parent 7285163c6f
commit 397ed1048a
2 changed files with 48 additions and 0 deletions
@@ -78,6 +78,36 @@ describe("session-notification-sender", () => {
})
describe("#given sendSessionNotification", () => {
describe("#when ctx.$ is unavailable", () => {
test("#then it returns early without throwing when ctx has no $", async () => {
const cmuxSpy = spyOn(utils, "getCmuxPath")
const mockCtx = unsafeTestValue<PluginInput>({})
await expect(sender.sendSessionNotification(mockCtx, "darwin", "Test", "Message")).resolves.toBeUndefined()
expect(cmuxSpy).not.toHaveBeenCalled()
})
test("#then it returns early without throwing when ctx.$ is not a function", async () => {
const cmuxSpy = spyOn(utils, "getCmuxPath")
const mockCtx = unsafeTestValue<PluginInput>({
$: "not-a-function",
})
await expect(sender.sendSessionNotification(mockCtx, "darwin", "Test", "Message")).resolves.toBeUndefined()
expect(cmuxSpy).not.toHaveBeenCalled()
})
test("#then it remains non-throwing across sender APIs", async () => {
const afplaySpy = spyOn(utils, "getAfplayPath")
const mockCtx = unsafeTestValue<PluginInput>({})
await expect(sender.sendSessionNotification(mockCtx, "darwin", "Test", "Message")).resolves.toBeUndefined()
await expect(sender.playSessionNotificationSound(mockCtx, "darwin", "/sound.aiff")).resolves.toBeUndefined()
expect(afplaySpy).not.toHaveBeenCalled()
})
})
describe("#when calling ctx.$ for notifications", () => {
test("#then should call .quiet() on all shell commands to suppress stdout/stderr", async () => {
const quietCalls: string[] = []
+18
View File
@@ -1,5 +1,6 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { platform } from "os"
import { log } from "../shared"
import {
getCmuxPath,
getOsascriptPath,
@@ -38,6 +39,19 @@ type ShellCommand = Promise<unknown> & {
nothrow?: () => ShellCommand
}
let hasLoggedUnavailableShellHelper = false
function canRunNotificationCommand(ctx: PluginInput): boolean {
if (typeof ctx?.$ === "function") return true
if (!hasLoggedUnavailableShellHelper) {
hasLoggedUnavailableShellHelper = true
log("[session-notification] ctx.$ unavailable; skipping notification command execution")
}
return false
}
async function runQuietNothrow(command: ShellCommand): Promise<void> {
const safeCommand = typeof command.nothrow === "function" ? command.nothrow() : command
if (typeof safeCommand.quiet === "function") {
@@ -54,6 +68,8 @@ export async function sendSessionNotification(
title: string,
message: string
): Promise<void> {
if (!canRunNotificationCommand(ctx)) return
switch (platform) {
case "darwin": {
// Try cmux first - native UNUserNotificationCenter, properly attributed
@@ -113,6 +129,8 @@ export async function playSessionNotificationSound(
platform: Platform,
soundPath: string
): Promise<void> {
if (!canRunNotificationCommand(ctx)) return
switch (platform) {
case "darwin": {
const afplayPath = await getAfplayPath()