Compare commits

...
Author SHA1 Message Date
Kit Langton e790e81dfc fix(tui): show execution failures in the viewed session 2026-09-02 22:51:36 -04:00
3 changed files with 85 additions and 2 deletions
@@ -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)
}),
]
+52
View File
@@ -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
@@ -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,
@@ -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<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()