oh-my-opencode/src/shared/external-plugin-detector.test.ts
João Carlos Magalhães de Castro 1570e292fb
fix(session-notification): revert PR #543 and add proper notification plugin conflict detection (#575)
* revert: undo PR #543 changes (bun shell GC crash was misdiagnosed)

This reverts commit 4a38e70 (PR #543) and 2064568 (follow-up fix).

## Why This Revert

The original diagnosis was incorrect. PR #543 assumed Bun's
ShellInterpreter GC bug was causing Windows crashes, but further
investigation revealed the actual root cause:

**The crash occurs when oh-my-opencode's session-notification runs
alongside external notification plugins (e.g., @mohak34/opencode-notifier).**

Evidence:
- User removed opencode-notifier plugin → crashes stopped
- Release version (with original ctx.$ code) works fine when used alone
- No widespread crash reports from users without external notifiers
- Both plugins listen to session.idle and send concurrent notifications

The real issue is a conflict between two notification systems:
1. oh-my-opencode: ctx.$ → PowerShell → Windows.UI.Notifications
2. opencode-notifier: node-notifier → SnoreToast.exe

A proper fix will detect and handle this conflict gracefully.

Refs: #543, oven-sh/bun#23177, oven-sh/bun#24368
See: docs/CRASH_INVESTIGATION_TIMELINE.md (in follow-up commit)

* fix(session-notification): detect and avoid conflict with external notification plugins

When oh-my-opencode's session-notification runs alongside external
notification plugins like opencode-notifier, both listen to session.idle
and send concurrent notifications. This can cause crashes on Windows
due to resource contention between different notification mechanisms:
- oh-my-opencode: ctx.$ → PowerShell → Windows.UI.Notifications
- opencode-notifier: node-notifier → SnoreToast.exe

This commit adds:
1. External plugin detection (checks opencode.json for known notifiers)
2. Auto-disable of session-notification when conflict detected
3. Console warning explaining the situation
4. Config option 'notification.force_enable' to override

Known notification plugins detected:
- opencode-notifier
- @mohak34/opencode-notifier
- mohak34/opencode-notifier

This is the actual fix for the Windows crash issue previously
misdiagnosed as a Bun.spawn GC bug (PR #543).

Refs: #543

* docs: add crash investigation timeline explaining the real root cause

Documents the investigation journey from initial misdiagnosis (Bun GC bug)
to discovering the actual root cause (notification plugin conflict).

Key findings:
- PR #543 was based on incorrect assumption
- The real issue is concurrent notification plugins
- oh-my-opencode + opencode-notifier = crash on Windows
- Either plugin alone works fine

* fix: address review feedback - add PowerShell escaping and use existing JSONC parser

- Add back single-quote escaping for PowerShell soundPath to prevent command failures
- Replace custom stripJsonComments with existing parseJsoncSafe from jsonc-parser
- All 655 tests pass

---------

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2026-01-07 23:44:03 +09:00

134 lines
4.4 KiB
TypeScript

import { describe, expect, test, beforeEach, afterEach } from "bun:test"
import { detectExternalNotificationPlugin, getNotificationConflictWarning } from "./external-plugin-detector"
import * as fs from "node:fs"
import * as path from "node:path"
import * as os from "node:os"
describe("external-plugin-detector", () => {
let tempDir: string
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "omo-test-"))
})
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true })
})
describe("detectExternalNotificationPlugin", () => {
test("should return detected=false when no plugins configured", () => {
// #given - empty directory
// #when
const result = detectExternalNotificationPlugin(tempDir)
// #then
expect(result.detected).toBe(false)
expect(result.pluginName).toBeNull()
})
test("should return detected=false when only oh-my-opencode is configured", () => {
// #given - opencode.json with only oh-my-opencode
const opencodeDir = path.join(tempDir, ".opencode")
fs.mkdirSync(opencodeDir, { recursive: true })
fs.writeFileSync(
path.join(opencodeDir, "opencode.json"),
JSON.stringify({ plugin: ["oh-my-opencode"] })
)
// #when
const result = detectExternalNotificationPlugin(tempDir)
// #then
expect(result.detected).toBe(false)
expect(result.pluginName).toBeNull()
expect(result.allPlugins).toContain("oh-my-opencode")
})
test("should detect opencode-notifier plugin", () => {
// #given - opencode.json with opencode-notifier
const opencodeDir = path.join(tempDir, ".opencode")
fs.mkdirSync(opencodeDir, { recursive: true })
fs.writeFileSync(
path.join(opencodeDir, "opencode.json"),
JSON.stringify({ plugin: ["oh-my-opencode", "opencode-notifier"] })
)
// #when
const result = detectExternalNotificationPlugin(tempDir)
// #then
expect(result.detected).toBe(true)
expect(result.pluginName).toBe("opencode-notifier")
})
test("should detect opencode-notifier with version suffix", () => {
// #given - opencode.json with versioned opencode-notifier
const opencodeDir = path.join(tempDir, ".opencode")
fs.mkdirSync(opencodeDir, { recursive: true })
fs.writeFileSync(
path.join(opencodeDir, "opencode.json"),
JSON.stringify({ plugin: ["oh-my-opencode", "opencode-notifier@1.2.3"] })
)
// #when
const result = detectExternalNotificationPlugin(tempDir)
// #then
expect(result.detected).toBe(true)
expect(result.pluginName).toBe("opencode-notifier")
})
test("should detect @mohak34/opencode-notifier", () => {
// #given - opencode.json with scoped package name
const opencodeDir = path.join(tempDir, ".opencode")
fs.mkdirSync(opencodeDir, { recursive: true })
fs.writeFileSync(
path.join(opencodeDir, "opencode.json"),
JSON.stringify({ plugin: ["oh-my-opencode", "@mohak34/opencode-notifier"] })
)
// #when
const result = detectExternalNotificationPlugin(tempDir)
// #then - returns the matched known plugin pattern, not the full entry
expect(result.detected).toBe(true)
expect(result.pluginName).toContain("opencode-notifier")
})
test("should handle JSONC format with comments", () => {
// #given - opencode.jsonc with comments
const opencodeDir = path.join(tempDir, ".opencode")
fs.mkdirSync(opencodeDir, { recursive: true })
fs.writeFileSync(
path.join(opencodeDir, "opencode.jsonc"),
`{
// This is a comment
"plugin": [
"oh-my-opencode",
"opencode-notifier" // Another comment
]
}`
)
// #when
const result = detectExternalNotificationPlugin(tempDir)
// #then
expect(result.detected).toBe(true)
expect(result.pluginName).toBe("opencode-notifier")
})
})
describe("getNotificationConflictWarning", () => {
test("should generate warning message with plugin name", () => {
// #when
const warning = getNotificationConflictWarning("opencode-notifier")
// #then
expect(warning).toContain("opencode-notifier")
expect(warning).toContain("session.idle")
expect(warning).toContain("auto-disabled")
expect(warning).toContain("force_enable")
})
})
})