Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/tui/src/feature-plugins/system/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,16 @@ export default Plugin.define({
context.data.on("session.execution.interrupted", (event) => ended(event.data.sessionID)),
context.data.on("session.execution.failed", (event) => {
const sessionID = event.data.sessionID
if (terminal.has(sessionID)) return
if (errored.has(sessionID)) {
ended(sessionID)
return
}
errored.add(sessionID)
notify(context, sessionID, event.data.error.message, "error")
const route = context.ui.router.current()
if (route.type === "session" && route.sessionID === sessionID)
context.ui.toast.show({ title: "Session failed", message: event.data.error.message, variant: "error" })
ended(sessionID)
}),
]
Expand Down
52 changes: 52 additions & 0 deletions packages/tui/test/app-lifecycle.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1368,6 +1368,58 @@ test.each(["manual", "select"] as const)(
},
)

test.each([100, 44])(
"execution failure keeps the empty session composer and draft usable at width %s",
async (width) => {
await using state = await tmpdir()
const session = {
id: "ses_failure",
projectID: "proj_test",
location: { directory },
title: "Failure fixture",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
}
await using setup = await createAppFixture({
width,
state: state.path,
args: { sessionID: session.id },
config: { animations: false, tabs: { enabled: false } },
fetch: (url) => {
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
if (url.pathname === `/api/session/${session.id}/message`) return json({ data: [], cursor: {} })
if ([`/api/session/${session.id}/inbox`, `/api/session/${session.id}/permission`].includes(url.pathname))
return json({ data: [] })
return undefined
},
})
await setup.ready
await setup.waitForFrame((frame) => frame.includes("commands"))
setup.mockInput.pressKey("u", { ctrl: true })
await setup.mockInput.typeText("Keep this draft")
await setup.waitForFrame((frame) => frame.includes("Keep this draft"))
setup.events.emit({
id: "evt_execution_failed",
created: 2,
type: "session.execution.failed",
durable: { aggregateID: session.id, seq: 1, version: 1 },
data: {
sessionID: session.id,
error: { type: "unknown", message: 'Plugin "broken-skills" failed during skill.transform.' },
},
})
await setup.waitForFrame((frame) => frame.includes("Session failed"))
expect(setup.captureCharFrame()).toContain("broken-skills")
expect(setup.captureCharFrame()).toContain("skill.transform")
expect(setup.captureCharFrame()).toContain("Keep this draft")
expect(setup.captureCharFrame()).not.toContain("Select directory")
await setup.mockInput.typeText(" intact")
await setup.waitForFrame((frame) => frame.includes("Keep this draft intact"))
},
)

async function createAppFixture(
input: {
width?: number
Expand Down
31 changes: 29 additions & 2 deletions packages/tui/test/cli/cmd/tui/notifications.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { describe, expect, test } from "bun:test"
import Notifications from "../../../../src/feature-plugins/system/notifications"
import type { OpenCodeEvent, PermissionAsked } from "@opencode-ai/client"
import type { AttentionNotifyOptions, Context } from "@opencode-ai/plugin/tui/context"
import type { AttentionNotifyOptions, Context, Route, ToastOptions } from "@opencode-ai/plugin/tui/context"

type Session = { id: string; title: string; parentID?: string }

async function setup() {
async function setup(route: Route = { type: "session", sessionID: "session" }) {
const notifications: AttentionNotifyOptions[] = []
const toasts: ToastOptions[] = []
const handlers = new Map<OpenCodeEvent["type"], ((event: OpenCodeEvent) => void)[]>()
const session = (id: string, title: string, parentID?: string): Session => ({
id,
Expand All @@ -21,6 +22,10 @@ async function setup() {
}

await Notifications.setup({
ui: {
router: { current: () => route },
toast: { show: (toast: ToastOptions) => toasts.push(toast) },
},
attention: {
async notify(input: AttentionNotifyOptions) {
notifications.push(input)
Expand Down Expand Up @@ -52,6 +57,7 @@ async function setup() {

return {
notifications,
toasts,
emit(event: OpenCodeEvent) {
for (const handler of handlers.get(event.type) ?? []) handler(event)
},
Expand Down Expand Up @@ -140,6 +146,27 @@ const permissionNotification: AttentionNotifyOptions = {
}

describe("internal notifications TUI plugin", () => {
test("shows execution failures in the viewed session without needing an assistant message", async () => {
const harness = await setup()
harness.emit(executionStarted("started"))
harness.emit(executionFailed("failed"))
harness.emit(executionFailed("duplicate"))
expect(harness.toasts).toEqual([{ title: "Session failed", message: "boom", variant: "error" }])
harness.emit(executionStarted("retry"))
harness.emit(executionFailed("failed-again"))
expect(harness.toasts).toHaveLength(2)
})

test.each<Route>([{ type: "home" }, { type: "session", sessionID: "other" }])(
"keeps other sessions' failures out of the current composer (%j)",
async (route) => {
const harness = await setup(route)
harness.emit(executionFailed("failed"))
expect(harness.toasts).toEqual([])
expect(harness.notifications).toHaveLength(1)
},
)

test("notifies for form and permission requests with blurred notifications and always-on sounds", async () => {
const harness = await setup()

Expand Down
Loading