fix(tui): scope attention notifications
This commit is contained in:
committed by
opencode-agent[bot]
parent
b9f3b382fc
commit
97c49512ac
@@ -517,7 +517,15 @@ export type TuiSlots = {
|
||||
}
|
||||
|
||||
export type TuiEventBus = {
|
||||
on: <Type extends Event["type"]>(type: Type, handler: (event: Extract<Event, { type: Type }>) => void) => () => void
|
||||
on: <Type extends Event["type"]>(
|
||||
type: Type,
|
||||
handler: (event: Extract<Event, { type: Type }>, metadata: TuiEventMetadata) => void,
|
||||
) => () => void
|
||||
}
|
||||
|
||||
export type TuiEventMetadata = {
|
||||
directory: string
|
||||
workspace: string | undefined
|
||||
}
|
||||
|
||||
export type TuiDispose = () => void | Promise<void>
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import type { Event } from "@opencode-ai/sdk/v2"
|
||||
import type { TuiAttentionSoundName, TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import type { TuiAttentionSoundName, TuiEventMetadata, TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import type { BuiltinTuiPlugin } from "../builtins"
|
||||
|
||||
const id = "internal:notifications"
|
||||
|
||||
type SessionError = Extract<Event, { type: "session.error" }>["properties"]["error"]
|
||||
|
||||
function matchesSession(api: TuiPluginApi, sessionID: string, metadata: TuiEventMetadata) {
|
||||
const session = api.state.session.get(sessionID)
|
||||
return session?.directory === metadata.directory && session.workspaceID === metadata.workspace
|
||||
}
|
||||
|
||||
function notify(api: TuiPluginApi, sessionID: string | undefined, message: string, sound: TuiAttentionSoundName) {
|
||||
const session = sessionID ? api.state.session.get(sessionID) : undefined
|
||||
if (!session) return
|
||||
const isSubagent = session?.parentID !== undefined
|
||||
void api.attention.notify({
|
||||
title: session?.title,
|
||||
@@ -32,32 +38,41 @@ const tui: TuiPlugin = async (api) => {
|
||||
const questions = new Set<string>()
|
||||
const permissions = new Set<string>()
|
||||
|
||||
api.event.on("question.asked", (event) => {
|
||||
api.event.on("question.asked", (event, metadata) => {
|
||||
if (!matchesSession(api, event.properties.sessionID, metadata)) return
|
||||
if (!api.state.session.question(event.properties.sessionID).some((item) => item.id === event.properties.id)) return
|
||||
if (questions.has(event.properties.id)) return
|
||||
questions.add(event.properties.id)
|
||||
notify(api, event.properties.sessionID, "Question needs input", "question")
|
||||
})
|
||||
|
||||
api.event.on("question.replied", (event) => {
|
||||
api.event.on("question.replied", (event, metadata) => {
|
||||
if (!matchesSession(api, event.properties.sessionID, metadata)) return
|
||||
questions.delete(event.properties.requestID)
|
||||
})
|
||||
|
||||
api.event.on("question.rejected", (event) => {
|
||||
api.event.on("question.rejected", (event, metadata) => {
|
||||
if (!matchesSession(api, event.properties.sessionID, metadata)) return
|
||||
questions.delete(event.properties.requestID)
|
||||
})
|
||||
|
||||
api.event.on("permission.asked", (event) => {
|
||||
api.event.on("permission.asked", (event, metadata) => {
|
||||
if (!matchesSession(api, event.properties.sessionID, metadata)) return
|
||||
if (!api.state.session.permission(event.properties.sessionID).some((item) => item.id === event.properties.id))
|
||||
return
|
||||
if (permissions.has(event.properties.id)) return
|
||||
permissions.add(event.properties.id)
|
||||
notify(api, event.properties.sessionID, "Permission needs input", "permission")
|
||||
})
|
||||
|
||||
api.event.on("permission.replied", (event) => {
|
||||
api.event.on("permission.replied", (event, metadata) => {
|
||||
if (!matchesSession(api, event.properties.sessionID, metadata)) return
|
||||
permissions.delete(event.properties.requestID)
|
||||
})
|
||||
|
||||
api.event.on("session.status", (event) => {
|
||||
api.event.on("session.status", (event, metadata) => {
|
||||
const sessionID = event.properties.sessionID
|
||||
if (!matchesSession(api, sessionID, metadata)) return
|
||||
if (event.properties.status.type === "busy" || event.properties.status.type === "retry") {
|
||||
active.add(sessionID)
|
||||
errored.delete(sessionID)
|
||||
@@ -77,9 +92,10 @@ const tui: TuiPlugin = async (api) => {
|
||||
notify(api, sessionID, "Session done", session?.parentID ? "subagent_done" : "done")
|
||||
})
|
||||
|
||||
api.event.on("session.error", (event) => {
|
||||
api.event.on("session.error", (event, metadata) => {
|
||||
const sessionID = event.properties.sessionID
|
||||
if (!sessionID) return
|
||||
if (!matchesSession(api, sessionID, metadata)) return
|
||||
if (!active.has(sessionID)) return
|
||||
errored.add(sessionID)
|
||||
notify(api, sessionID, sessionErrorMessage(event.properties.error), "error")
|
||||
|
||||
@@ -4,9 +4,14 @@ import type { Event, PermissionRequest, QuestionRequest, Session } from "@openco
|
||||
import type { TuiAttentionNotifyInput } from "@opencode-ai/plugin/tui"
|
||||
import { createTuiPluginApi } from "../../../fixture/tui-plugin"
|
||||
|
||||
async function setup() {
|
||||
async function setup(input: { auto?: boolean } = {}) {
|
||||
const notifications: TuiAttentionNotifyInput[] = []
|
||||
const handlers = new Map<Event["type"], ((event: Event) => void)[]>()
|
||||
const handlers = new Map<
|
||||
Event["type"],
|
||||
((event: Event, metadata: { directory: string; workspace: string | undefined }) => void)[]
|
||||
>()
|
||||
const permissions: Record<string, PermissionRequest[]> = {}
|
||||
const questions: Record<string, QuestionRequest[]> = {}
|
||||
const session = (id: string, title: string, parentID?: string): Session => ({
|
||||
id,
|
||||
title,
|
||||
@@ -33,9 +38,18 @@ async function setup() {
|
||||
},
|
||||
},
|
||||
event: {
|
||||
on: <Type extends Event["type"]>(type: Type, handler: (event: Extract<Event, { type: Type }>) => void) => {
|
||||
on: <Type extends Event["type"]>(
|
||||
type: Type,
|
||||
handler: (
|
||||
event: Extract<Event, { type: Type }>,
|
||||
metadata: { directory: string; workspace: string | undefined },
|
||||
) => void,
|
||||
) => {
|
||||
const list = handlers.get(type) ?? []
|
||||
const wrapped = handler as (event: Event) => void
|
||||
const wrapped = handler as (
|
||||
event: Event,
|
||||
metadata: { directory: string; workspace: string | undefined },
|
||||
) => void
|
||||
list.push(wrapped)
|
||||
handlers.set(type, list)
|
||||
return () => {
|
||||
@@ -49,6 +63,8 @@ async function setup() {
|
||||
state: {
|
||||
session: {
|
||||
get: (sessionID: string) => sessions[sessionID],
|
||||
permission: (sessionID: string) => permissions[sessionID] ?? [],
|
||||
question: (sessionID: string) => questions[sessionID] ?? [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -58,8 +74,30 @@ async function setup() {
|
||||
|
||||
return {
|
||||
notifications,
|
||||
emit(event: Event) {
|
||||
for (const handler of handlers.get(event.type) ?? []) handler(event)
|
||||
emit(event: Event, metadata = { directory: "/workspace", workspace: undefined as string | undefined }) {
|
||||
if (event.type === "permission.asked" && !input.auto) {
|
||||
permissions[event.properties.sessionID] = [
|
||||
...(permissions[event.properties.sessionID] ?? []).filter((item) => item.id !== event.properties.id),
|
||||
event.properties,
|
||||
]
|
||||
}
|
||||
if (event.type === "permission.replied") {
|
||||
permissions[event.properties.sessionID] = (permissions[event.properties.sessionID] ?? []).filter(
|
||||
(item) => item.id !== event.properties.requestID,
|
||||
)
|
||||
}
|
||||
if (event.type === "question.asked") {
|
||||
questions[event.properties.sessionID] = [
|
||||
...(questions[event.properties.sessionID] ?? []).filter((item) => item.id !== event.properties.id),
|
||||
event.properties,
|
||||
]
|
||||
}
|
||||
if (event.type === "question.replied" || event.type === "question.rejected") {
|
||||
questions[event.properties.sessionID] = (questions[event.properties.sessionID] ?? []).filter(
|
||||
(item) => item.id !== event.properties.requestID,
|
||||
)
|
||||
}
|
||||
for (const handler of handlers.get(event.type) ?? []) handler(event, metadata)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -136,6 +174,52 @@ describe("internal notifications TUI plugin", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("suppresses auto-approved permission requests", async () => {
|
||||
const harness = await setup({ auto: true })
|
||||
|
||||
harness.emit({ id: "event-1", type: "permission.asked", properties: permission("permission-1") })
|
||||
|
||||
expect(harness.notifications).toEqual([])
|
||||
})
|
||||
|
||||
test("ignores events outside the session location", async () => {
|
||||
const harness = await setup()
|
||||
const foreign = { directory: "/other", workspace: undefined }
|
||||
|
||||
harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1") }, foreign)
|
||||
harness.emit({ id: "event-2", type: "permission.asked", properties: permission("permission-1") }, foreign)
|
||||
harness.emit(
|
||||
{ id: "event-2b", type: "permission.asked", properties: permission("permission-2") },
|
||||
{ directory: "/workspace", workspace: "other-workspace" },
|
||||
)
|
||||
harness.emit(
|
||||
{
|
||||
id: "event-3",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "session", status: { type: "busy" } },
|
||||
},
|
||||
foreign,
|
||||
)
|
||||
harness.emit(
|
||||
{
|
||||
id: "event-4",
|
||||
type: "session.error",
|
||||
properties: { sessionID: "session", error: { name: "UnknownError", data: { message: "boom" } } },
|
||||
},
|
||||
foreign,
|
||||
)
|
||||
harness.emit(
|
||||
{
|
||||
id: "event-5",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "session", status: { type: "idle" } },
|
||||
},
|
||||
foreign,
|
||||
)
|
||||
|
||||
expect(harness.notifications).toEqual([])
|
||||
})
|
||||
|
||||
test("notifies when an active session becomes idle and suppresses no-op idle", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user