attention.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484
  1. import { describe, expect, test } from "bun:test"
  2. import type { AudioPlayOptions, AudioSound } from "@opentui/core"
  3. import { createTuiAttention } from "@kirincode-ai/tui/attention"
  4. import type { TuiConfig } from "@kirincode-ai/tui/config"
  5. type FocusEvent = "focus" | "blur"
  6. type AttentionConfig = Pick<TuiConfig.Resolved, "attention">
  7. class FakeRenderer {
  8. isDestroyed = false
  9. notificationResult = true
  10. notificationThrows = false
  11. notifications: { message: string; title: string | undefined }[] = []
  12. listeners: Record<FocusEvent, Set<() => void>> = {
  13. focus: new Set(),
  14. blur: new Set(),
  15. }
  16. on(event: FocusEvent, listener: () => void) {
  17. this.listeners[event].add(listener)
  18. return this
  19. }
  20. off(event: FocusEvent, listener: () => void) {
  21. this.listeners[event].delete(listener)
  22. return this
  23. }
  24. emit(event: FocusEvent) {
  25. for (const listener of this.listeners[event]) listener()
  26. }
  27. listenerCount(event: FocusEvent) {
  28. return this.listeners[event].size
  29. }
  30. triggerNotification(message: string, title?: string) {
  31. if (this.notificationThrows) throw new Error("notification failed")
  32. this.notifications.push({ message, title })
  33. return this.notificationResult
  34. }
  35. }
  36. class FakeAudioEngine {
  37. loadResult: AudioSound | null = 1
  38. playResult: number | null = 1
  39. loadCalls = 0
  40. playCalls = 0
  41. volumes: (number | undefined)[] = []
  42. loadPaths: string[] = []
  43. rejectLoad = false
  44. rejectPaths = new Set<string>()
  45. async loadSoundFile(path: string) {
  46. this.loadCalls += 1
  47. this.loadPaths.push(path)
  48. if (this.rejectLoad || this.rejectPaths.has(path)) throw new Error("decode failed")
  49. return this.loadResult
  50. }
  51. play(_sound: AudioSound, options?: AudioPlayOptions) {
  52. this.playCalls += 1
  53. this.volumes.push(options?.volume)
  54. return this.playResult
  55. }
  56. }
  57. class FakeKV {
  58. store: Record<string, unknown> = {}
  59. get ready() {
  60. return true
  61. }
  62. get<Value = unknown>(key: string, fallback?: Value) {
  63. return (this.store[key] ?? fallback) as Value
  64. }
  65. set(key: string, value: unknown) {
  66. this.store[key] = value
  67. }
  68. }
  69. function config(attention: Partial<AttentionConfig["attention"]> = {}): AttentionConfig {
  70. return {
  71. attention: {
  72. enabled: true,
  73. notifications: true,
  74. sound: true,
  75. volume: 0.4,
  76. sound_pack: "opencode.default",
  77. sounds: {},
  78. ...attention,
  79. },
  80. }
  81. }
  82. describe("createTuiAttention", () => {
  83. test("defaults to sound always and notification blurred", async () => {
  84. const renderer = new FakeRenderer()
  85. const audio = new FakeAudioEngine()
  86. const attention = createTuiAttention({ renderer, config: config(), audio })
  87. expect(await attention.notify({ message: "hello" })).toEqual({
  88. ok: true,
  89. notification: false,
  90. sound: true,
  91. })
  92. expect(renderer.notifications).toHaveLength(0)
  93. expect(audio.playCalls).toBe(1)
  94. })
  95. test("supports blurred-only requests", async () => {
  96. const renderer = new FakeRenderer()
  97. const audio = new FakeAudioEngine()
  98. const attention = createTuiAttention({ renderer, config: config(), audio })
  99. expect(await attention.notify({ message: "unknown", sound: { when: "blurred" } })).toEqual({
  100. ok: false,
  101. notification: false,
  102. sound: false,
  103. skipped: "focus_unknown",
  104. })
  105. renderer.emit("focus")
  106. expect(await attention.notify({ message: "focused", sound: { when: "blurred" } })).toEqual({
  107. ok: false,
  108. notification: false,
  109. sound: false,
  110. skipped: "focused",
  111. })
  112. renderer.emit("blur")
  113. expect(await attention.notify({ message: "blurred", sound: { when: "blurred" } })).toEqual({
  114. ok: true,
  115. notification: true,
  116. sound: true,
  117. })
  118. expect(audio.playCalls).toBe(1)
  119. })
  120. test("supports focused-only requests", async () => {
  121. const renderer = new FakeRenderer()
  122. const attention = createTuiAttention({ renderer, config: config(), audio: new FakeAudioEngine() })
  123. expect(await attention.notify({ message: "unknown", notification: { when: "focused" }, sound: false })).toEqual({
  124. ok: false,
  125. notification: false,
  126. sound: false,
  127. skipped: "focus_unknown",
  128. })
  129. renderer.emit("blur")
  130. expect(await attention.notify({ message: "blurred", notification: { when: "focused" }, sound: false })).toEqual({
  131. ok: false,
  132. notification: false,
  133. sound: false,
  134. skipped: "blurred",
  135. })
  136. renderer.emit("focus")
  137. expect(await attention.notify({ message: "focused", notification: { when: "focused" }, sound: false })).toEqual({
  138. ok: true,
  139. notification: true,
  140. sound: false,
  141. })
  142. expect(renderer.notifications).toEqual([{ title: "kirincode", message: "focused" }])
  143. })
  144. test("notification can deliver while focused when requested", async () => {
  145. const renderer = new FakeRenderer()
  146. const audio = new FakeAudioEngine()
  147. const attention = createTuiAttention({ renderer, config: config(), audio })
  148. renderer.emit("focus")
  149. expect(await attention.notify({ message: "hello", notification: { when: "always" } })).toEqual({
  150. ok: true,
  151. notification: true,
  152. sound: true,
  153. })
  154. expect(audio.playCalls).toBe(1)
  155. expect(renderer.notifications).toEqual([{ title: "kirincode", message: "hello" }])
  156. })
  157. test("notifies while blurred", async () => {
  158. const renderer = new FakeRenderer()
  159. const attention = createTuiAttention({ renderer, config: config(), audio: new FakeAudioEngine() })
  160. renderer.emit("blur")
  161. expect(await attention.notify({ title: "kirincode", message: "hello", sound: false })).toEqual({
  162. ok: true,
  163. notification: true,
  164. sound: false,
  165. })
  166. expect(renderer.notifications).toEqual([{ title: "kirincode", message: "hello" }])
  167. })
  168. test("when requested, blurred-only calls do not notify or play sound while focused", async () => {
  169. const renderer = new FakeRenderer()
  170. const audio = new FakeAudioEngine()
  171. const attention = createTuiAttention({ renderer, config: config(), audio })
  172. renderer.emit("focus")
  173. expect(await attention.notify({ message: "hello", sound: { when: "blurred" } })).toEqual({
  174. ok: false,
  175. notification: false,
  176. sound: false,
  177. skipped: "focused",
  178. })
  179. expect(renderer.notifications).toHaveLength(0)
  180. expect(audio.loadCalls).toBe(0)
  181. })
  182. test("can play sound always while notification is blurred-only", async () => {
  183. const renderer = new FakeRenderer()
  184. const audio = new FakeAudioEngine()
  185. const attention = createTuiAttention({ renderer, config: config(), audio })
  186. renderer.emit("focus")
  187. expect(
  188. await attention.notify({
  189. message: "hello",
  190. sound: { name: "question" },
  191. }),
  192. ).toEqual({
  193. ok: true,
  194. notification: false,
  195. sound: true,
  196. })
  197. expect(renderer.notifications).toHaveLength(0)
  198. expect(audio.playCalls).toBe(1)
  199. renderer.emit("blur")
  200. expect(
  201. await attention.notify({
  202. message: "hello again",
  203. sound: { name: "question" },
  204. }),
  205. ).toEqual({
  206. ok: true,
  207. notification: true,
  208. sound: true,
  209. })
  210. expect(renderer.notifications).toEqual([{ title: "kirincode", message: "hello again" }])
  211. })
  212. test("can disable notification per call while still playing sound", async () => {
  213. const renderer = new FakeRenderer()
  214. const audio = new FakeAudioEngine()
  215. const attention = createTuiAttention({ renderer, config: config(), audio })
  216. expect(await attention.notify({ message: "hello", notification: false })).toEqual({
  217. ok: true,
  218. notification: false,
  219. sound: true,
  220. })
  221. expect(renderer.notifications).toHaveLength(0)
  222. expect(audio.playCalls).toBe(1)
  223. })
  224. test("skips empty messages and disabled attention", async () => {
  225. const empty = new FakeRenderer()
  226. empty.emit("blur")
  227. const disabled = new FakeRenderer()
  228. disabled.emit("blur")
  229. expect(await createTuiAttention({ renderer: empty, config: config() }).notify({ message: " \n " })).toEqual({
  230. ok: false,
  231. notification: false,
  232. sound: false,
  233. skipped: "empty_message",
  234. })
  235. expect(
  236. await createTuiAttention({ renderer: disabled, config: config({ enabled: false }) }).notify({ message: "hello" }),
  237. ).toEqual({
  238. ok: false,
  239. notification: false,
  240. sound: false,
  241. skipped: "attention_disabled",
  242. })
  243. })
  244. test("respects notification and sound config independently", async () => {
  245. const renderer = new FakeRenderer()
  246. const audio = new FakeAudioEngine()
  247. const attention = createTuiAttention({ renderer, config: config({ notifications: false }), audio })
  248. renderer.emit("blur")
  249. expect(await attention.notify({ message: "hello", sound: true })).toEqual({
  250. ok: true,
  251. notification: false,
  252. sound: true,
  253. })
  254. expect(renderer.notifications).toHaveLength(0)
  255. expect(audio.playCalls).toBe(1)
  256. const soundDisabledRenderer = new FakeRenderer()
  257. const soundDisabledAudio = new FakeAudioEngine()
  258. const soundDisabled = createTuiAttention({
  259. renderer: soundDisabledRenderer,
  260. config: config({ sound: false }),
  261. audio: soundDisabledAudio,
  262. })
  263. soundDisabledRenderer.emit("blur")
  264. expect(await soundDisabled.notify({ message: "hello", sound: true })).toEqual({
  265. ok: true,
  266. notification: true,
  267. sound: false,
  268. })
  269. expect(soundDisabledAudio.loadCalls).toBe(0)
  270. })
  271. test("loads audio lazily only for eligible sound requests", async () => {
  272. const renderer = new FakeRenderer()
  273. const audio = new FakeAudioEngine()
  274. const attention = createTuiAttention({ renderer, config: config(), audio })
  275. await attention.notify({ message: "unknown", sound: { when: "blurred" } })
  276. expect(audio.loadCalls).toBe(0)
  277. renderer.emit("blur")
  278. expect(await attention.notify({ message: "blurred", sound: { volume: 2 } })).toEqual({
  279. ok: true,
  280. notification: true,
  281. sound: true,
  282. })
  283. expect(audio.loadCalls).toBe(1)
  284. expect(audio.volumes).toEqual([1])
  285. })
  286. test("handles unavailable playback and delegates sound loading", async () => {
  287. const unavailableRenderer = new FakeRenderer()
  288. const unavailableAudio = new FakeAudioEngine()
  289. unavailableAudio.playResult = null
  290. const unavailable = createTuiAttention({ renderer: unavailableRenderer, config: config(), audio: unavailableAudio })
  291. unavailableRenderer.emit("blur")
  292. expect(await unavailable.notify({ message: "hello", sound: true })).toEqual({
  293. ok: true,
  294. notification: true,
  295. sound: false,
  296. })
  297. expect(unavailableAudio.loadCalls).toBe(1)
  298. expect(unavailableAudio.playCalls).toBe(1)
  299. const repeatedRenderer = new FakeRenderer()
  300. const repeatedAudio = new FakeAudioEngine()
  301. const repeated = createTuiAttention({ renderer: repeatedRenderer, config: config(), audio: repeatedAudio })
  302. repeatedRenderer.emit("blur")
  303. await repeated.notify({ message: "one", sound: true })
  304. await repeated.notify({ message: "two", sound: true })
  305. expect(repeatedAudio.loadCalls).toBe(2)
  306. expect(repeatedAudio.playCalls).toBe(2)
  307. })
  308. test("plays named sounds from the active sound pack", async () => {
  309. const renderer = new FakeRenderer()
  310. const audio = new FakeAudioEngine()
  311. const attention = createTuiAttention({ renderer, config: config(), audio })
  312. renderer.emit("blur")
  313. const dispose = attention.soundboard.registerPack({
  314. id: "acme.soft",
  315. name: "Soft Alerts",
  316. sounds: {
  317. question: "/tmp/question.mp3",
  318. },
  319. })
  320. expect(attention.soundboard.activate("acme.soft")).toBe(true)
  321. expect(attention.soundboard.current()).toBe("acme.soft")
  322. expect(attention.soundboard.list()).toContainEqual({
  323. id: "acme.soft",
  324. name: "Soft Alerts",
  325. active: true,
  326. builtin: false,
  327. })
  328. expect(await attention.notify({ message: "question", sound: { name: "question" } })).toEqual({
  329. ok: true,
  330. notification: true,
  331. sound: true,
  332. })
  333. expect(audio.loadPaths).toEqual(["/tmp/question.mp3"])
  334. dispose()
  335. expect(attention.soundboard.current()).toBe("opencode.default")
  336. })
  337. test("uses config sound overrides before active pack sounds and falls back on load failure", async () => {
  338. const renderer = new FakeRenderer()
  339. const audio = new FakeAudioEngine()
  340. audio.rejectPaths.add("/tmp/bad-question.mp3")
  341. const attention = createTuiAttention({
  342. renderer,
  343. config: config({ sounds: { question: "/tmp/bad-question.mp3" } }),
  344. audio,
  345. })
  346. renderer.emit("blur")
  347. attention.soundboard.registerPack({
  348. id: "acme.soft",
  349. sounds: {
  350. question: "/tmp/good-question.mp3",
  351. },
  352. })
  353. attention.soundboard.activate("acme.soft")
  354. expect(await attention.notify({ message: "question", sound: { name: "question" } })).toEqual({
  355. ok: true,
  356. notification: true,
  357. sound: true,
  358. })
  359. expect(audio.loadPaths).toEqual(["/tmp/bad-question.mp3", "/tmp/good-question.mp3"])
  360. })
  361. test("persists activated sound pack in KV", () => {
  362. const kv = new FakeKV()
  363. const renderer = new FakeRenderer()
  364. const attention = createTuiAttention({ renderer, config: config(), kv })
  365. attention.soundboard.registerPack({ id: "acme.soft", sounds: { done: "/tmp/done.mp3" } })
  366. expect(attention.soundboard.activate("missing", { persist: true })).toBe(false)
  367. expect(kv.store.attention_sound_pack).toBeUndefined()
  368. expect(attention.soundboard.activate("acme.soft", { persist: true })).toBe(true)
  369. expect(kv.store.attention_sound_pack).toBe("acme.soft")
  370. const next = createTuiAttention({ renderer: new FakeRenderer(), config: config(), kv })
  371. next.soundboard.registerPack({ id: "acme.soft", sounds: { done: "/tmp/done.mp3" } })
  372. expect(next.soundboard.current()).toBe("acme.soft")
  373. })
  374. test("does not throw for notification or sound failures", async () => {
  375. const renderer = new FakeRenderer()
  376. const audio = new FakeAudioEngine()
  377. renderer.notificationThrows = true
  378. audio.rejectLoad = true
  379. const attention = createTuiAttention({ renderer, config: config(), audio })
  380. renderer.emit("blur")
  381. expect(await attention.notify({ message: "hello", sound: true })).toEqual({
  382. ok: false,
  383. notification: false,
  384. sound: false,
  385. })
  386. })
  387. test("strips unsafe notification text", async () => {
  388. const renderer = new FakeRenderer()
  389. const attention = createTuiAttention({ renderer, config: config(), audio: new FakeAudioEngine() })
  390. renderer.emit("blur")
  391. await attention.notify({
  392. title: "\u001b[31m danger\n title\u0007",
  393. message: "\u001b[32m hello\n world\u0000",
  394. })
  395. expect(renderer.notifications).toEqual([{ title: "danger title", message: "hello world" }])
  396. })
  397. test("disposes renderer listeners", async () => {
  398. const renderer = new FakeRenderer()
  399. const audio = new FakeAudioEngine()
  400. const attention = createTuiAttention({ renderer, config: config(), audio })
  401. renderer.emit("blur")
  402. await attention.notify({ message: "hello", sound: true })
  403. expect(renderer.listenerCount("focus")).toBe(1)
  404. expect(renderer.listenerCount("blur")).toBe(1)
  405. attention.dispose()
  406. renderer.isDestroyed = true
  407. expect(renderer.listenerCount("focus")).toBe(0)
  408. expect(renderer.listenerCount("blur")).toBe(0)
  409. expect(audio.loadCalls).toBe(1)
  410. expect(await attention.notify({ message: "hello" })).toEqual({
  411. ok: false,
  412. notification: false,
  413. sound: false,
  414. skipped: "renderer_destroyed",
  415. })
  416. })
  417. })