mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 15:32:11 +08:00
fix: silence interrupted session notifications
This commit is contained in:
@@ -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
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user