fix: silence interrupted session notifications

This commit is contained in:
marius-kilocode
2026-06-17 17:09:03 +02:00
parent 565f740e1e
commit f21a34a1e6
5 changed files with 107 additions and 33 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@kilocode/cli": patch
"kilo-code": patch
---
Skip attention sounds when a session is manually interrupted.
@@ -8,6 +8,7 @@ type Sync = Extract<SSEPayload, { type: "sync" }>
type Question = Extract<SSEPayload, { type: "question.asked" | "question.replied" | "question.rejected" }>
type Permission = Extract<SSEPayload, { type: "permission.asked" | "permission.replied" }>
type Status = Extract<SSEPayload, { type: "session.status" }>
type Close = Extract<SSEPayload, { type: "session.turn.close" }>
type Error = Extract<SSEPayload, { type: "session.error" }>
export function previewSound(value: string) {
@@ -44,6 +45,7 @@ export class AttentionService implements vscode.Disposable {
}
if (event.type === "permission.asked" || event.type === "permission.replied") return this.permission(event)
if (event.type === "session.status") return this.status(event)
if (event.type === "session.turn.close") return this.close(event)
if (event.type === "session.error") return this.error(event)
}
@@ -86,15 +88,16 @@ export class AttentionService implements vscode.Disposable {
private status(event: Status) {
const sessionID = event.properties.sessionID
if (event.properties.status.type === "busy" || event.properties.status.type === "retry") {
this.active.add(sessionID)
this.errored.delete(sessionID)
return
}
if (event.properties.status.type !== "idle") return
if (!this.active.has(sessionID)) return
this.active.delete(sessionID)
if (event.properties.status.type !== "busy" && event.properties.status.type !== "retry") return
this.active.add(sessionID)
this.errored.delete(sessionID)
}
private close(event: Close) {
const sessionID = event.properties.sessionID
if (!this.active.delete(sessionID)) return
if (this.errored.delete(sessionID)) return
if (event.properties.reason !== "completed") return
this.notify(this.parents.get(sessionID) ? "subagent_done" : "done")
}
@@ -102,6 +105,7 @@ export class AttentionService implements vscode.Disposable {
const sessionID = event.properties.sessionID
if (!sessionID || !this.active.has(sessionID)) return
this.errored.add(sessionID)
if (event.properties.error?.name === "MessageAbortedError") return
this.notify("error")
}
@@ -34,11 +34,12 @@ function event(value: unknown) {
}
describe("AttentionService", () => {
it("plays the upstream completion sound once after active becomes idle", () => {
it("plays the upstream completion sound once after a completed turn closes", () => {
const test = setup()
test.event(event({ type: "session.status", properties: { sessionID: "s1", status: { type: "busy" } } }))
test.event(event({ type: "session.status", properties: { sessionID: "s1", status: { type: "idle" } } }))
test.event(event({ type: "session.status", properties: { sessionID: "s1", status: { type: "idle" } } }))
test.event(event({ type: "session.turn.close", properties: { sessionID: "s1", reason: "completed" } }))
test.event(event({ type: "session.turn.close", properties: { sessionID: "s1", reason: "completed" } }))
expect(test.sounds).toEqual(["done"])
test.service.dispose()
@@ -58,6 +59,7 @@ describe("AttentionService", () => {
)
test.event(event({ type: "session.status", properties: { sessionID: "child", status: { type: "retry" } } }))
test.event(event({ type: "session.status", properties: { sessionID: "child", status: { type: "idle" } } }))
test.event(event({ type: "session.turn.close", properties: { sessionID: "child", reason: "completed" } }))
expect(test.sounds).toEqual(["subagent_done"])
test.service.dispose()
@@ -86,11 +88,37 @@ describe("AttentionService", () => {
test.service.dispose()
})
it("stays silent when a turn is manually interrupted after becoming idle", () => {
const test = setup()
test.event(event({ type: "session.status", properties: { sessionID: "s1", status: { type: "busy" } } }))
test.event(event({ type: "session.status", properties: { sessionID: "s1", status: { type: "idle" } } }))
test.event(event({ type: "session.turn.close", properties: { sessionID: "s1", reason: "interrupted" } }))
expect(test.sounds).toEqual([])
test.service.dispose()
})
it("does not treat an aborted session error as requiring attention", () => {
const test = setup()
test.event(event({ type: "session.status", properties: { sessionID: "s1", status: { type: "busy" } } }))
test.event(
event({
type: "session.error",
properties: { sessionID: "s1", error: { name: "MessageAbortedError", data: { message: "Aborted" } } },
}),
)
test.event(event({ type: "session.turn.close", properties: { sessionID: "s1", reason: "interrupted" } }))
expect(test.sounds).toEqual([])
test.service.dispose()
})
it("clears transitions when the backend disconnects", () => {
const test = setup()
test.event(event({ type: "session.status", properties: { sessionID: "s1", status: { type: "busy" } } }))
test.state("disconnected")
test.event(event({ type: "session.status", properties: { sessionID: "s1", status: { type: "idle" } } }))
test.event(event({ type: "session.turn.close", properties: { sessionID: "s1", reason: "completed" } }))
expect(test.sounds).toEqual([])
test.service.dispose()
@@ -56,32 +56,30 @@ const tui: TuiPlugin = async (api) => {
permissions.delete(event.properties.requestID)
})
// kilocode_change start - only completed turns need completion attention
api.event.on("session.status", (event) => {
const sessionID = event.properties.sessionID
if (event.properties.status.type === "busy" || event.properties.status.type === "retry") {
active.add(sessionID)
errored.delete(sessionID)
return
}
if (event.properties.status.type !== "idle") return
if (!active.has(sessionID)) return
active.delete(sessionID)
if (errored.has(sessionID)) {
errored.delete(sessionID)
return
}
if (event.properties.status.type !== "busy" && event.properties.status.type !== "retry") return
active.add(sessionID)
errored.delete(sessionID)
})
api.event.on("session.turn.close", (event) => {
const sessionID = event.properties.sessionID
if (!active.delete(sessionID)) return
if (errored.delete(sessionID)) return
if (event.properties.reason !== "completed") return
const session = api.state.session.get(sessionID)
notify(api, sessionID, "Session done", session?.parentID ? "subagent_done" : "done")
})
// kilocode_change end
api.event.on("session.error", (event) => {
const sessionID = event.properties.sessionID
if (!sessionID) return
if (!active.has(sessionID)) return
errored.add(sessionID)
if (event.properties.error?.name === "MessageAbortedError") return // kilocode_change - manual stops do not need attention
notify(api, sessionID, sessionErrorMessage(event.properties.error), "error")
})
}
@@ -136,7 +136,8 @@ describe("internal notifications TUI plugin", () => {
])
})
test("notifies when an active session becomes idle and suppresses no-op idle", async () => {
// kilocode_change start
test("notifies only when an active turn closes as completed", async () => {
const harness = await setup()
harness.emit({
@@ -154,6 +155,11 @@ describe("internal notifications TUI plugin", () => {
type: "session.status",
properties: { sessionID: "session", status: { type: "idle" } },
})
harness.emit({
id: "event-4",
type: "session.turn.close",
properties: { sessionID: "session", reason: "completed" },
})
expect(harness.notifications).toEqual([
{
@@ -165,6 +171,28 @@ describe("internal notifications TUI plugin", () => {
])
})
test("stays silent when an active turn closes as interrupted after becoming idle", 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.status",
properties: { sessionID: "session", status: { type: "idle" } },
})
harness.emit({
id: "event-3",
type: "session.turn.close",
properties: { sessionID: "session", reason: "interrupted" },
})
expect(harness.notifications).toEqual([])
})
test("uses sound-only notifications and subagent_done sound for subagent sessions", async () => {
const harness = await setup()
@@ -179,6 +207,11 @@ describe("internal notifications TUI plugin", () => {
type: "session.status",
properties: { sessionID: "subagent", status: { type: "idle" } },
})
harness.emit({
id: "event-4",
type: "session.turn.close",
properties: { sessionID: "subagent", reason: "completed" },
})
expect(harness.notifications).toEqual([
{
@@ -225,7 +258,7 @@ describe("internal notifications TUI plugin", () => {
])
})
test("special-cases aborts and model response timeouts", async () => {
test("stays silent for manual aborts and identifies model response timeouts", async () => {
const harness = await setup()
harness.emit({
@@ -241,21 +274,25 @@ describe("internal notifications TUI plugin", () => {
harness.emit({
id: "event-3",
type: "session.status",
properties: { sessionID: "timeout", status: { type: "busy" } },
properties: { sessionID: "abort", status: { type: "idle" } },
})
harness.emit({
id: "event-4",
type: "session.turn.close",
properties: { sessionID: "abort", reason: "interrupted" },
})
harness.emit({
id: "event-5",
type: "session.status",
properties: { sessionID: "timeout", status: { type: "busy" } },
})
harness.emit({
id: "event-6",
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",
@@ -264,4 +301,5 @@ describe("internal notifications TUI plugin", () => {
},
])
})
// kilocode_change end
})