diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 6bd818c96..41f0d6225 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -1,11 +1,12 @@ import { describe, test, expect, beforeEach } from "bun:test" -import type { BackgroundTask } from "./types" +import type { BackgroundTask, ResumeInput } from "./types" const TASK_TTL_MS = 30 * 60 * 1000 class MockBackgroundManager { private tasks: Map = new Map() private notifications: Map = new Map() + public resumeCalls: Array<{ sessionId: string; prompt: string }> = [] addTask(task: BackgroundTask): void { this.tasks.set(task.id, task) @@ -15,6 +16,15 @@ class MockBackgroundManager { return this.tasks.get(id) } + findBySession(sessionID: string): BackgroundTask | undefined { + for (const task of this.tasks.values()) { + if (task.sessionID === sessionID) { + return task + } + } + return undefined + } + getTasksByParentSession(sessionID: string): BackgroundTask[] { const result: BackgroundTask[] = [] for (const task of this.tasks.values()) { @@ -105,6 +115,29 @@ class MockBackgroundManager { } return count } + + resume(input: ResumeInput): BackgroundTask { + const existingTask = this.findBySession(input.sessionId) + if (!existingTask) { + throw new Error(`Task not found for session: ${input.sessionId}`) + } + + this.resumeCalls.push({ sessionId: input.sessionId, prompt: input.prompt }) + + existingTask.status = "running" + existingTask.completedAt = undefined + existingTask.error = undefined + existingTask.parentSessionID = input.parentSessionID + existingTask.parentMessageID = input.parentMessageID + existingTask.parentModel = input.parentModel + + existingTask.progress = { + toolCalls: existingTask.progress?.toolCalls ?? 0, + lastUpdate: new Date(), + } + + return existingTask + } } function createMockTask(overrides: Partial & { id: string; sessionID: string; parentSessionID: string }): BackgroundTask { @@ -482,3 +515,131 @@ describe("BackgroundManager.pruneStaleTasksAndNotifications", () => { expect(manager.getTask("task-fresh")).toBeDefined() }) }) + +describe("BackgroundManager.resume", () => { + let manager: MockBackgroundManager + + beforeEach(() => { + // #given + manager = new MockBackgroundManager() + }) + + test("should throw error when task not found", () => { + // #given - empty manager + + // #when / #then + expect(() => manager.resume({ + sessionId: "non-existent", + prompt: "continue", + parentSessionID: "session-new", + parentMessageID: "msg-new", + })).toThrow("Task not found for session: non-existent") + }) + + test("should resume existing task and reset state to running", () => { + // #given + const completedTask = createMockTask({ + id: "task-a", + sessionID: "session-a", + parentSessionID: "session-parent", + status: "completed", + }) + completedTask.completedAt = new Date() + completedTask.error = "previous error" + manager.addTask(completedTask) + + // #when + const result = manager.resume({ + sessionId: "session-a", + prompt: "continue the work", + parentSessionID: "session-new-parent", + parentMessageID: "msg-new", + }) + + // #then + expect(result.status).toBe("running") + expect(result.completedAt).toBeUndefined() + expect(result.error).toBeUndefined() + expect(result.parentSessionID).toBe("session-new-parent") + expect(result.parentMessageID).toBe("msg-new") + }) + + test("should preserve task identity while updating parent context", () => { + // #given + const existingTask = createMockTask({ + id: "task-a", + sessionID: "session-a", + parentSessionID: "old-parent", + description: "original description", + agent: "explore", + }) + manager.addTask(existingTask) + + // #when + const result = manager.resume({ + sessionId: "session-a", + prompt: "new prompt", + parentSessionID: "new-parent", + parentMessageID: "new-msg", + parentModel: { providerID: "anthropic", modelID: "claude-opus" }, + }) + + // #then + expect(result.id).toBe("task-a") + expect(result.sessionID).toBe("session-a") + expect(result.description).toBe("original description") + expect(result.agent).toBe("explore") + expect(result.parentModel).toEqual({ providerID: "anthropic", modelID: "claude-opus" }) + }) + + test("should track resume calls with prompt", () => { + // #given + const task = createMockTask({ + id: "task-a", + sessionID: "session-a", + parentSessionID: "session-parent", + }) + manager.addTask(task) + + // #when + manager.resume({ + sessionId: "session-a", + prompt: "continue with additional context", + parentSessionID: "session-new", + parentMessageID: "msg-new", + }) + + // #then + expect(manager.resumeCalls).toHaveLength(1) + expect(manager.resumeCalls[0]).toEqual({ + sessionId: "session-a", + prompt: "continue with additional context", + }) + }) + + test("should preserve existing tool call count in progress", () => { + // #given + const taskWithProgress = createMockTask({ + id: "task-a", + sessionID: "session-a", + parentSessionID: "session-parent", + }) + taskWithProgress.progress = { + toolCalls: 42, + lastTool: "read", + lastUpdate: new Date(), + } + manager.addTask(taskWithProgress) + + // #when + const result = manager.resume({ + sessionId: "session-a", + prompt: "continue", + parentSessionID: "session-new", + parentMessageID: "msg-new", + }) + + // #then + expect(result.progress?.toolCalls).toBe(42) + }) +}) diff --git a/src/features/background-agent/types.ts b/src/features/background-agent/types.ts index 8a697a0e5..11e95c680 100644 --- a/src/features/background-agent/types.ts +++ b/src/features/background-agent/types.ts @@ -27,7 +27,9 @@ export interface BackgroundTask { error?: string progress?: TaskProgress parentModel?: { providerID: string; modelID: string } - model?: string + model?: { providerID: string; modelID: string } + /** Agent name used for concurrency tracking */ + concurrencyKey?: string } export interface LaunchInput { @@ -37,4 +39,13 @@ export interface LaunchInput { parentSessionID: string parentMessageID: string parentModel?: { providerID: string; modelID: string } + model?: { providerID: string; modelID: string } +} + +export interface ResumeInput { + sessionId: string + prompt: string + parentSessionID: string + parentMessageID: string + parentModel?: { providerID: string; modelID: string } }