From fc0aaeadc751cb5b49fa21963b53eaa1c82bc38e Mon Sep 17 00:00:00 2001 From: Filipe Ferreira Date: Thu, 19 Mar 2026 01:24:40 +0000 Subject: [PATCH 1/2] fix(background): scope task notifications to originating parent session --- src/background/manager.ts | 42 +++++++++++++++++++++++++++++++++------ src/background/types.ts | 2 ++ src/index.ts | 11 +++++++--- 3 files changed, 46 insertions(+), 9 deletions(-) diff --git a/src/background/manager.ts b/src/background/manager.ts index c2ea406..5bd35e2 100644 --- a/src/background/manager.ts +++ b/src/background/manager.ts @@ -18,6 +18,7 @@ import type { TaskToastManager } from "../features/task-toast" export class BackgroundManager { private tasks = new Map() + private detachedParentSessions = new Set() private mainSessionID: string | undefined private toastManager: TaskToastManager | undefined @@ -41,6 +42,10 @@ export class BackgroundManager { client: OpencodeClient, input: LaunchInput ): Promise { + if (!input.parentSessionID) { + throw new Error("Background task launch requires parentSessionID") + } + // Store main session ID for notifications if (!this.mainSessionID) { this.mainSessionID = input.parentSessionID @@ -65,6 +70,7 @@ export class BackgroundManager { const task: BackgroundTask = { id: this.generateTaskId(), sessionID, + parentSessionID: input.parentSessionID, agent: input.agent, description: input.description, prompt: input.prompt, @@ -239,19 +245,32 @@ export class BackgroundManager { task.status = "completed" task.completedAt = new Date() + // Parent session is gone - silently keep lifecycle local and cleanup task + if (this.detachedParentSessions.has(task.parentSessionID)) { + this.tasks.delete(task.id) + return null + } + // Show completion toast if (this.toastManager) { this.toastManager.showCompletionToast(task.id).catch(() => {}) } - // Generate notification - return this.getCompletionStatus() + // Generate notification for the task's parent session only + return this.getCompletionStatusForSession(task.parentSessionID) } - getCompletionStatus(): CompletionNotification { - const allTasks = [...this.tasks.values()] - const runningTasks = allTasks.filter((t) => t.status === "running") - const completedTasks = allTasks.filter( + getCompletionStatusForSession(parentSessionID: string): CompletionNotification | null { + const sessionTasks = [...this.tasks.values()].filter( + (t) => t.parentSessionID === parentSessionID + ) + + if (sessionTasks.length === 0) { + return null + } + + const runningTasks = sessionTasks.filter((t) => t.status === "running") + const completedTasks = sessionTasks.filter( (t) => t.status === "completed" || t.status === "failed" ) @@ -289,6 +308,7 @@ ${runningTasks.length} task(s) still running. Continue working. message, completedTasks, runningCount: runningTasks.length, + parentSessionID, parentAgent, parentModel, } @@ -309,4 +329,14 @@ ${runningTasks.length} task(s) still running. Continue working. } } } + + detachParentSession(sessionID: string): void { + this.detachedParentSessions.add(sessionID) + + for (const [id, task] of this.tasks) { + if (task.parentSessionID === sessionID && task.status !== "running") { + this.tasks.delete(id) + } + } + } } diff --git a/src/background/types.ts b/src/background/types.ts index aa1993e..e588a15 100644 --- a/src/background/types.ts +++ b/src/background/types.ts @@ -13,6 +13,7 @@ export interface ParentModel { export interface BackgroundTask { id: string sessionID: string + parentSessionID: string agent: string description: string prompt: string @@ -38,6 +39,7 @@ export interface CompletionNotification { message: string completedTasks: BackgroundTask[] runningCount: number + parentSessionID: string parentAgent?: string // Agent to return notification to (preserves mode) parentModel?: ParentModel // Model to return notification to (preserves model) } diff --git a/src/index.ts b/src/index.ts index ce05df9..380334b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -170,6 +170,11 @@ const ZenoxPlugin: Plugin = async (ctx) => { // Clear session agent tracking clearSessionAgent(sessionID) + // Clear background tasks tied to this session + if (sessionID) { + backgroundManager.detachParentSession(sessionID) + } + // Clear main session if this was it if (sessionID && sessionID === backgroundManager.getMainSession()) { backgroundManager.setMainSession(undefined) @@ -187,13 +192,13 @@ const ZenoxPlugin: Plugin = async (ctx) => { // If a background task completed, notify the main session if (notification) { - const mainSessionID = backgroundManager.getMainSession() - if (mainSessionID) { + const targetSessionID = notification.parentSessionID + if (targetSessionID) { // Send notification with retry logic for undefined agent/model errors const sendNotification = async (omitContext = false) => { try { await ctx.client.session.prompt({ - path: { id: mainSessionID }, + path: { id: targetSessionID }, body: { // noReply: true = silent (don't trigger response) // noReply: false = loud (trigger response) From 3011814bf972ff7a260d28a8598af741af700794 Mon Sep 17 00:00:00 2001 From: Filipe Ferreira Date: Thu, 19 Mar 2026 01:24:45 +0000 Subject: [PATCH 2/2] test(background): add regression tests for cross-session task bleed --- package.json | 1 + tests/background-manager.test.ts | 110 +++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 tests/background-manager.test.ts diff --git a/package.json b/package.json index 8304747..a2fd1fa 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "dist" ], "scripts": { + "test": "bun test", "build": "bun build src/index.ts --outdir dist --target bun --format esm && bun build src/cli/index.ts --outdir dist/cli --target node --format esm && tsc --emitDeclarationOnly", "clean": "rm -rf dist", "prepublishOnly": "bun run clean && bun run build", diff --git a/tests/background-manager.test.ts b/tests/background-manager.test.ts new file mode 100644 index 0000000..18e39c3 --- /dev/null +++ b/tests/background-manager.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from "bun:test" +import type { OpencodeClient } from "@opencode-ai/sdk" +import { BackgroundManager } from "../src/background/manager" + +function createMockClient(): OpencodeClient { + let counter = 0 + + const client = { + session: { + create: async () => ({ data: { id: `child_${++counter}` } }), + prompt: async () => ({ data: {} }), + abort: async () => ({ data: {} }), + messages: async () => ({ data: [] }), + }, + } + + return client as unknown as OpencodeClient +} + +describe("BackgroundManager session scoping", () => { + test("keeps completion notifications scoped to parent session", async () => { + const manager = new BackgroundManager() + const client = createMockClient() + + const taskA = await manager.launch(client, { + agent: "explorer", + description: "task a", + prompt: "prompt a", + parentSessionID: "parent_a", + }) + + const taskB = await manager.launch(client, { + agent: "explorer", + description: "task b", + prompt: "prompt b", + parentSessionID: "parent_b", + }) + + const notification = manager.handleSessionIdle(taskA.sessionID) + + expect(notification).not.toBeNull() + expect(notification?.parentSessionID).toBe("parent_a") + expect(notification?.allComplete).toBe(true) + expect(notification?.completedTasks.map((t) => t.id)).toEqual([taskA.id]) + expect(notification?.message.includes(taskA.id)).toBe(true) + expect(notification?.message.includes(taskB.id)).toBe(false) + }) + + test("tracks running count per parent session", async () => { + const manager = new BackgroundManager() + const client = createMockClient() + + const taskA1 = await manager.launch(client, { + agent: "explorer", + description: "task a1", + prompt: "prompt a1", + parentSessionID: "parent_a", + }) + + const taskA2 = await manager.launch(client, { + agent: "explorer", + description: "task a2", + prompt: "prompt a2", + parentSessionID: "parent_a", + }) + + const taskB = await manager.launch(client, { + agent: "explorer", + description: "task b", + prompt: "prompt b", + parentSessionID: "parent_b", + }) + + const first = manager.handleSessionIdle(taskA1.sessionID) + expect(first).not.toBeNull() + expect(first?.parentSessionID).toBe("parent_a") + expect(first?.allComplete).toBe(false) + expect(first?.runningCount).toBe(1) + expect(first?.completedTasks.map((t) => t.id)).toEqual([taskA1.id]) + + const second = manager.handleSessionIdle(taskA2.sessionID) + expect(second).not.toBeNull() + expect(second?.parentSessionID).toBe("parent_a") + expect(second?.allComplete).toBe(true) + expect(second?.completedTasks.map((t) => t.id).sort()).toEqual( + [taskA1.id, taskA2.id].sort() + ) + expect(second?.completedTasks.map((t) => t.id).includes(taskB.id)).toBe(false) + }) + + test("detached parent session suppresses notifications and keeps running tasks until completion", async () => { + const manager = new BackgroundManager() + const client = createMockClient() + + const task = await manager.launch(client, { + agent: "explorer", + description: "task", + prompt: "prompt", + parentSessionID: "parent_a", + }) + + manager.detachParentSession("parent_a") + + expect(manager.listAllTasks().some((t) => t.id === task.id)).toBe(true) + + const notification = manager.handleSessionIdle(task.sessionID) + expect(notification).toBeNull() + expect(manager.listAllTasks().some((t) => t.id === task.id)).toBe(false) + }) +})