Add TUI notifications and attention sounds (disabled by default) (#26980)
This commit is contained in:
484
packages/opencode/test/cli/cmd/tui/attention.test.ts
Normal file
484
packages/opencode/test/cli/cmd/tui/attention.test.ts
Normal file
@@ -0,0 +1,484 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { AudioPlayOptions, AudioSound } from "@opentui/core"
|
||||
import { createTuiAttention } from "@/cli/cmd/tui/attention"
|
||||
import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||
|
||||
type FocusEvent = "focus" | "blur"
|
||||
|
||||
type AttentionConfig = Pick<TuiConfig.Resolved, "attention">
|
||||
|
||||
class FakeRenderer {
|
||||
isDestroyed = false
|
||||
notificationResult = true
|
||||
notificationThrows = false
|
||||
notifications: { message: string; title: string | undefined }[] = []
|
||||
listeners: Record<FocusEvent, Set<() => void>> = {
|
||||
focus: new Set(),
|
||||
blur: new Set(),
|
||||
}
|
||||
|
||||
on(event: FocusEvent, listener: () => void) {
|
||||
this.listeners[event].add(listener)
|
||||
return this
|
||||
}
|
||||
|
||||
off(event: FocusEvent, listener: () => void) {
|
||||
this.listeners[event].delete(listener)
|
||||
return this
|
||||
}
|
||||
|
||||
emit(event: FocusEvent) {
|
||||
for (const listener of this.listeners[event]) listener()
|
||||
}
|
||||
|
||||
listenerCount(event: FocusEvent) {
|
||||
return this.listeners[event].size
|
||||
}
|
||||
|
||||
triggerNotification(message: string, title?: string) {
|
||||
if (this.notificationThrows) throw new Error("notification failed")
|
||||
this.notifications.push({ message, title })
|
||||
return this.notificationResult
|
||||
}
|
||||
}
|
||||
|
||||
class FakeAudioEngine {
|
||||
loadResult: AudioSound | null = 1
|
||||
playResult: number | null = 1
|
||||
loadCalls = 0
|
||||
playCalls = 0
|
||||
volumes: (number | undefined)[] = []
|
||||
loadPaths: string[] = []
|
||||
rejectLoad = false
|
||||
rejectPaths = new Set<string>()
|
||||
|
||||
async loadSoundFile(path: string) {
|
||||
this.loadCalls += 1
|
||||
this.loadPaths.push(path)
|
||||
if (this.rejectLoad || this.rejectPaths.has(path)) throw new Error("decode failed")
|
||||
return this.loadResult
|
||||
}
|
||||
|
||||
play(_sound: AudioSound, options?: AudioPlayOptions) {
|
||||
this.playCalls += 1
|
||||
this.volumes.push(options?.volume)
|
||||
return this.playResult
|
||||
}
|
||||
}
|
||||
|
||||
class FakeKV {
|
||||
store: Record<string, unknown> = {}
|
||||
|
||||
get ready() {
|
||||
return true
|
||||
}
|
||||
|
||||
get<Value = unknown>(key: string, fallback?: Value) {
|
||||
return (this.store[key] ?? fallback) as Value
|
||||
}
|
||||
|
||||
set(key: string, value: unknown) {
|
||||
this.store[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
function config(attention: Partial<AttentionConfig["attention"]> = {}): AttentionConfig {
|
||||
return {
|
||||
attention: {
|
||||
enabled: true,
|
||||
notifications: true,
|
||||
sound: true,
|
||||
volume: 0.4,
|
||||
sound_pack: "opencode.default",
|
||||
sounds: {},
|
||||
...attention,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("createTuiAttention", () => {
|
||||
test("defaults to sound always and notification blurred", async () => {
|
||||
const renderer = new FakeRenderer()
|
||||
const audio = new FakeAudioEngine()
|
||||
const attention = createTuiAttention({ renderer, config: config(), audio })
|
||||
|
||||
expect(await attention.notify({ message: "hello" })).toEqual({
|
||||
ok: true,
|
||||
notification: false,
|
||||
sound: true,
|
||||
})
|
||||
expect(renderer.notifications).toHaveLength(0)
|
||||
expect(audio.playCalls).toBe(1)
|
||||
})
|
||||
|
||||
test("supports blurred-only requests", async () => {
|
||||
const renderer = new FakeRenderer()
|
||||
const audio = new FakeAudioEngine()
|
||||
const attention = createTuiAttention({ renderer, config: config(), audio })
|
||||
|
||||
expect(await attention.notify({ message: "unknown", sound: { when: "blurred" } })).toEqual({
|
||||
ok: false,
|
||||
notification: false,
|
||||
sound: false,
|
||||
skipped: "focus_unknown",
|
||||
})
|
||||
renderer.emit("focus")
|
||||
expect(await attention.notify({ message: "focused", sound: { when: "blurred" } })).toEqual({
|
||||
ok: false,
|
||||
notification: false,
|
||||
sound: false,
|
||||
skipped: "focused",
|
||||
})
|
||||
renderer.emit("blur")
|
||||
expect(await attention.notify({ message: "blurred", sound: { when: "blurred" } })).toEqual({
|
||||
ok: true,
|
||||
notification: true,
|
||||
sound: true,
|
||||
})
|
||||
expect(audio.playCalls).toBe(1)
|
||||
})
|
||||
|
||||
test("supports focused-only requests", async () => {
|
||||
const renderer = new FakeRenderer()
|
||||
const attention = createTuiAttention({ renderer, config: config(), audio: new FakeAudioEngine() })
|
||||
|
||||
expect(await attention.notify({ message: "unknown", notification: { when: "focused" }, sound: false })).toEqual({
|
||||
ok: false,
|
||||
notification: false,
|
||||
sound: false,
|
||||
skipped: "focus_unknown",
|
||||
})
|
||||
renderer.emit("blur")
|
||||
expect(await attention.notify({ message: "blurred", notification: { when: "focused" }, sound: false })).toEqual({
|
||||
ok: false,
|
||||
notification: false,
|
||||
sound: false,
|
||||
skipped: "blurred",
|
||||
})
|
||||
renderer.emit("focus")
|
||||
expect(await attention.notify({ message: "focused", notification: { when: "focused" }, sound: false })).toEqual({
|
||||
ok: true,
|
||||
notification: true,
|
||||
sound: false,
|
||||
})
|
||||
expect(renderer.notifications).toEqual([{ title: "opencode", message: "focused" }])
|
||||
})
|
||||
|
||||
test("notification can deliver while focused when requested", async () => {
|
||||
const renderer = new FakeRenderer()
|
||||
const audio = new FakeAudioEngine()
|
||||
const attention = createTuiAttention({ renderer, config: config(), audio })
|
||||
renderer.emit("focus")
|
||||
|
||||
expect(await attention.notify({ message: "hello", notification: { when: "always" } })).toEqual({
|
||||
ok: true,
|
||||
notification: true,
|
||||
sound: true,
|
||||
})
|
||||
expect(audio.playCalls).toBe(1)
|
||||
expect(renderer.notifications).toEqual([{ title: "opencode", message: "hello" }])
|
||||
})
|
||||
|
||||
test("notifies while blurred", async () => {
|
||||
const renderer = new FakeRenderer()
|
||||
const attention = createTuiAttention({ renderer, config: config(), audio: new FakeAudioEngine() })
|
||||
renderer.emit("blur")
|
||||
|
||||
expect(await attention.notify({ title: "opencode", message: "hello", sound: false })).toEqual({
|
||||
ok: true,
|
||||
notification: true,
|
||||
sound: false,
|
||||
})
|
||||
expect(renderer.notifications).toEqual([{ title: "opencode", message: "hello" }])
|
||||
})
|
||||
|
||||
test("when requested, blurred-only calls do not notify or play sound while focused", async () => {
|
||||
const renderer = new FakeRenderer()
|
||||
const audio = new FakeAudioEngine()
|
||||
const attention = createTuiAttention({ renderer, config: config(), audio })
|
||||
renderer.emit("focus")
|
||||
|
||||
expect(await attention.notify({ message: "hello", sound: { when: "blurred" } })).toEqual({
|
||||
ok: false,
|
||||
notification: false,
|
||||
sound: false,
|
||||
skipped: "focused",
|
||||
})
|
||||
expect(renderer.notifications).toHaveLength(0)
|
||||
expect(audio.loadCalls).toBe(0)
|
||||
})
|
||||
|
||||
test("can play sound always while notification is blurred-only", async () => {
|
||||
const renderer = new FakeRenderer()
|
||||
const audio = new FakeAudioEngine()
|
||||
const attention = createTuiAttention({ renderer, config: config(), audio })
|
||||
renderer.emit("focus")
|
||||
|
||||
expect(
|
||||
await attention.notify({
|
||||
message: "hello",
|
||||
sound: { name: "question" },
|
||||
}),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
notification: false,
|
||||
sound: true,
|
||||
})
|
||||
expect(renderer.notifications).toHaveLength(0)
|
||||
expect(audio.playCalls).toBe(1)
|
||||
|
||||
renderer.emit("blur")
|
||||
expect(
|
||||
await attention.notify({
|
||||
message: "hello again",
|
||||
sound: { name: "question" },
|
||||
}),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
notification: true,
|
||||
sound: true,
|
||||
})
|
||||
expect(renderer.notifications).toEqual([{ title: "opencode", message: "hello again" }])
|
||||
})
|
||||
|
||||
test("can disable notification per call while still playing sound", async () => {
|
||||
const renderer = new FakeRenderer()
|
||||
const audio = new FakeAudioEngine()
|
||||
const attention = createTuiAttention({ renderer, config: config(), audio })
|
||||
|
||||
expect(await attention.notify({ message: "hello", notification: false })).toEqual({
|
||||
ok: true,
|
||||
notification: false,
|
||||
sound: true,
|
||||
})
|
||||
expect(renderer.notifications).toHaveLength(0)
|
||||
expect(audio.playCalls).toBe(1)
|
||||
})
|
||||
|
||||
test("skips empty messages and disabled attention", async () => {
|
||||
const empty = new FakeRenderer()
|
||||
empty.emit("blur")
|
||||
const disabled = new FakeRenderer()
|
||||
disabled.emit("blur")
|
||||
|
||||
expect(await createTuiAttention({ renderer: empty, config: config() }).notify({ message: " \n " })).toEqual({
|
||||
ok: false,
|
||||
notification: false,
|
||||
sound: false,
|
||||
skipped: "empty_message",
|
||||
})
|
||||
expect(
|
||||
await createTuiAttention({ renderer: disabled, config: config({ enabled: false }) }).notify({ message: "hello" }),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
notification: false,
|
||||
sound: false,
|
||||
skipped: "attention_disabled",
|
||||
})
|
||||
})
|
||||
|
||||
test("respects notification and sound config independently", async () => {
|
||||
const renderer = new FakeRenderer()
|
||||
const audio = new FakeAudioEngine()
|
||||
const attention = createTuiAttention({ renderer, config: config({ notifications: false }), audio })
|
||||
renderer.emit("blur")
|
||||
|
||||
expect(await attention.notify({ message: "hello", sound: true })).toEqual({
|
||||
ok: true,
|
||||
notification: false,
|
||||
sound: true,
|
||||
})
|
||||
expect(renderer.notifications).toHaveLength(0)
|
||||
expect(audio.playCalls).toBe(1)
|
||||
|
||||
const soundDisabledRenderer = new FakeRenderer()
|
||||
const soundDisabledAudio = new FakeAudioEngine()
|
||||
const soundDisabled = createTuiAttention({
|
||||
renderer: soundDisabledRenderer,
|
||||
config: config({ sound: false }),
|
||||
audio: soundDisabledAudio,
|
||||
})
|
||||
soundDisabledRenderer.emit("blur")
|
||||
|
||||
expect(await soundDisabled.notify({ message: "hello", sound: true })).toEqual({
|
||||
ok: true,
|
||||
notification: true,
|
||||
sound: false,
|
||||
})
|
||||
expect(soundDisabledAudio.loadCalls).toBe(0)
|
||||
})
|
||||
|
||||
test("loads audio lazily only for eligible sound requests", async () => {
|
||||
const renderer = new FakeRenderer()
|
||||
const audio = new FakeAudioEngine()
|
||||
const attention = createTuiAttention({ renderer, config: config(), audio })
|
||||
|
||||
await attention.notify({ message: "unknown", sound: { when: "blurred" } })
|
||||
expect(audio.loadCalls).toBe(0)
|
||||
|
||||
renderer.emit("blur")
|
||||
expect(await attention.notify({ message: "blurred", sound: { volume: 2 } })).toEqual({
|
||||
ok: true,
|
||||
notification: true,
|
||||
sound: true,
|
||||
})
|
||||
expect(audio.loadCalls).toBe(1)
|
||||
expect(audio.volumes).toEqual([1])
|
||||
})
|
||||
|
||||
test("handles unavailable playback and delegates sound loading", async () => {
|
||||
const unavailableRenderer = new FakeRenderer()
|
||||
const unavailableAudio = new FakeAudioEngine()
|
||||
unavailableAudio.playResult = null
|
||||
const unavailable = createTuiAttention({ renderer: unavailableRenderer, config: config(), audio: unavailableAudio })
|
||||
unavailableRenderer.emit("blur")
|
||||
|
||||
expect(await unavailable.notify({ message: "hello", sound: true })).toEqual({
|
||||
ok: true,
|
||||
notification: true,
|
||||
sound: false,
|
||||
})
|
||||
expect(unavailableAudio.loadCalls).toBe(1)
|
||||
expect(unavailableAudio.playCalls).toBe(1)
|
||||
|
||||
const repeatedRenderer = new FakeRenderer()
|
||||
const repeatedAudio = new FakeAudioEngine()
|
||||
const repeated = createTuiAttention({ renderer: repeatedRenderer, config: config(), audio: repeatedAudio })
|
||||
repeatedRenderer.emit("blur")
|
||||
|
||||
await repeated.notify({ message: "one", sound: true })
|
||||
await repeated.notify({ message: "two", sound: true })
|
||||
expect(repeatedAudio.loadCalls).toBe(2)
|
||||
expect(repeatedAudio.playCalls).toBe(2)
|
||||
})
|
||||
|
||||
test("plays named sounds from the active sound pack", async () => {
|
||||
const renderer = new FakeRenderer()
|
||||
const audio = new FakeAudioEngine()
|
||||
const attention = createTuiAttention({ renderer, config: config(), audio })
|
||||
renderer.emit("blur")
|
||||
|
||||
const dispose = attention.soundboard.registerPack({
|
||||
id: "acme.soft",
|
||||
name: "Soft Alerts",
|
||||
sounds: {
|
||||
question: "/tmp/question.mp3",
|
||||
},
|
||||
})
|
||||
|
||||
expect(attention.soundboard.activate("acme.soft")).toBe(true)
|
||||
expect(attention.soundboard.current()).toBe("acme.soft")
|
||||
expect(attention.soundboard.list()).toContainEqual({
|
||||
id: "acme.soft",
|
||||
name: "Soft Alerts",
|
||||
active: true,
|
||||
builtin: false,
|
||||
})
|
||||
|
||||
expect(await attention.notify({ message: "question", sound: { name: "question" } })).toEqual({
|
||||
ok: true,
|
||||
notification: true,
|
||||
sound: true,
|
||||
})
|
||||
expect(audio.loadPaths).toEqual(["/tmp/question.mp3"])
|
||||
|
||||
dispose()
|
||||
expect(attention.soundboard.current()).toBe("opencode.default")
|
||||
})
|
||||
|
||||
test("uses config sound overrides before active pack sounds and falls back on load failure", async () => {
|
||||
const renderer = new FakeRenderer()
|
||||
const audio = new FakeAudioEngine()
|
||||
audio.rejectPaths.add("/tmp/bad-question.mp3")
|
||||
const attention = createTuiAttention({
|
||||
renderer,
|
||||
config: config({ sounds: { question: "/tmp/bad-question.mp3" } }),
|
||||
audio,
|
||||
})
|
||||
renderer.emit("blur")
|
||||
|
||||
attention.soundboard.registerPack({
|
||||
id: "acme.soft",
|
||||
sounds: {
|
||||
question: "/tmp/good-question.mp3",
|
||||
},
|
||||
})
|
||||
attention.soundboard.activate("acme.soft")
|
||||
|
||||
expect(await attention.notify({ message: "question", sound: { name: "question" } })).toEqual({
|
||||
ok: true,
|
||||
notification: true,
|
||||
sound: true,
|
||||
})
|
||||
expect(audio.loadPaths).toEqual(["/tmp/bad-question.mp3", "/tmp/good-question.mp3"])
|
||||
})
|
||||
|
||||
test("persists activated sound pack in KV", () => {
|
||||
const kv = new FakeKV()
|
||||
const renderer = new FakeRenderer()
|
||||
const attention = createTuiAttention({ renderer, config: config(), kv })
|
||||
|
||||
attention.soundboard.registerPack({ id: "acme.soft", sounds: { done: "/tmp/done.mp3" } })
|
||||
|
||||
expect(attention.soundboard.activate("missing", { persist: true })).toBe(false)
|
||||
expect(kv.store.attention_sound_pack).toBeUndefined()
|
||||
expect(attention.soundboard.activate("acme.soft", { persist: true })).toBe(true)
|
||||
expect(kv.store.attention_sound_pack).toBe("acme.soft")
|
||||
|
||||
const next = createTuiAttention({ renderer: new FakeRenderer(), config: config(), kv })
|
||||
next.soundboard.registerPack({ id: "acme.soft", sounds: { done: "/tmp/done.mp3" } })
|
||||
expect(next.soundboard.current()).toBe("acme.soft")
|
||||
})
|
||||
|
||||
test("does not throw for notification or sound failures", async () => {
|
||||
const renderer = new FakeRenderer()
|
||||
const audio = new FakeAudioEngine()
|
||||
renderer.notificationThrows = true
|
||||
audio.rejectLoad = true
|
||||
const attention = createTuiAttention({ renderer, config: config(), audio })
|
||||
renderer.emit("blur")
|
||||
|
||||
expect(await attention.notify({ message: "hello", sound: true })).toEqual({
|
||||
ok: false,
|
||||
notification: false,
|
||||
sound: false,
|
||||
})
|
||||
})
|
||||
|
||||
test("strips unsafe notification text", async () => {
|
||||
const renderer = new FakeRenderer()
|
||||
const attention = createTuiAttention({ renderer, config: config(), audio: new FakeAudioEngine() })
|
||||
renderer.emit("blur")
|
||||
|
||||
await attention.notify({
|
||||
title: "\u001b[31m danger\n title\u0007",
|
||||
message: "\u001b[32m hello\n world\u0000",
|
||||
})
|
||||
|
||||
expect(renderer.notifications).toEqual([{ title: "danger title", message: "hello world" }])
|
||||
})
|
||||
|
||||
test("disposes renderer listeners", async () => {
|
||||
const renderer = new FakeRenderer()
|
||||
const audio = new FakeAudioEngine()
|
||||
const attention = createTuiAttention({ renderer, config: config(), audio })
|
||||
renderer.emit("blur")
|
||||
await attention.notify({ message: "hello", sound: true })
|
||||
|
||||
expect(renderer.listenerCount("focus")).toBe(1)
|
||||
expect(renderer.listenerCount("blur")).toBe(1)
|
||||
|
||||
attention.dispose()
|
||||
renderer.isDestroyed = true
|
||||
|
||||
expect(renderer.listenerCount("focus")).toBe(0)
|
||||
expect(renderer.listenerCount("blur")).toBe(0)
|
||||
expect(audio.loadCalls).toBe(1)
|
||||
expect(await attention.notify({ message: "hello" })).toEqual({
|
||||
ok: false,
|
||||
notification: false,
|
||||
sound: false,
|
||||
skipped: "renderer_destroyed",
|
||||
})
|
||||
})
|
||||
})
|
||||
267
packages/opencode/test/cli/cmd/tui/notifications.test.ts
Normal file
267
packages/opencode/test/cli/cmd/tui/notifications.test.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import Notifications from "@/cli/cmd/tui/feature-plugins/system/notifications"
|
||||
import type { Event, PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2"
|
||||
import type { TuiAttentionNotifyInput } from "@opencode-ai/plugin/tui"
|
||||
import { createTuiPluginApi } from "../../../fixture/tui-plugin"
|
||||
|
||||
async function setup() {
|
||||
const notifications: TuiAttentionNotifyInput[] = []
|
||||
const handlers = new Map<Event["type"], ((event: Event) => void)[]>()
|
||||
const session = (id: string, title: string, parentID?: string): Session => ({
|
||||
id,
|
||||
title,
|
||||
slug: id,
|
||||
projectID: "project",
|
||||
directory: "/workspace",
|
||||
...(parentID && { parentID }),
|
||||
version: "0.0.0-test",
|
||||
time: { created: 0, updated: 0 },
|
||||
})
|
||||
const sessions: Record<string, Session> = {
|
||||
session: session("session", "Demo session"),
|
||||
subagent: session("subagent", "Subagent session", "session"),
|
||||
abort: session("abort", "Abort session"),
|
||||
timeout: session("timeout", "Timeout session"),
|
||||
}
|
||||
|
||||
await Notifications.tui(
|
||||
createTuiPluginApi({
|
||||
attention: {
|
||||
async notify(input) {
|
||||
notifications.push(input)
|
||||
return { ok: true, notification: true, sound: true }
|
||||
},
|
||||
},
|
||||
event: {
|
||||
on: <Type extends Event["type"]>(type: Type, handler: (event: Extract<Event, { type: Type }>) => void) => {
|
||||
const list = handlers.get(type) ?? []
|
||||
const wrapped = handler as (event: Event) => void
|
||||
list.push(wrapped)
|
||||
handlers.set(type, list)
|
||||
return () => {
|
||||
handlers.set(
|
||||
type,
|
||||
(handlers.get(type) ?? []).filter((item) => item !== wrapped),
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
state: {
|
||||
session: {
|
||||
get: (sessionID: string) => sessions[sessionID],
|
||||
},
|
||||
},
|
||||
}),
|
||||
undefined,
|
||||
{} as never,
|
||||
)
|
||||
|
||||
return {
|
||||
notifications,
|
||||
emit(event: Event) {
|
||||
for (const handler of handlers.get(event.type) ?? []) handler(event)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function question(id: string, sessionID = "session"): QuestionRequest {
|
||||
return {
|
||||
id,
|
||||
sessionID,
|
||||
questions: [],
|
||||
}
|
||||
}
|
||||
|
||||
function permission(id: string, sessionID = "session"): PermissionRequest {
|
||||
return {
|
||||
id,
|
||||
sessionID,
|
||||
permission: "edit",
|
||||
patterns: [],
|
||||
metadata: {},
|
||||
always: [],
|
||||
}
|
||||
}
|
||||
|
||||
const questionNotification: TuiAttentionNotifyInput = {
|
||||
title: "Demo session",
|
||||
message: "Question needs input",
|
||||
notification: { when: "blurred" },
|
||||
sound: { name: "question", when: "always" },
|
||||
}
|
||||
|
||||
const permissionNotification: TuiAttentionNotifyInput = {
|
||||
title: "Demo session",
|
||||
message: "Permission needs input",
|
||||
notification: { when: "blurred" },
|
||||
sound: { name: "permission", when: "always" },
|
||||
}
|
||||
|
||||
describe("internal notifications TUI plugin", () => {
|
||||
test("notifies for question and permission requests with blurred notifications and always-on sounds", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1") })
|
||||
harness.emit({ id: "event-2", type: "permission.asked", properties: permission("permission-1") })
|
||||
|
||||
expect(harness.notifications).toEqual([questionNotification, permissionNotification])
|
||||
})
|
||||
|
||||
test("dedupes pending questions and permissions until they are resolved", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1") })
|
||||
harness.emit({ id: "event-2", type: "question.asked", properties: question("question-1") })
|
||||
harness.emit({
|
||||
id: "event-3",
|
||||
type: "question.replied",
|
||||
properties: { sessionID: "session", requestID: "question-1", answers: [] },
|
||||
})
|
||||
harness.emit({ id: "event-4", type: "question.asked", properties: question("question-1") })
|
||||
|
||||
harness.emit({ id: "event-5", type: "permission.asked", properties: permission("permission-1") })
|
||||
harness.emit({ id: "event-6", type: "permission.asked", properties: permission("permission-1") })
|
||||
harness.emit({
|
||||
id: "event-7",
|
||||
type: "permission.replied",
|
||||
properties: { sessionID: "session", requestID: "permission-1", reply: "once" },
|
||||
})
|
||||
harness.emit({ id: "event-8", type: "permission.asked", properties: permission("permission-1") })
|
||||
|
||||
expect(harness.notifications).toEqual([
|
||||
questionNotification,
|
||||
questionNotification,
|
||||
permissionNotification,
|
||||
permissionNotification,
|
||||
])
|
||||
})
|
||||
|
||||
test("notifies when an active session becomes idle and suppresses no-op idle", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit({
|
||||
id: "event-1",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "session", status: { type: "idle" } },
|
||||
})
|
||||
harness.emit({
|
||||
id: "event-2",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "session", status: { type: "busy" } },
|
||||
})
|
||||
harness.emit({
|
||||
id: "event-3",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "session", status: { type: "idle" } },
|
||||
})
|
||||
|
||||
expect(harness.notifications).toEqual([
|
||||
{
|
||||
title: "Demo session",
|
||||
message: "Session done",
|
||||
notification: { when: "blurred" },
|
||||
sound: { name: "done", when: "always" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("uses sound-only notifications and subagent_done sound for subagent sessions", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1", "subagent") })
|
||||
harness.emit({
|
||||
id: "event-2",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "subagent", status: { type: "busy" } },
|
||||
})
|
||||
harness.emit({
|
||||
id: "event-3",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "subagent", status: { type: "idle" } },
|
||||
})
|
||||
|
||||
expect(harness.notifications).toEqual([
|
||||
{
|
||||
title: "Subagent session",
|
||||
message: "Question needs input",
|
||||
notification: false,
|
||||
sound: { name: "question", when: "always" },
|
||||
},
|
||||
{
|
||||
title: "Subagent session",
|
||||
message: "Session done",
|
||||
notification: false,
|
||||
sound: { name: "subagent_done", when: "always" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("notifies session errors once and suppresses the following idle done notification", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit({
|
||||
id: "event-1",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "session", status: { type: "busy" } },
|
||||
})
|
||||
harness.emit({
|
||||
id: "event-2",
|
||||
type: "session.error",
|
||||
properties: { sessionID: "session", error: { name: "UnknownError", data: { message: "boom" } } },
|
||||
})
|
||||
harness.emit({
|
||||
id: "event-3",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "session", status: { type: "idle" } },
|
||||
})
|
||||
|
||||
expect(harness.notifications).toEqual([
|
||||
{
|
||||
title: "Demo session",
|
||||
message: "Session error",
|
||||
notification: { when: "blurred" },
|
||||
sound: { name: "error", when: "always" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("special-cases aborts and model response timeouts", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit({
|
||||
id: "event-1",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "abort", status: { type: "busy" } },
|
||||
})
|
||||
harness.emit({
|
||||
id: "event-2",
|
||||
type: "session.error",
|
||||
properties: { sessionID: "abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } },
|
||||
})
|
||||
harness.emit({
|
||||
id: "event-3",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "timeout", status: { type: "busy" } },
|
||||
})
|
||||
harness.emit({
|
||||
id: "event-4",
|
||||
type: "session.error",
|
||||
properties: { sessionID: "timeout", error: { name: "UnknownError", data: { message: "SSE read timed out" } } },
|
||||
})
|
||||
|
||||
expect(harness.notifications).toEqual([
|
||||
{
|
||||
title: "Abort session",
|
||||
message: "Session aborted",
|
||||
notification: { when: "blurred" },
|
||||
sound: { name: "error", when: "always" },
|
||||
},
|
||||
{
|
||||
title: "Timeout session",
|
||||
message: "Model stopped responding",
|
||||
notification: { when: "blurred" },
|
||||
sound: { name: "error", when: "always" },
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user