diff --git a/packages/tui/src/feature-plugins/system/notifications.ts b/packages/tui/src/feature-plugins/system/notifications.ts index 03b6b6265158..5b399d501038 100644 --- a/packages/tui/src/feature-plugins/system/notifications.ts +++ b/packages/tui/src/feature-plugins/system/notifications.ts @@ -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) }), ] diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index 88ded711905f..f995e48713f9 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -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 diff --git a/packages/tui/test/cli/cmd/tui/notifications.test.ts b/packages/tui/test/cli/cmd/tui/notifications.test.ts index a0a246105ced..9918d26238c5 100644 --- a/packages/tui/test/cli/cmd/tui/notifications.test.ts +++ b/packages/tui/test/cli/cmd/tui/notifications.test.ts @@ -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 void)[]>() const session = (id: string, title: string, parentID?: string): Session => ({ id, @@ -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) @@ -52,6 +57,7 @@ async function setup() { return { notifications, + toasts, emit(event: OpenCodeEvent) { for (const handler of handlers.get(event.type) ?? []) handler(event) }, @@ -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([{ 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()