From 76d412275a7bac46420ffbe66e618e40a5c68a48 Mon Sep 17 00:00:00 2001 From: Brace Sproul Date: Thu, 12 Jun 2025 13:19:18 -0700 Subject: [PATCH] refactor: Drop ThreadWithTasks (#142) * refactor: Drop ThreadWithTasks * cr --- apps/open-swe/src/nodes/diagnose-error.ts | 2 +- .../open-swe/src/nodes/generate-conclusion.ts | 2 +- apps/open-swe/src/nodes/generate-message.ts | 2 +- apps/open-swe/src/nodes/interrupt-plan.ts | 2 +- apps/open-swe/src/nodes/open-pr.ts | 2 +- apps/open-swe/src/nodes/progress-plan-step.ts | 2 +- .../src/nodes/summarize-task-steps.ts | 2 +- apps/open-swe/src/nodes/update-plan.ts | 5 +- apps/web/src/components/task-list-sidebar.tsx | 7 +- apps/web/src/components/task-list.tsx | 6 +- apps/web/src/components/thread-item.tsx | 32 +- apps/web/src/hooks/useThreadPolling.ts | 9 +- apps/web/src/lib/polling/thread-poller.ts | 41 +-- apps/web/src/lib/thread.ts | 32 ++ apps/web/src/providers/Thread.tsx | 104 +------ packages/shared/src/open-swe/tasks.ts | 274 ++++++++++++++++++ 16 files changed, 387 insertions(+), 137 deletions(-) create mode 100644 apps/web/src/lib/thread.ts create mode 100644 packages/shared/src/open-swe/tasks.ts diff --git a/apps/open-swe/src/nodes/diagnose-error.ts b/apps/open-swe/src/nodes/diagnose-error.ts index 6436ed4f..7b5ea8a9 100644 --- a/apps/open-swe/src/nodes/diagnose-error.ts +++ b/apps/open-swe/src/nodes/diagnose-error.ts @@ -19,7 +19,7 @@ import { getCompletedPlanItems, getCurrentPlanItem, } from "../utils/current-task.js"; -import { getActivePlanItems } from "../utils/task-plan.js"; +import { getActivePlanItems } from "@open-swe/shared/open-swe/tasks"; const logger = createLogger(LogLevel.INFO, "DiagnoseError"); diff --git a/apps/open-swe/src/nodes/generate-conclusion.ts b/apps/open-swe/src/nodes/generate-conclusion.ts index bcacbef9..19e7dee0 100644 --- a/apps/open-swe/src/nodes/generate-conclusion.ts +++ b/apps/open-swe/src/nodes/generate-conclusion.ts @@ -13,7 +13,7 @@ import { completeTask, getActivePlanItems, getActiveTask, -} from "../utils/task-plan.js"; +} from "@open-swe/shared/open-swe/tasks"; const logger = createLogger(LogLevel.INFO, "GenerateConclusionNode"); diff --git a/apps/open-swe/src/nodes/generate-message.ts b/apps/open-swe/src/nodes/generate-message.ts index 194c2e7e..05559d84 100644 --- a/apps/open-swe/src/nodes/generate-message.ts +++ b/apps/open-swe/src/nodes/generate-message.ts @@ -16,7 +16,7 @@ import { stopSandbox } from "../utils/sandbox.js"; import { createLogger, LogLevel } from "../utils/logger.js"; import { getCurrentPlanItem } from "../utils/current-task.js"; import { getMessageContentString } from "@open-swe/shared/messages"; -import { getActivePlanItems } from "../utils/task-plan.js"; +import { getActivePlanItems } from "@open-swe/shared/open-swe/tasks"; import { SANDBOX_ROOT_DIR } from "@open-swe/shared/constants"; const logger = createLogger(LogLevel.INFO, "GenerateMessageNode"); diff --git a/apps/open-swe/src/nodes/interrupt-plan.ts b/apps/open-swe/src/nodes/interrupt-plan.ts index a9e03dbf..deb06e90 100644 --- a/apps/open-swe/src/nodes/interrupt-plan.ts +++ b/apps/open-swe/src/nodes/interrupt-plan.ts @@ -6,7 +6,7 @@ import { HumanResponse, } from "@langchain/langgraph/prebuilt"; import { startSandbox } from "../utils/sandbox.js"; -import { createNewTask } from "../utils/task-plan.js"; +import { createNewTask } from "@open-swe/shared/open-swe/tasks"; import { getUserRequest } from "../utils/user-request.js"; import { PLAN_INTERRUPT_ACTION_TITLE, diff --git a/apps/open-swe/src/nodes/open-pr.ts b/apps/open-swe/src/nodes/open-pr.ts index 2d5cf9f3..9a13b9b6 100644 --- a/apps/open-swe/src/nodes/open-pr.ts +++ b/apps/open-swe/src/nodes/open-pr.ts @@ -19,7 +19,7 @@ import { getUserRequest } from "../utils/user-request.js"; import { ToolMessage } from "@langchain/core/messages"; import { daytonaClient, deleteSandbox } from "../utils/sandbox.js"; import { getGitHubTokensFromConfig } from "../utils/github-tokens.js"; -import { getActivePlanItems } from "../utils/task-plan.js"; +import { getActivePlanItems } from "@open-swe/shared/open-swe/tasks"; const logger = createLogger(LogLevel.INFO, "Open PR"); diff --git a/apps/open-swe/src/nodes/progress-plan-step.ts b/apps/open-swe/src/nodes/progress-plan-step.ts index 5e247e45..06e2c46b 100644 --- a/apps/open-swe/src/nodes/progress-plan-step.ts +++ b/apps/open-swe/src/nodes/progress-plan-step.ts @@ -16,7 +16,7 @@ import { completePlanItem, getActivePlanItems, getActiveTask, -} from "../utils/task-plan.js"; +} from "@open-swe/shared/open-swe/tasks"; import { getCurrentPlanItem, getRemainingPlanItems, diff --git a/apps/open-swe/src/nodes/summarize-task-steps.ts b/apps/open-swe/src/nodes/summarize-task-steps.ts index 798a56ed..46027aec 100644 --- a/apps/open-swe/src/nodes/summarize-task-steps.ts +++ b/apps/open-swe/src/nodes/summarize-task-steps.ts @@ -19,7 +19,7 @@ import { completePlanItem, getActivePlanItems, getActiveTask, -} from "../utils/task-plan.js"; +} from "@open-swe/shared/open-swe/tasks"; import { getCompletedPlanItems } from "../utils/current-task.js"; const taskSummarySysPrompt = `You are operating as a terminal-based agentic coding assistant built by LangChain. It wraps LLM models to enable natural language interaction with a local codebase. You are expected to be precise, safe, and helpful. diff --git a/apps/open-swe/src/nodes/update-plan.ts b/apps/open-swe/src/nodes/update-plan.ts index 80269df7..1de15711 100644 --- a/apps/open-swe/src/nodes/update-plan.ts +++ b/apps/open-swe/src/nodes/update-plan.ts @@ -6,7 +6,10 @@ import { } from "@open-swe/shared/open-swe/types"; import { loadModel, Task } from "../utils/load-model.js"; import { z } from "zod"; -import { getActiveTask, updateTaskPlanItems } from "../utils/task-plan.js"; +import { + getActiveTask, + updateTaskPlanItems, +} from "@open-swe/shared/open-swe/tasks"; import { BaseMessage, isAIMessage, diff --git a/apps/web/src/components/task-list-sidebar.tsx b/apps/web/src/components/task-list-sidebar.tsx index 93ed42dd..b8fbd6c7 100644 --- a/apps/web/src/components/task-list-sidebar.tsx +++ b/apps/web/src/components/task-list-sidebar.tsx @@ -6,10 +6,12 @@ import { PanelRightOpen, } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { useThreads, ThreadWithTasks } from "@/providers/Thread"; +import { useThreads } from "@/providers/Thread"; import { useQueryState, parseAsString } from "nuqs"; import { useState, useCallback } from "react"; import { ThreadItem } from "./thread-item"; +import { Thread } from "@langchain/langgraph-sdk"; +import { GraphState } from "@open-swe/shared/open-swe/types"; const THREADS_PER_PAGE = 10; @@ -18,13 +20,12 @@ interface TaskListSidebarProps { } export default function TaskListSidebar({ onCollapse }: TaskListSidebarProps) { - const [taskId, setTaskId] = useQueryState("taskId", parseAsString); const [threadId, setThreadId] = useQueryState("threadId", parseAsString); const [currentPage, setCurrentPage] = useState(0); const { threads, threadsLoading, handleThreadClick } = useThreads(); const onThreadClick = useCallback( - (thread: ThreadWithTasks) => { + (thread: Thread) => { handleThreadClick(thread, threadId, setThreadId); }, [handleThreadClick, threadId, setThreadId], diff --git a/apps/web/src/components/task-list.tsx b/apps/web/src/components/task-list.tsx index c420940a..9131d776 100644 --- a/apps/web/src/components/task-list.tsx +++ b/apps/web/src/components/task-list.tsx @@ -1,10 +1,12 @@ "use client"; import { Archive, ChevronLeft, ChevronRight } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { useThreads, ThreadWithTasks } from "@/providers/Thread"; +import { useThreads } from "@/providers/Thread"; import { useQueryState, parseAsString } from "nuqs"; import { useState, useCallback } from "react"; import { ThreadItem } from "./thread-item"; +import { Thread } from "@langchain/langgraph-sdk"; +import { GraphState } from "@open-swe/shared/open-swe/types"; const THREADS_PER_PAGE = 5; @@ -17,7 +19,7 @@ export default function TaskList() { const isDashboardMode = !taskId; const onThreadClick = useCallback( - (thread: ThreadWithTasks) => { + (thread: Thread) => { handleThreadClick(thread, threadId, setThreadId); setTaskId(null); }, diff --git a/apps/web/src/components/thread-item.tsx b/apps/web/src/components/thread-item.tsx index 4afd0f4c..df362401 100644 --- a/apps/web/src/components/thread-item.tsx +++ b/apps/web/src/components/thread-item.tsx @@ -1,21 +1,19 @@ "use client"; import { memo } from "react"; -import { - formatDistanceToNow, - differenceInHours, - differenceInMinutes, - format, -} from "date-fns"; +import { differenceInHours, differenceInMinutes, format } from "date-fns"; import { GitBranch, ArrowRight, ListTodo } from "lucide-react"; -import { ThreadWithTasks, useThreads } from "@/providers/Thread"; +import { useThreads } from "@/providers/Thread"; import { cn } from "@/lib/utils"; import { StatusIndicator } from "@/components/status-indicator"; import { GitHubSVG } from "./icons/github"; import { useQueryState } from "nuqs"; +import { Thread } from "@langchain/langgraph-sdk"; +import { GraphState } from "@open-swe/shared/open-swe/types"; +import { getThreadTasks, getThreadTitle } from "@/lib/thread"; interface ThreadItemProps { - thread: ThreadWithTasks; - onClick: (thread: ThreadWithTasks) => void; + thread: Thread; + onClick: (thread: Thread) => void; variant?: "sidebar" | "dashboard"; className?: string; } @@ -55,6 +53,8 @@ export const ThreadItem = memo(function ThreadItem({ const displayDate = formatRelativeDate(thread.created_at); + const { totalTasks, completedTasks } = getThreadTasks(thread); + return (

- {thread.threadTitle} + {getThreadTitle(thread)}

@@ -85,10 +85,14 @@ export const ThreadItem = memo(function ThreadItem({ height="16" className="flex-shrink-0" /> - {thread.repository} + + {thread.values.targetRepository.repo} + / - {thread.branch} + + {thread.values.targetRepository.branch} + • @@ -101,7 +105,7 @@ export const ThreadItem = memo(function ThreadItem({
- {thread.completedTasksCount}/{thread.totalTasksCount} tasks + {completedTasks}/{totalTasks} tasks
@@ -111,7 +115,7 @@ export const ThreadItem = memo(function ThreadItem({
- {thread.completedTasksCount}/{thread.totalTasksCount} tasks + {completedTasks}/{totalTasks} tasks
)} diff --git a/apps/web/src/hooks/useThreadPolling.ts b/apps/web/src/hooks/useThreadPolling.ts index 042e388e..d5007884 100644 --- a/apps/web/src/hooks/useThreadPolling.ts +++ b/apps/web/src/hooks/useThreadPolling.ts @@ -1,12 +1,13 @@ import { useEffect, useRef } from "react"; import { ThreadPoller, PollConfig } from "@/lib/polling/thread-poller"; -import { ThreadWithTasks } from "@/providers/Thread"; +import { GraphState } from "@open-swe/shared/open-swe/types"; +import { Thread } from "@langchain/langgraph-sdk"; interface UseThreadPollingProps { - threads: ThreadWithTasks[]; - getThread: (threadId: string) => Promise; + threads: Thread[]; + getThread: (threadId: string) => Promise | null>; onUpdate: ( - updatedThreads: ThreadWithTasks[], + updatedThreads: Thread[], changedThreadIds: string[], ) => void; diff --git a/apps/web/src/lib/polling/thread-poller.ts b/apps/web/src/lib/polling/thread-poller.ts index 9cfa43b4..d804de43 100644 --- a/apps/web/src/lib/polling/thread-poller.ts +++ b/apps/web/src/lib/polling/thread-poller.ts @@ -1,9 +1,11 @@ -import { ThreadWithTasks } from "@/providers/Thread"; +import { Thread } from "@langchain/langgraph-sdk"; +import { GraphState } from "@open-swe/shared/open-swe/types"; +import { getThreadTasks, getThreadTitle } from "../thread"; export interface PollConfig { interval: number; onUpdate: ( - updatedThreads: ThreadWithTasks[], + updatedThreads: Thread[], changedThreadIds: string[], ) => void; } @@ -12,13 +14,13 @@ export class ThreadPoller { private config: PollConfig; private isPolling: boolean = false; private intervalId: NodeJS.Timeout | null = null; - private threads: ThreadWithTasks[]; - private getThreadFn: (threadId: string) => Promise; + private threads: Thread[]; + private getThreadFn: (threadId: string) => Promise | null>; constructor( config: PollConfig, - threads: ThreadWithTasks[], - getThreadFn: (threadId: string) => Promise, + threads: Thread[], + getThreadFn: (threadId: string) => Promise | null>, ) { this.config = config; this.threads = threads; @@ -49,7 +51,7 @@ export class ThreadPoller { const currentThreads = this.threads; const threadsToPool = currentThreads.slice(0, 10); - const updatedThreads: ThreadWithTasks[] = []; + const updatedThreads: Thread[] = []; const changedThreadIds: string[] = []; const errors: string[] = []; @@ -79,19 +81,24 @@ export class ThreadPoller { } private hasThreadChanged( - current: ThreadWithTasks, - updated: ThreadWithTasks, + current: Thread, + updated: Thread, ): boolean { + const currentTaskCounts = getThreadTasks(current); + const updatedTaskCounts = getThreadTasks(updated); + const currentTargetRepo = current.values?.targetRepository; + const updatedTargetRepo = updated.values?.targetRepository; return ( - current.completedTasksCount !== updated.completedTasksCount || - current.totalTasksCount !== updated.totalTasksCount || + currentTaskCounts.completedTasks !== updatedTaskCounts.completedTasks || + currentTaskCounts.totalTasks !== updatedTaskCounts.totalTasks || current.status !== updated.status || - current.threadTitle !== updated.threadTitle || - current.repository !== updated.repository || - current.branch !== updated.branch || - JSON.stringify(current.tasks) !== JSON.stringify(updated.tasks) || - JSON.stringify(current.proposedPlan) !== - JSON.stringify(updated.proposedPlan) + getThreadTitle(current) !== getThreadTitle(updated) || + currentTargetRepo.repo !== updatedTargetRepo.repo || + currentTargetRepo.branch !== updatedTargetRepo.branch || + JSON.stringify(current.values?.plan) !== + JSON.stringify(updated.values?.plan) || + JSON.stringify(current.values?.proposedPlan) !== + JSON.stringify(updated.values?.proposedPlan) ); } } diff --git a/apps/web/src/lib/thread.ts b/apps/web/src/lib/thread.ts new file mode 100644 index 00000000..55d3ac4f --- /dev/null +++ b/apps/web/src/lib/thread.ts @@ -0,0 +1,32 @@ +import { Thread } from "@langchain/langgraph-sdk"; +import { getMessageContentString } from "@open-swe/shared/messages"; +import { GraphState } from "@open-swe/shared/open-swe/types"; +import { getActivePlanItems } from "@open-swe/shared/open-swe/tasks"; + +export function getThreadTitle(thread: Thread): string { + const messages = thread?.values.messages; + if (!messages?.length || !messages[0]?.content) { + return `Thread ${thread.thread_id.substring(0, 8)}`; + } + const threadTitle = getMessageContentString(messages[0].content); + return threadTitle; +} + +export function getThreadTasks(thread: Thread): { + totalTasks: number; + completedTasks: number; +} { + if (!thread.values.plan) { + return { + totalTasks: 0, + completedTasks: 0, + }; + } + const activePlanItems = getActivePlanItems(thread.values.plan); + const totalTasks = activePlanItems.length; + const completedTasks = activePlanItems.filter((p) => p.completed).length; + return { + totalTasks, + completedTasks, + }; +} diff --git a/apps/web/src/providers/Thread.tsx b/apps/web/src/providers/Thread.tsx index 61429cb0..9c853cd9 100644 --- a/apps/web/src/providers/Thread.tsx +++ b/apps/web/src/providers/Thread.tsx @@ -12,31 +12,20 @@ import { useTransition, } from "react"; import { createClient } from "./client"; -import { getMessageContentString } from "@open-swe/shared/messages"; import { TaskPlan, GraphState } from "@open-swe/shared/open-swe/types"; import { useThreadPolling } from "@/hooks/useThreadPolling"; -export interface ThreadWithTasks extends Thread { - threadTitle: string; - repository: string; - branch: string; - completedTasksCount: number; - totalTasksCount: number; - tasks: TaskPlan | undefined; - proposedPlan: string[]; -} - interface ThreadContextType { - threads: ThreadWithTasks[]; - setThreads: Dispatch>; + threads: Thread[]; + setThreads: Dispatch[]>>; threadsLoading: boolean; setThreadsLoading: Dispatch>; refreshThreads: () => Promise; - getThread: (threadId: string) => Promise; + getThread: (threadId: string) => Promise | null>; isPending: boolean; recentlyUpdatedThreads: Set; handleThreadClick: ( - thread: ThreadWithTasks, + thread: Thread, currentThreadId: string | null, setThreadId: (id: string) => void, ) => void; @@ -115,7 +104,7 @@ export function ThreadProvider({ children }: { children: ReactNode }) { const assistantId: string | undefined = process.env.NEXT_PUBLIC_ASSISTANT_ID ?? ""; - const [threads, setThreads] = useState([]); + const [threads, setThreads] = useState[]>([]); const [threadsLoading, setThreadsLoading] = useState(false); const [isPending, startTransition] = useTransition(); const [recentlyUpdatedThreads, setRecentlyUpdatedThreads] = useState< @@ -123,23 +112,13 @@ export function ThreadProvider({ children }: { children: ReactNode }) { >(new Set()); const getThread = useCallback( - async (threadId: string): Promise => { + async (threadId: string): Promise | null> => { if (!apiUrl || !assistantId) return null; const client = createClient(apiUrl); try { - const thread = await client.threads.get(threadId); - - // Get the comprehensive state data which contains the plan - let stateData: { values: GraphState } | null = null; - - try { - stateData = await client.threads.getState(threadId); - } catch (stateError) { - console.error("Failed to get state data:", stateError); - } - - return enhanceThreadWithTasks(thread, stateData); + const thread = await client.threads.get(threadId); + return thread; } catch (error) { console.error("Failed to fetch thread:", threadId, error); return null; @@ -148,42 +127,6 @@ export function ThreadProvider({ children }: { children: ReactNode }) { [apiUrl, assistantId], ); - const enhanceThreadWithTasks = ( - thread: Thread, - stateData?: { values: GraphState } | null, - ): ThreadWithTasks => { - const stateValues = stateData?.values; - const threadValues = thread.values as GraphState; - - const plan: TaskPlan | undefined = stateValues?.plan || threadValues?.plan; - const proposedPlan: string[] = - stateValues?.proposedPlan || threadValues?.proposedPlan || []; - - const targetRepository = - stateValues?.targetRepository || threadValues?.targetRepository; - const messages = stateValues?.messages || threadValues?.messages; - const firstMessageContent = messages?.[0]?.content; - const threadTitle = firstMessageContent - ? getMessageContentString(firstMessageContent) - : `Thread ${thread.thread_id.substring(0, 8)}`; - - const { totalTasksCount, completedTasksCount } = getTaskCounts( - plan, - proposedPlan, - ); - - return { - ...thread, - threadTitle, - repository: targetRepository?.repo || "Unknown Repository", - branch: targetRepository?.branch || "main", - completedTasksCount, - totalTasksCount, - tasks: plan, - proposedPlan, - }; - }; - const refreshThreads = useCallback(async (): Promise => { if (!apiUrl || !assistantId) return; @@ -196,42 +139,25 @@ export function ThreadProvider({ children }: { children: ReactNode }) { metadata: getThreadSearchMetadata(assistantId), }; - let threadsResponse = await client.threads.search(searchParams); + let threadsResponse = + await client.threads.search(searchParams); if (threadsResponse.length === 0) { const altMetadata = assistantId.includes("-") ? { assistant_id: assistantId } : { graph_id: assistantId }; - threadsResponse = await client.threads.search({ + threadsResponse = await client.threads.search({ limit: 100, metadata: altMetadata, }); } - const enhancedThreads: ThreadWithTasks[] = []; - for (const thread of threadsResponse) { - try { - const fullThread = await client.threads.get(thread.thread_id); - - let stateData: { values: GraphState } | null = null; - try { - stateData = await client.threads.getState(thread.thread_id); - } catch (stateError) { - console.error("Failed to get state data:", stateError); - } - - enhancedThreads.push(enhanceThreadWithTasks(fullThread, stateData)); - } catch (error) { - console.error(`Failed to enhance thread ${thread.thread_id}:`, error); - } - } - - enhancedThreads.sort( + threadsResponse.sort( (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), ); - setThreads(enhancedThreads); + setThreads(threadsResponse); } catch (error) { console.error("Failed to fetch threads:", error); } finally { @@ -244,7 +170,7 @@ export function ThreadProvider({ children }: { children: ReactNode }) { }, [refreshThreads]); const handlePollingUpdate = useCallback( - (updatedThreads: ThreadWithTasks[], changedThreadIds: string[]) => { + (updatedThreads: Thread[], changedThreadIds: string[]) => { setThreads((currentThreads) => { const updatedMap = new Map(updatedThreads.map((t) => [t.thread_id, t])); return currentThreads.map( @@ -271,7 +197,7 @@ export function ThreadProvider({ children }: { children: ReactNode }) { const handleThreadClick = useCallback( ( - thread: ThreadWithTasks, + thread: Thread, currentThreadId: string | null, setThreadId: (id: string) => void, ) => { diff --git a/packages/shared/src/open-swe/tasks.ts b/packages/shared/src/open-swe/tasks.ts new file mode 100644 index 00000000..030d1ca1 --- /dev/null +++ b/packages/shared/src/open-swe/tasks.ts @@ -0,0 +1,274 @@ +import { v4 as uuidv4 } from "uuid"; +import { PlanItem, Task, TaskPlan, PlanRevision } from "./types.js"; + +/** + * Creates a new task with the provided plan items. + * Can either add to an existing TaskPlan or create a brand new one. + * + * @param request The original user request text that initiated this task + * @param planItems The plan items to include in the new task + * @param existingTaskPlan Optional existing TaskPlan to add the new task to + * @param parentTaskId Optional ID of a parent task if this task is derived from another + * @returns The updated TaskPlan with the new task added + */ +export function createNewTask( + request: string, + planItems: PlanItem[], + existingTaskPlan?: TaskPlan, + parentTaskId?: string, +): TaskPlan { + // Create the initial plan revision + const initialRevision: PlanRevision = { + revisionIndex: 0, + plans: planItems, + createdAt: Date.now(), + createdBy: "agent", + }; + + // Create the new task + const newTask: Task = { + id: uuidv4(), + taskIndex: existingTaskPlan ? existingTaskPlan.tasks.length : 0, + request, + createdAt: Date.now(), + completed: false, + planRevisions: [initialRevision], + activeRevisionIndex: 0, + parentTaskId, + }; + + // If there's an existing task plan, add the new task to it + if (existingTaskPlan) { + return { + tasks: [...existingTaskPlan.tasks, newTask], + activeTaskIndex: existingTaskPlan.tasks.length, // Set the new task as active + }; + } + + // Otherwise create a new task plan with just this task + return { + tasks: [newTask], + activeTaskIndex: 0, + }; +} + +/** + * Updates the plan items for an existing task by creating a new revision. + * + * @param taskPlan The current task plan + * @param taskId The ID of the task to update + * @param planItems The new plan items + * @param createdBy Who created this revision ('agent' or 'user') + * @returns The updated TaskPlan with the new revision + * @throws Error if the task ID doesn't exist + */ +export function updateTaskPlanItems( + taskPlan: TaskPlan, + taskId: string, + planItems: PlanItem[], + createdBy: "agent" | "user" = "agent", +): TaskPlan { + // Find the task to update + const taskIndex = taskPlan.tasks.findIndex((task) => task.id === taskId); + + if (taskIndex === -1) { + throw new Error(`Task with ID ${taskId} not found`); + } + + const task = taskPlan.tasks[taskIndex]; + + // Create a new revision with the updated plan items + const newRevision: PlanRevision = { + revisionIndex: task.planRevisions.length, + plans: planItems, + createdAt: Date.now(), + createdBy, + }; + + // Create an updated task with the new revision + const updatedTask: Task = { + ...task, + planRevisions: [...task.planRevisions, newRevision], + activeRevisionIndex: task.planRevisions.length, // Set the new revision as active + }; + + // Create a new array of tasks with the updated task + const updatedTasks = [...taskPlan.tasks]; + updatedTasks[taskIndex] = updatedTask; + + // Return the updated task plan + return { + ...taskPlan, + tasks: updatedTasks, + }; +} + +/** + * Helper function to get the active task from a TaskPlan + * + * @param taskPlan The task plan + * @returns The currently active task + * @throws Error if there are no tasks + */ +export function getActiveTask(taskPlan: TaskPlan): Task { + if (taskPlan.tasks.length === 0) { + throw new Error("No tasks available"); + } + + return taskPlan.tasks[taskPlan.activeTaskIndex]; +} + +/** + * Helper function to get the active plan items for the active task + * + * @param taskPlan The task plan + * @returns The currently active plan items + * @throws Error if there are no tasks or no plan revisions + */ +export function getActivePlanItems(taskPlan: TaskPlan): PlanItem[] { + const activeTask = getActiveTask(taskPlan); + + if (activeTask.planRevisions.length === 0) { + throw new Error("No plan revisions available for the active task"); + } + + return activeTask.planRevisions[activeTask.activeRevisionIndex].plans; +} + +/** + * Marks a specific plan item as completed and adds a summary. + * This operation modifies the current active revision directly and does NOT create a new revision. + * + * @param taskPlan The current task plan + * @param taskId The ID of the task containing the plan item + * @param planItemIndex The `index` property of the plan item to mark as completed + * @param summary Optional summary of the completed plan item. If undefined, existing summary is preserved. + * @returns The updated TaskPlan + * @throws Error if the task or plan item is not found, or if no active revision exists. + */ +export function completePlanItem( + taskPlan: TaskPlan, + taskId: string, + planItemIndex: number, + summary?: string, +): TaskPlan { + const taskIndexInPlan = taskPlan.tasks.findIndex( + (task) => task.id === taskId, + ); + + if (taskIndexInPlan === -1) { + throw new Error(`Task with ID ${taskId} not found in task plan`); + } + + const originalTask = taskPlan.tasks[taskIndexInPlan]; + + const activeRevisionIndex = originalTask.activeRevisionIndex; + + // Ensure there's a planRevisions array and the activeRevisionIndex is valid + if ( + !originalTask.planRevisions || + activeRevisionIndex < 0 || + activeRevisionIndex >= originalTask.planRevisions.length + ) { + throw new Error( + `Invalid active revision index (${activeRevisionIndex}) for task ${taskId}`, + ); + } + + const originalActiveRevision = + originalTask.planRevisions[activeRevisionIndex]; + + // This check should be covered by the index check, but reinforces intent + if (!originalActiveRevision) { + // This case implies an issue with activeRevisionIndex or planRevisions structure + throw new Error( + `Active revision (index ${activeRevisionIndex}) not found for task ${taskId}`, + ); + } + + const planItemToUpdateActualIndexInPlansArray = + originalActiveRevision.plans.findIndex( + (item) => item.index === planItemIndex, + ); + + if (planItemToUpdateActualIndexInPlansArray === -1) { + throw new Error( + `Plan item with .index ${planItemIndex} not found in active revision (index ${activeRevisionIndex}) of task ${taskId}`, + ); + } + + // Create a new 'plans' array with the specific item updated + const updatedPlansForRevision = originalActiveRevision.plans.map((item) => { + if (item.index === planItemIndex) { + const newSummary = summary !== undefined ? summary : item.summary; + return { ...item, completed: true, summary: newSummary }; + } + return item; + }); + + // Create a new 'PlanRevision' object for the active revision, with the updated 'plans' + const updatedActiveRevision: PlanRevision = { + ...originalActiveRevision, + plans: updatedPlansForRevision, + }; + + // Create a new 'planRevisions' array, replacing the active revision with the updated one + const updatedPlanRevisions = [...originalTask.planRevisions]; + updatedPlanRevisions[activeRevisionIndex] = updatedActiveRevision; + + // Create a new 'Task' object with the updated 'planRevisions' + const updatedTask: Task = { + ...originalTask, + planRevisions: updatedPlanRevisions, + }; + + // Create a new 'tasks' array for the TaskPlan, replacing the updated task + const updatedTasksArray = [...taskPlan.tasks]; + updatedTasksArray[taskIndexInPlan] = updatedTask; + + // Return the new TaskPlan object + return { + ...taskPlan, + tasks: updatedTasksArray, + }; +} + +/** + * Marks a task as completed + * + * @param taskPlan The current task plan + * @param taskId The ID of the task to mark as completed + * @param summary Optional summary of the completed task + * @returns The updated TaskPlan + */ +export function completeTask( + taskPlan: TaskPlan, + taskId: string, + summary?: string, +): TaskPlan { + const taskIndex = taskPlan.tasks.findIndex((task) => task.id === taskId); + + if (taskIndex === -1) { + throw new Error(`Task with ID ${taskId} not found`); + } + + const task = taskPlan.tasks[taskIndex]; + + // Create an updated task marked as completed + const updatedTask: Task = { + ...task, + completed: true, + completedAt: Date.now(), + summary, + }; + + // Create a new array of tasks with the updated task + const updatedTasks = [...taskPlan.tasks]; + updatedTasks[taskIndex] = updatedTask; + + // Return the updated task plan + return { + ...taskPlan, + tasks: updatedTasks, + }; +}