notifications.test.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. import { describe, expect, test } from "bun:test"
  2. import Notifications from "@/cli/cmd/tui/feature-plugins/system/notifications"
  3. import type { Event, PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2"
  4. import type { TuiAttentionNotifyInput } from "@opencode-ai/plugin/tui"
  5. import { createTuiPluginApi } from "../../../fixture/tui-plugin"
  6. async function setup() {
  7. const notifications: TuiAttentionNotifyInput[] = []
  8. const handlers = new Map<Event["type"], ((event: Event) => void)[]>()
  9. const session = (id: string, title: string, parentID?: string): Session => ({
  10. id,
  11. title,
  12. slug: id,
  13. projectID: "project",
  14. directory: "/workspace",
  15. ...(parentID && { parentID }),
  16. version: "0.0.0-test",
  17. time: { created: 0, updated: 0 },
  18. })
  19. const sessions: Record<string, Session> = {
  20. session: session("session", "Demo session"),
  21. subagent: session("subagent", "Subagent session", "session"),
  22. abort: session("abort", "Abort session"),
  23. timeout: session("timeout", "Timeout session"),
  24. }
  25. await Notifications.tui(
  26. createTuiPluginApi({
  27. attention: {
  28. async notify(input) {
  29. notifications.push(input)
  30. return { ok: true, notification: true, sound: true }
  31. },
  32. },
  33. event: {
  34. on: <Type extends Event["type"]>(type: Type, handler: (event: Extract<Event, { type: Type }>) => void) => {
  35. const list = handlers.get(type) ?? []
  36. const wrapped = handler as (event: Event) => void
  37. list.push(wrapped)
  38. handlers.set(type, list)
  39. return () => {
  40. handlers.set(
  41. type,
  42. (handlers.get(type) ?? []).filter((item) => item !== wrapped),
  43. )
  44. }
  45. },
  46. },
  47. state: {
  48. session: {
  49. get: (sessionID: string) => sessions[sessionID],
  50. },
  51. },
  52. }),
  53. undefined,
  54. {} as never,
  55. )
  56. return {
  57. notifications,
  58. emit(event: Event) {
  59. for (const handler of handlers.get(event.type) ?? []) handler(event)
  60. },
  61. }
  62. }
  63. function question(id: string, sessionID = "session"): QuestionRequest {
  64. return {
  65. id,
  66. sessionID,
  67. questions: [],
  68. }
  69. }
  70. function permission(id: string, sessionID = "session"): PermissionRequest {
  71. return {
  72. id,
  73. sessionID,
  74. permission: "edit",
  75. patterns: [],
  76. metadata: {},
  77. always: [],
  78. }
  79. }
  80. const questionNotification: TuiAttentionNotifyInput = {
  81. title: "Demo session",
  82. message: "Question needs input",
  83. notification: { when: "blurred" },
  84. sound: { name: "question", when: "always" },
  85. }
  86. const permissionNotification: TuiAttentionNotifyInput = {
  87. title: "Demo session",
  88. message: "Permission needs input",
  89. notification: { when: "blurred" },
  90. sound: { name: "permission", when: "always" },
  91. }
  92. describe("internal notifications TUI plugin", () => {
  93. test("notifies for question and permission requests with blurred notifications and always-on sounds", async () => {
  94. const harness = await setup()
  95. harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1") })
  96. harness.emit({ id: "event-2", type: "permission.asked", properties: permission("permission-1") })
  97. expect(harness.notifications).toEqual([questionNotification, permissionNotification])
  98. })
  99. test("dedupes pending questions and permissions until they are resolved", async () => {
  100. const harness = await setup()
  101. harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1") })
  102. harness.emit({ id: "event-2", type: "question.asked", properties: question("question-1") })
  103. harness.emit({
  104. id: "event-3",
  105. type: "question.replied",
  106. properties: { sessionID: "session", requestID: "question-1", answers: [] },
  107. })
  108. harness.emit({ id: "event-4", type: "question.asked", properties: question("question-1") })
  109. harness.emit({ id: "event-5", type: "permission.asked", properties: permission("permission-1") })
  110. harness.emit({ id: "event-6", type: "permission.asked", properties: permission("permission-1") })
  111. harness.emit({
  112. id: "event-7",
  113. type: "permission.replied",
  114. properties: { sessionID: "session", requestID: "permission-1", reply: "once" },
  115. })
  116. harness.emit({ id: "event-8", type: "permission.asked", properties: permission("permission-1") })
  117. expect(harness.notifications).toEqual([
  118. questionNotification,
  119. questionNotification,
  120. permissionNotification,
  121. permissionNotification,
  122. ])
  123. })
  124. test("notifies when an active session becomes idle and suppresses no-op idle", async () => {
  125. const harness = await setup()
  126. harness.emit({
  127. id: "event-1",
  128. type: "session.status",
  129. properties: { sessionID: "session", status: { type: "idle" } },
  130. })
  131. harness.emit({
  132. id: "event-2",
  133. type: "session.status",
  134. properties: { sessionID: "session", status: { type: "busy" } },
  135. })
  136. harness.emit({
  137. id: "event-3",
  138. type: "session.status",
  139. properties: { sessionID: "session", status: { type: "idle" } },
  140. })
  141. expect(harness.notifications).toEqual([
  142. {
  143. title: "Demo session",
  144. message: "Session done",
  145. notification: { when: "blurred" },
  146. sound: { name: "done", when: "always" },
  147. },
  148. ])
  149. })
  150. test("uses sound-only notifications and subagent_done sound for subagent sessions", async () => {
  151. const harness = await setup()
  152. harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1", "subagent") })
  153. harness.emit({
  154. id: "event-2",
  155. type: "session.status",
  156. properties: { sessionID: "subagent", status: { type: "busy" } },
  157. })
  158. harness.emit({
  159. id: "event-3",
  160. type: "session.status",
  161. properties: { sessionID: "subagent", status: { type: "idle" } },
  162. })
  163. expect(harness.notifications).toEqual([
  164. {
  165. title: "Subagent session",
  166. message: "Question needs input",
  167. notification: false,
  168. sound: { name: "question", when: "always" },
  169. },
  170. {
  171. title: "Subagent session",
  172. message: "Session done",
  173. notification: false,
  174. sound: { name: "subagent_done", when: "always" },
  175. },
  176. ])
  177. })
  178. test("notifies session errors once and suppresses the following idle done notification", async () => {
  179. const harness = await setup()
  180. harness.emit({
  181. id: "event-1",
  182. type: "session.status",
  183. properties: { sessionID: "session", status: { type: "busy" } },
  184. })
  185. harness.emit({
  186. id: "event-2",
  187. type: "session.error",
  188. properties: { sessionID: "session", error: { name: "UnknownError", data: { message: "boom" } } },
  189. })
  190. harness.emit({
  191. id: "event-3",
  192. type: "session.status",
  193. properties: { sessionID: "session", status: { type: "idle" } },
  194. })
  195. expect(harness.notifications).toEqual([
  196. {
  197. title: "Demo session",
  198. message: "Session error",
  199. notification: { when: "blurred" },
  200. sound: { name: "error", when: "always" },
  201. },
  202. ])
  203. })
  204. test("special-cases aborts and model response timeouts", async () => {
  205. const harness = await setup()
  206. harness.emit({
  207. id: "event-1",
  208. type: "session.status",
  209. properties: { sessionID: "abort", status: { type: "busy" } },
  210. })
  211. harness.emit({
  212. id: "event-2",
  213. type: "session.error",
  214. properties: { sessionID: "abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } },
  215. })
  216. harness.emit({
  217. id: "event-3",
  218. type: "session.status",
  219. properties: { sessionID: "timeout", status: { type: "busy" } },
  220. })
  221. harness.emit({
  222. id: "event-4",
  223. type: "session.error",
  224. properties: { sessionID: "timeout", error: { name: "UnknownError", data: { message: "SSE read timed out" } } },
  225. })
  226. expect(harness.notifications).toEqual([
  227. {
  228. title: "Abort session",
  229. message: "Session aborted",
  230. notification: { when: "blurred" },
  231. sound: { name: "error", when: "always" },
  232. },
  233. {
  234. title: "Timeout session",
  235. message: "Model stopped responding",
  236. notification: { when: "blurred" },
  237. sound: { name: "error", when: "always" },
  238. },
  239. ])
  240. })
  241. })