From 1a523e7d1b14a7bdc5451cd9fbdf719492ca8baf Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 1 Jul 2026 23:44:05 -0700 Subject: [PATCH] FN-7407: enable done-task planner refinements Enable planner chat on completed tasks and let clear follow-up requests create refinements. - Allow task-planner chat sessions for done tasks while keeping archived tasks blocked. - Add a task-scoped refinement tool that creates follow-up tasks only from completed source tasks. - Render refinement creation, pending, and failure states in the planner chat transcript. - Extend planner-chat context, route, manager, and component tests for done-task chat and refinement behavior. - Document the completed-task planner chat behavior and add a patch changeset. Files changed: .changeset/fn-7407-done-planner-chat.md | 7 ++ docs/dashboard-guide.md | 5 +- .../app/components/TaskPlannerChatTab.tsx | 50 +++++++- .../__tests__/TaskPlannerChatTab.test.tsx | 66 ++++++++++- .../dashboard/src/__tests__/chat-manager.test.ts | 126 ++++++++++++++++++++- .../dashboard/src/__tests__/chat-routes.test.ts | 41 ++++++- .../__tests__/task-planner-chat-context.test.ts | 5 +- packages/dashboard/src/chat.ts | 66 ++++++++++- .../dashboard/src/routes/register-chat-routes.ts | 9 +- .../dashboard/src/task-planner-chat-context.ts | 5 +- 10 files changed, 360 insertions(+), 20 deletions(-) Fusion-Task-Id: FN-7407 Fusion-Task-Lineage: b3214d89-a59c-4682-b5cd-98d7d6cc09e2 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7407-done-planner-chat.md | 7 + docs/dashboard-guide.md | 5 +- .../app/components/TaskPlannerChatTab.tsx | 50 ++++++- .../__tests__/TaskPlannerChatTab.test.tsx | 66 ++++++++- .../src/__tests__/chat-manager.test.ts | 126 +++++++++++++++++- .../src/__tests__/chat-routes.test.ts | 41 +++++- .../task-planner-chat-context.test.ts | 5 +- packages/dashboard/src/chat.ts | 66 ++++++++- .../src/routes/register-chat-routes.ts | 9 +- .../src/task-planner-chat-context.ts | 5 +- 10 files changed, 360 insertions(+), 20 deletions(-) create mode 100644 .changeset/fn-7407-done-planner-chat.md diff --git a/.changeset/fn-7407-done-planner-chat.md b/.changeset/fn-7407-done-planner-chat.md new file mode 100644 index 0000000000..a051f808b6 --- /dev/null +++ b/.changeset/fn-7407-done-planner-chat.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Allow completed task planner Chat to answer and create refinements. +category: feature +dev: Adds done-task task-planner session creation and `fn_task_planner_create_refinement`. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 661bcaa97a..a2dc27a16d 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -407,7 +407,8 @@ Chat view provides project-scoped conversations with agents. - On mobile direct-chat threads, tapping the active title/identity in the thread header opens a lightweight conversation dropdown so you can switch to another direct session or start a New Chat without backing out to the sidebar list first; long conversation titles now stay readable in the dropdown via wrapped option text and taller touch-friendly rows. - Direct chat sessions can be renamed from the desktop conversation context menu and from the mobile session switcher; blank rename submissions clear the custom title so the default session label is shown again. -- Task-detail planner Chat conversations stay available from each task's **Chat** tab. They are hidden from the common Direct/common Chat feed by default; enable **Settings → Project General → Show task chats in common Chat feed** to include populated task chats again. Empty task chat sessions stay hidden either way. Planner Chat can answer token-count, estimated-cost, runtime, timing-event, workflow-step duration, and per-model usage questions for the current task through a read-only task-scoped metrics tool; unknown/stale pricing is reported as uncertain instead of `$0`. + +- Task-detail planner Chat conversations stay available from each task's **Chat** tab, including after the task is `done`. They are hidden from the common Direct/common Chat feed by default; enable **Settings → Project General → Show task chats in common Chat feed** to include populated task chats again. Empty task chat sessions stay hidden either way. Planner Chat can answer token-count, estimated-cost, runtime, timing-event, workflow-step duration, and per-model usage questions for the current task through a read-only task-scoped metrics tool; unknown/stale pricing is reported as uncertain instead of `$0`. On completed tasks, clear follow-up implementation or improvement requests can create a normal refinement task from the completed source task. - On desktop/tablet Direct chat, the thread header shows an estimated token count against the active model's known context window (for example `~12.3k / 200k`). It is hidden on mobile, narrow floating chat, rooms, and unknown-context-window models. @@ -1137,7 +1138,7 @@ Recommended workflow: ordinary chains stay as `Blocks N` so noise stays low, hig The **Activity** tab is the first task-detail tab by default and presents a segmented control for **Live**, **Feed**, and **Raw Logs**. Live contains the live, chat-styled transcript of task agent output. Consecutive entries are grouped by role and labeled as Planner, Executor, Reviewer, or Merger; legacy log rows without an agent role use the neutral Agent fallback. Agent group headers and user message headers show a small muted relative timestamp (for example, “just now”, “1m ago”, or “2h ago”) based on the transcript timestamp, while agent group metadata still includes the entry count. Consecutive text/message chunks inside a role group render as one continuous markdown bubble, while consecutive tool/tool-result/tool-error rows collapse into one expandable, compact tool-call summary that stays collapsed by default and mirrors regular Chat's dense treatment; the summary stays single-line/ellipsis-friendly on desktop and mobile, counts tool invocations, lists deduped tool names with overflow, and shows an error count when failures are present, while the expanded body pairs each call with its result or error in dense entry cards. Thinking entries render in a collapsible block that starts expanded. The transcript opens at the latest output whenever the tab loads or becomes active, then follows new live output when you are already near the bottom while preserving your scroll position when you review older messages. When older task-agent history exists, scrolling to the top or selecting **Load previous messages** prepends earlier transcript entries without moving the message you were reading. When you scroll away from the bottom of a populated transcript, a sticky **Latest** button appears inside the transcript so you can jump back to the newest message and resume live follow. For non-`done` tasks, the Activity Live composer sends typed guidance through the same steering path used by comments, including active planning/triage, `in-progress`, and `in-review` sessions, plus live CLI-agent sessions reported by the session bridge; an `in-review` Activity Live message or Comments-tab task comment re-engages an executor unless an open PR blocks moving the task back, and other messages are still saved as queued guidance when no session is currently live. Feed and Raw Logs do not show the composer. On a `done` task, the same composer starts a refinement task using the typed text as feedback and shows a success toast with the new task ID, while the current task detail modal remains on the completed task. The task-detail Activity Live segment keeps the composer pinned and visible on mobile and desktop while the transcript scrolls internally; its textarea placeholder reads “Steer the currently executing agent” for steering mode and switches to refinement copy for completed tasks, with the same inline, icon-only send affordance to the right of the input at every breakpoint. In the composer, plain **Enter** sends, **Shift+Enter** inserts a newline, and **Cmd/Ctrl+Enter** remains a supported send shortcut. -The top-level **Chat** tab opens the planner-model conversation for the same task instead of posting steering comments. It appears after Activity by default, or before Activity when **Settings → Appearance → Open task details with Chat first** is enabled. Each send includes server-built, bounded context for the task id, status/column/progress/current step, dependencies, recent activity/comment excerpts, prompt/plan content, and available source/review state; unavailable sections are labeled so the planner states uncertainty rather than inventing execution evidence. Opening the tab with no existing history does not create a database chat row; when no planner-chat history is found, Chat shows a guided empty state with starter prompts for recent activity, current status/blockers, next best action, and plan/definition review. Selecting a starter creates/resumes the planner chat and sends that prompt as an ordinary chat message through the task-context-aware planner-chat composer/stream path. The starter prompts disappear while history is loading or after conversation history exists, so Activity Live, Feed, Raw Logs, and the steering composer remain separate. Planner Chat uses the same standard chat bubble, markdown/plain assistant rendering, thinking details, tool-call/question cards, and mobile first-tap send/stop affordance as the main Chat view while keeping task-scoped planner sessions separate. Planner Chat defaults to focused mode, keeps its composer visible at the bottom while only the transcript scrolls, and on narrow/mobile task-detail layouts collapses nonessential rows above the chat until the user selects the Chat collapse control. +The top-level **Chat** tab opens the planner-model conversation for the same task instead of posting steering comments. It appears after Activity by default, or before Activity when **Settings → Appearance → Open task details with Chat first** is enabled. Each send includes server-built, bounded context for the task id, status/column/progress/current step, dependencies, recent activity/comment excerpts, prompt/plan content, and available source/review state; unavailable sections are labeled so the planner states uncertainty rather than inventing execution evidence. Opening the tab with no existing history does not create a database chat row; when no planner-chat history is found, Chat shows a guided empty state with starter prompts for recent activity, current status/blockers, next best action, and plan/definition review. Selecting a starter creates/resumes the planner chat and sends that prompt as an ordinary chat message through the task-context-aware planner-chat composer/stream path, including for completed tasks. On live tasks, clear bounded implementation-change requests are routed to task steering; on `done` tasks, clear follow-up implementation or improvement requests are routed through a task-scoped planner refinement tool that calls the same refinement creation path as the completed-task Activity composer. The starter prompts disappear while history is loading or after conversation history exists, so Activity Live, Feed, Raw Logs, and the steering/refinement composer remain separate. Planner Chat uses the same standard chat bubble, markdown/plain assistant rendering, thinking details, tool-call/question cards, and mobile first-tap send/stop affordance as the main Chat view while keeping task-scoped planner sessions separate. Planner Chat defaults to focused mode, keeps its composer visible at the bottom while only the transcript scrolls, and on narrow/mobile task-detail layouts collapses nonessential rows above the chat until the user selects the Chat collapse control. The **Raw Logs** segment is designed for debugging long-running and tool-heavy sessions, while legacy links that requested the former top-level Logs tab land on Activity → Feed: diff --git a/packages/dashboard/app/components/TaskPlannerChatTab.tsx b/packages/dashboard/app/components/TaskPlannerChatTab.tsx index b71a0f66f8..1163188520 100644 --- a/packages/dashboard/app/components/TaskPlannerChatTab.tsx +++ b/packages/dashboard/app/components/TaskPlannerChatTab.tsx @@ -135,6 +135,7 @@ function makeStreamingAssistantMessage(sessionId: string, content: string, toolC } const TASK_PLANNER_STEERING_TOOL_NAME = "fn_task_planner_add_steering"; +const TASK_PLANNER_REFINEMENT_TOOL_NAME = "fn_task_planner_create_refinement"; interface PlannerSteeringResult { text: string; @@ -142,6 +143,12 @@ interface PlannerSteeringResult { createdAt?: string; } +interface PlannerRefinementResult { + sourceTaskId: string; + refinementTaskId: string; + description?: string; +} + function readRecord(value: unknown): Record | null { return value && typeof value === "object" ? value as Record : null; } @@ -178,6 +185,20 @@ function extractPlannerSteeringTextFromResult(result: unknown): string | null { return text || null; } +function extractPlannerRefinementResult(toolCall: ToolCallInfo): PlannerRefinementResult | null { + if (toolCall.toolName !== TASK_PLANNER_REFINEMENT_TOOL_NAME || toolCall.isError || toolCall.status === "running") return null; + const resultRecord = readRecord(toolCall.result); + const detailsRecord = readRecord(resultRecord?.details) ?? resultRecord; + const sourceTaskId = typeof detailsRecord?.sourceTaskId === "string" ? detailsRecord.sourceTaskId.trim() : ""; + const refinementTaskId = typeof detailsRecord?.refinementTaskId === "string" ? detailsRecord.refinementTaskId.trim() : ""; + if (!sourceTaskId || !refinementTaskId) return null; + return { + sourceTaskId, + refinementTaskId, + ...(typeof detailsRecord?.description === "string" && detailsRecord.description.trim() ? { description: detailsRecord.description.trim() } : {}), + }; +} + function normalizeChatFailureSummary(error: string | ChatFailureInfo, fallback: string): string { return typeof error === "string" ? error || fallback : error.summary || fallback; } @@ -632,6 +653,30 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false, ); } + const refinementResult = extractPlannerRefinementResult(toolCall); + if (refinementResult) { + return ( +
+ {t("taskDetail.plannerChat.refinementCreated", "Created refinement task")} {refinementResult.refinementTaskId} + {refinementResult.description &&

{refinementResult.description}

} +
+ ); + } + const isRunningRefinement = toolCall.toolName === TASK_PLANNER_REFINEMENT_TOOL_NAME && toolCall.status === "running"; + if (isRunningRefinement) { + return ( +
+ {t("taskDetail.plannerChat.refinementCreating", "Creating refinement task…")} +
+ ); + } + if (toolCall.toolName === TASK_PLANNER_REFINEMENT_TOOL_NAME && toolCall.isError) { + return ( +
+ {t("taskDetail.plannerChat.refinementFailed", "Refinement task was not created")} +
+ ); + } const isRunningSteering = toolCall.toolName === TASK_PLANNER_STEERING_TOOL_NAME && toolCall.status === "running"; if (isRunningSteering) { return ( @@ -665,7 +710,10 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false, /* FNXC:TaskDetailPlannerChat 2026-06-30-23:58: - Planner Chat is a separate task-detail surface from Activity steering. It can answer from task context, offer starter prompts, ask structured follow-up questions, and convert explicit operator intent into steering through the server-side planner-chat tool instead of posting every chat message as steering by default. + Planner Chat is a separate task-detail surface from Activity steering. It can answer from task context, offer starter prompts, ask structured follow-up questions, and convert explicit live-task operator intent into steering through the server-side planner-chat tool instead of posting every chat message as steering by default. + + FNXC:TaskDetailPlannerChat 2026-07-01-21:58: + Done-task Planner Chat remains sendable after completion and renders model-created refinement tool results inline, but tab activation stays lookup-only and the source task id still travels only through the server-bound `task-planner:` session and stream metadata. FNXC:TaskDetailChat 2026-06-30-23:59: When the planner steering tool succeeds, the Chat transcript must show an explicit confirmation and refresh task detail data immediately so Activity/current steering reflects the persisted comment without closing the modal. Clarification tool calls stay as questions and never insert optimistic steering bubbles. diff --git a/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx index ff1866359b..01bd61de48 100644 --- a/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx @@ -50,8 +50,8 @@ vi.mock("lucide-react", () => ({ Send: (props: any) => React.createElement("svg", { "data-testid": "send-icon", ...props }), })); -function makeTask(id: string) { - return { id, description: "Test task", column: "todo", dependencies: [], steps: [], currentStep: 0, createdAt: "2026-06-30T00:00:00.000Z", updatedAt: "2026-06-30T00:00:00.000Z", planningModelProvider: "anthropic", planningModelId: "claude-plan" } as any; +function makeTask(id: string, overrides: Record = {}) { + return { id, description: "Test task", column: "todo", dependencies: [], steps: [], currentStep: 0, createdAt: "2026-06-30T00:00:00.000Z", updatedAt: "2026-06-30T00:00:00.000Z", planningModelProvider: "anthropic", planningModelId: "claude-plan", ...overrides } as any; } function makePlannerSession(overrides: Record = {}) { @@ -199,6 +199,57 @@ describe("TaskPlannerChatTab", () => { ); }); + it("lets a done task with no planner history create a task-scoped session on first composer send", async () => { + const user = userEvent.setup(); + const addToast = vi.fn(); + mockFetchTaskPlannerChatSession.mockResolvedValueOnce({ session: null }); + + renderPlannerChat({ task: makeTask("FN-DONE", { column: "done" }), addToast }); + await screen.findByTestId("task-planner-chat-empty"); + + expect(mockEnsureTaskPlannerChatSession).not.toHaveBeenCalled(); + await user.type(screen.getByLabelText("Message planner chat"), "What changed in this completed task?"); + await user.click(screen.getByRole("button", { name: "Send" })); + + expect(mockEnsureTaskPlannerChatSession).toHaveBeenCalledWith( + "FN-DONE", + { modelProvider: "anthropic", modelId: "claude-plan" }, + undefined, + ); + expect(mockStreamChatResponse).toHaveBeenCalledWith( + "chat-planner", + "What changed in this completed task?", + expect.any(Object), + undefined, + undefined, + { taskId: "FN-DONE" }, + ); + expect(addToast).not.toHaveBeenCalledWith(expect.stringContaining("planner chat can only be started while a task is live"), "error"); + }); + + it("lets a done task with no planner history create a task-scoped session from a starter prompt", async () => { + const user = userEvent.setup(); + mockFetchTaskPlannerChatSession.mockResolvedValueOnce({ session: null }); + + renderPlannerChat({ task: makeTask("FN-DONE", { column: "done" }) }); + await screen.findByTestId("task-planner-chat-empty"); + await user.click(screen.getByRole("button", { name: /Summarize recent activity/ })); + + expect(mockEnsureTaskPlannerChatSession).toHaveBeenCalledWith( + "FN-DONE", + { modelProvider: "anthropic", modelId: "claude-plan" }, + undefined, + ); + expect(mockStreamChatResponse).toHaveBeenCalledWith( + "chat-planner", + "Summarize the recent activity for this task and call out anything important I should know.", + expect.any(Object), + undefined, + undefined, + { taskId: "FN-DONE" }, + ); + }); + it("renders accessible expand controls without moving the composer out of the panel", async () => { const onExpandedChange = vi.fn(); renderPlannerChat({ expanded: true, onExpandedChange }); @@ -930,16 +981,19 @@ describe("TaskPlannerChatTab", () => { expect(onTaskUpdated).toHaveBeenCalledWith(updatedTask); }); - it("renders reattached planner questions and pending/completed/error steering tool states", async () => { + it("renders reattached planner questions and pending/completed/error steering and refinement tool states", async () => { const inFlightGeneration = { status: "generating", - streamingText: "I need clarification and may add steering.", + streamingText: "I need clarification and may add steering or refinement.", streamingThinking: "Checking planner tools", toolCalls: [ { toolName: "fn_ask_question", args: { question: "Pick a path", options: ["Conservative", "Aggressive"] }, isError: false, status: "completed" }, { toolName: "fn_task_planner_add_steering", args: { text: "Persist this later" }, isError: false, status: "running" }, { toolName: "fn_task_planner_add_steering", args: { text: "Persisted steering" }, isError: false, result: { details: { text: "Persisted steering" } }, status: "completed" }, { toolName: "fn_task_planner_add_steering", args: { text: "Bad steering" }, isError: true, result: { error: "Invalid steering" }, status: "completed" }, + { toolName: "fn_task_planner_create_refinement", args: { feedback: "Create follow-up" }, isError: false, status: "running" }, + { toolName: "fn_task_planner_create_refinement", args: { feedback: "Add export support" }, isError: false, result: { details: { sourceTaskId: "FN-7310", refinementTaskId: "FN-REFINE", description: "Add export support" } }, status: "completed" }, + { toolName: "fn_task_planner_create_refinement", args: { feedback: "" }, isError: true, result: { details: { sourceTaskId: "FN-7310", error: "feedback required" } }, status: "completed" }, ], replayFromEventId: 12, updatedAt: "2026-07-01T14:00:00.000Z", @@ -954,6 +1008,10 @@ describe("TaskPlannerChatTab", () => { expect(screen.getByTestId("task-planner-chat-steering-pending")).toHaveTextContent("Adding steering comment…"); expect(screen.getByTestId("task-planner-chat-steering-confirmation")).toHaveTextContent("Persisted steering"); expect(screen.getByTestId("task-planner-chat-steering-error")).toHaveTextContent("Steering comment was not added"); + expect(screen.getByTestId("task-planner-chat-refinement-pending")).toHaveTextContent("Creating refinement task…"); + expect(screen.getByTestId("task-planner-chat-refinement-confirmation")).toHaveTextContent("Created refinement task FN-REFINE"); + expect(screen.getByTestId("task-planner-chat-refinement-confirmation")).toHaveTextContent("Add export support"); + expect(screen.getByTestId("task-planner-chat-refinement-error")).toHaveTextContent("Refinement task was not created"); expect(screen.getAllByTestId("chat-question-response-submit")).toHaveLength(1); }); diff --git a/packages/dashboard/src/__tests__/chat-manager.test.ts b/packages/dashboard/src/__tests__/chat-manager.test.ts index 97f59b9f6b..eedc3788e7 100644 --- a/packages/dashboard/src/__tests__/chat-manager.test.ts +++ b/packages/dashboard/src/__tests__/chat-manager.test.ts @@ -1358,11 +1358,15 @@ describe("ChatManager.sendMessage", () => { expect(createOptions.systemPrompt).toContain("Polish: in-progress"); expect(createOptions.systemPrompt).toContain("Activity transcript loaded"); expect(createOptions.systemPrompt).toContain("fn_ask_question"); - expect(createOptions.systemPrompt).toContain("Do not create steering for ordinary questions"); + expect(createOptions.systemPrompt).toContain("Do not create steering or refinements for ordinary questions"); expect(createOptions.systemPrompt).toContain("fn_task_planner_get_task_metrics"); expect(createOptions.systemPrompt).toContain("token counts, input/output/cache usage, model cost"); expect(createOptions.systemPrompt).toContain("state that uncertainty instead of inventing a number"); expect(createOptions.systemPrompt).toContain("ordinary status/progress/metrics questions"); + expect(createOptions.systemPrompt).toContain("fn_task_planner_create_refinement"); + expect(createOptions.systemPrompt).toContain("clear follow-up implementation, improvement, polish, bug-fix, or refinement request"); + expect(createOptions.systemPrompt).toContain("Never create a refinement for live non-done tasks"); + expect(createOptions.systemPrompt).toContain("never ask for or pass a task id"); expect(createOptions.systemPrompt).toContain("Ask a clarifying question"); expect(createOptions.systemPrompt).toContain("credential/secrets"); expect(createOptions.systemPrompt).toContain("destructive removals"); @@ -1480,13 +1484,13 @@ describe("ChatManager.sendMessage", () => { expect(taskStore.addSteeringComment).not.toHaveBeenCalled(); }); - it("does not expose the current-task metrics tool outside synthetic task planner chat", async () => { + it("does not expose the current-task metrics or refinement tools outside synthetic task planner chat", async () => { mockChatStore.getSession.mockReturnValue({ id: "chat-001", agentId: "agent-001", status: "active" }); const createResolvedSession = vi.fn(async () => ({ session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), state: { messages: [] } }, })); __setCreateResolvedAgentSession(createResolvedSession as any); - const taskStore = { getTask: vi.fn(), getSettings: vi.fn().mockResolvedValue({}) }; + const taskStore = { getTask: vi.fn(), refineTask: vi.fn(), getSettings: vi.fn().mockResolvedValue({}) }; const chatManager = new ChatManager(mockChatStore as any, "/tmp/test", mockAgentStore as any, undefined, undefined, undefined, taskStore as any); await chatManager.sendMessage("chat-001", "How many tokens did FN-7310 use?"); @@ -1494,6 +1498,122 @@ describe("ChatManager.sendMessage", () => { const createOptions = createResolvedSession.mock.calls[0]?.[0]; const toolNames = (createOptions.customTools ?? []).map((tool: { name: string }) => tool.name); expect(toolNames).not.toContain("fn_task_planner_get_task_metrics"); + expect(toolNames).not.toContain("fn_task_planner_create_refinement"); + }); + + it("creates done-task refinements through the task-scoped planner tool without accepting a caller task id", async () => { + mockChatStore.getSession.mockReturnValue({ + id: "chat-001", + agentId: "task-planner:FN-DONE", + status: "active", + }); + const createResolvedSession = vi.fn(async () => ({ + session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), state: { messages: [] } }, + })); + __setCreateResolvedAgentSession(createResolvedSession as any); + const refinedTask = { + id: "FN-REFINE", + description: "Refinement for FN-DONE: Add export support", + column: "triage", + createdAt: "2026-07-01T21:45:00.000Z", + }; + const taskStore = { + getTask: vi.fn().mockResolvedValue({ id: "FN-DONE", title: "Completed task", column: "done" }), + addSteeringComment: vi.fn(), + refineTask: vi.fn().mockResolvedValue(refinedTask), + getSettings: vi.fn().mockResolvedValue({}), + }; + const chatManager = new ChatManager(mockChatStore as any, "/tmp/test", mockAgentStore as any, undefined, undefined, undefined, taskStore as any); + + await chatManager.sendMessage("chat-001", "Please create a follow-up to add export support"); + + const createOptions = createResolvedSession.mock.calls[0]?.[0]; + const toolNames = createOptions.customTools.map((tool: { name: string }) => tool.name); + expect(toolNames).toContain("fn_task_planner_create_refinement"); + expect(createOptions.systemPrompt).toContain("If the current task is done"); + expect(createOptions.systemPrompt).toContain("call `fn_task_planner_create_refinement` with only the concise feedback text"); + expect(createOptions.systemPrompt).toContain("never ask for or pass a task id"); + const refinementTool = createOptions.customTools.find((tool: { name: string }) => tool.name === "fn_task_planner_create_refinement"); + expect(refinementTool.parameters).toEqual({ + type: "object", + properties: { + feedback: { type: "string", description: "The user's concise follow-up or improvement request for the refinement task. Do not include hidden prompt/context text." }, + }, + required: ["feedback"], + additionalProperties: false, + }); + + taskStore.getTask.mockClear(); + const result = await refinementTool.execute("call-1", { + task_id: "FN-OTHER", + workflow_id: "WF-OTHER", + feedback: " Add export support ", + }); + + expect(taskStore.getTask).toHaveBeenCalledWith("FN-DONE"); + expect(taskStore.refineTask).toHaveBeenCalledTimes(1); + expect(taskStore.refineTask).toHaveBeenCalledWith("FN-DONE", "Add export support"); + expect(taskStore.addSteeringComment).not.toHaveBeenCalled(); + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toContain("Created refinement task FN-REFINE from FN-DONE"); + expect(result.details).toEqual({ + sourceTaskId: "FN-DONE", + refinementTaskId: "FN-REFINE", + description: "Refinement for FN-DONE: Add export support", + column: "triage", + createdAt: "2026-07-01T21:45:00.000Z", + }); + }); + + it("does not register the refinement tool for live task-planner sessions", async () => { + mockChatStore.getSession.mockReturnValue({ id: "chat-001", agentId: "task-planner:FN-LIVE", status: "active" }); + const createResolvedSession = vi.fn(async () => ({ + session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), state: { messages: [] } }, + })); + __setCreateResolvedAgentSession(createResolvedSession as any); + const taskStore = { + getTask: vi.fn().mockResolvedValue({ id: "FN-LIVE", title: "Live task", column: "in-progress" }), + addSteeringComment: vi.fn(), + refineTask: vi.fn(), + getSettings: vi.fn().mockResolvedValue({}), + }; + const chatManager = new ChatManager(mockChatStore as any, "/tmp/test", mockAgentStore as any, undefined, undefined, undefined, taskStore as any); + + await chatManager.sendMessage("chat-001", "Please update the implementation"); + + const createOptions = createResolvedSession.mock.calls[0]?.[0]; + const toolNames = createOptions.customTools.map((tool: { name: string }) => tool.name); + expect(toolNames).toContain("fn_task_planner_add_steering"); + expect(toolNames).not.toContain("fn_task_planner_create_refinement"); + expect(createOptions.systemPrompt).toContain("If the current task is not done"); + expect(createOptions.systemPrompt).toContain("call `fn_task_planner_add_steering`"); + expect(taskStore.refineTask).not.toHaveBeenCalled(); + }); + + it("rejects empty planner refinement feedback without mutating the task", async () => { + mockChatStore.getSession.mockReturnValue({ id: "chat-001", agentId: "task-planner:FN-DONE", status: "active" }); + const createResolvedSession = vi.fn(async () => ({ + session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), state: { messages: [] } }, + })); + __setCreateResolvedAgentSession(createResolvedSession as any); + const taskStore = { + getTask: vi.fn().mockResolvedValue({ id: "FN-DONE", column: "done" }), + addSteeringComment: vi.fn(), + refineTask: vi.fn(), + getSettings: vi.fn().mockResolvedValue({}), + }; + const chatManager = new ChatManager(mockChatStore as any, "/tmp/test", mockAgentStore as any, undefined, undefined, undefined, taskStore as any); + + await chatManager.sendMessage("chat-001", "Create a follow-up"); + + const createOptions = createResolvedSession.mock.calls[0]?.[0]; + const refinementTool = createOptions.customTools.find((tool: { name: string }) => tool.name === "fn_task_planner_create_refinement"); + const result = await refinementTool.execute("call-1", { feedback: " " }); + + expect(result.isError).toBe(true); + expect(result.details).toEqual({ sourceTaskId: "FN-DONE" }); + expect(taskStore.refineTask).not.toHaveBeenCalled(); + expect(taskStore.addSteeringComment).not.toHaveBeenCalled(); }); it("adds steering through the task-scoped planner tool without accepting a caller task id", async () => { diff --git a/packages/dashboard/src/__tests__/chat-routes.test.ts b/packages/dashboard/src/__tests__/chat-routes.test.ts index d69f18a166..2b277001ae 100644 --- a/packages/dashboard/src/__tests__/chat-routes.test.ts +++ b/packages/dashboard/src/__tests__/chat-routes.test.ts @@ -579,7 +579,7 @@ describe("Chat API Routes", () => { expect(mockCreateSession).not.toHaveBeenCalled(); }); - it("rejects starting a new planner chat for done or archived tasks", async () => { + it("creates a new planner chat for a done task without existing history", async () => { vi.spyOn(store, "getTask").mockResolvedValueOnce({ id: "FN-DONE-EMPTY", title: "Done planner task", @@ -592,18 +592,53 @@ describe("Chat API Routes", () => { createdAt: "2026-06-30T00:00:00.000Z", updatedAt: "2026-06-30T00:00:00.000Z", } as any); + const created = { ...sampleSession, id: "chat-done-created", agentId: "task-planner:FN-DONE-EMPTY", title: "FN-DONE-EMPTY planner chat" }; mockFindLatestActiveSessionForTarget.mockReturnValue(null); + mockCreateSession.mockReturnValue(created); const doneResponse = await request( app, "POST", "/api/chat/task-planner/FN-DONE-EMPTY/session", + JSON.stringify({ modelProvider: "anthropic", modelId: "claude-plan" }), + { "content-type": "application/json" }, + ); + + expect(doneResponse.status).toBe(201); + expect((doneResponse.body as any).session).toEqual(created); + expect(mockCreateSession).toHaveBeenCalledWith(expect.objectContaining({ + agentId: "task-planner:FN-DONE-EMPTY", + title: "FN-DONE-EMPTY planner chat", + modelProvider: "anthropic", + modelId: "claude-plan", + })); + }); + + it("continues to reject starting a new planner chat for archived tasks", async () => { + vi.spyOn(store, "getTask").mockResolvedValueOnce({ + id: "FN-ARCHIVED-EMPTY", + title: "Archived planner task", + description: "Task description", + column: "archived", + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: "2026-06-30T00:00:00.000Z", + updatedAt: "2026-06-30T00:00:00.000Z", + } as any); + mockFindLatestActiveSessionForTarget.mockReturnValue(null); + + const archivedResponse = await request( + app, + "POST", + "/api/chat/task-planner/FN-ARCHIVED-EMPTY/session", JSON.stringify({}), { "content-type": "application/json" }, ); - expect(doneResponse.status).toBe(400); - expect((doneResponse.body as any).error).toContain("planner chat can only be started while a task is live"); + expect(archivedResponse.status).toBe(400); + expect((archivedResponse.body as any).error).toContain("planner chat cannot be started for archived tasks"); expect(mockCreateSession).not.toHaveBeenCalled(); }); }); diff --git a/packages/dashboard/src/__tests__/task-planner-chat-context.test.ts b/packages/dashboard/src/__tests__/task-planner-chat-context.test.ts index 858ebf5778..e31fa1f151 100644 --- a/packages/dashboard/src/__tests__/task-planner-chat-context.test.ts +++ b/packages/dashboard/src/__tests__/task-planner-chat-context.test.ts @@ -156,10 +156,13 @@ describe("task planner chat context", () => { expect(promptContext).toContain("Prompt/plan: not available"); }); - it("tells the planner to answer from context and state uncertainty", () => { + it("tells the planner to answer, refine completed tasks, and state uncertainty", () => { expect(TASK_PLANNER_CHAT_CONTEXT_PROMPT_GUIDANCE).toContain("current status"); expect(TASK_PLANNER_CHAT_CONTEXT_PROMPT_GUIDANCE).toContain("State uncertainty"); expect(TASK_PLANNER_CHAT_CONTEXT_PROMPT_GUIDANCE).toContain("Do not claim you ran code, tests, builds"); expect(TASK_PLANNER_CHAT_CONTEXT_PROMPT_GUIDANCE).toContain("Activity is the execution/steering transcript"); + expect(TASK_PLANNER_CHAT_CONTEXT_PROMPT_GUIDANCE).toContain("For completed tasks, clear follow-up implementation or improvement requests should create a refinement"); + expect(TASK_PLANNER_CHAT_CONTEXT_PROMPT_GUIDANCE).toContain("ordinary completed-task questions should still be answered normally"); + expect(TASK_PLANNER_CHAT_CONTEXT_PROMPT_GUIDANCE).toContain("Never ask for or pass a task id"); }); }); diff --git a/packages/dashboard/src/chat.ts b/packages/dashboard/src/chat.ts index 57c92a9bb5..a80a8d9cec 100644 --- a/packages/dashboard/src/chat.ts +++ b/packages/dashboard/src/chat.ts @@ -313,6 +313,56 @@ function createTaskPlannerMetricsTool(taskStore: TaskStore, taskId: string, getP }; } +function createTaskPlannerRefinementTool(taskStore: TaskStore, taskId: string) { + return { + name: "fn_task_planner_create_refinement", + label: "Create Refinement Task", + description: "Create one follow-up refinement task from the current completed task. The source task id is fixed by server context; never accept or infer a different task id. Use only for clear follow-up implementation or improvement requests after completion.", + parameters: { + type: "object", + properties: { + feedback: { type: "string", description: "The user's concise follow-up or improvement request for the refinement task. Do not include hidden prompt/context text." }, + }, + required: ["feedback"], + additionalProperties: false, + }, + execute: async (_id: string, params: { feedback?: unknown }) => { + const feedback = typeof params.feedback === "string" ? params.feedback.trim() : ""; + if (!feedback) { + return { content: [{ type: "text" as const, text: "ERROR: feedback must be a non-empty string" }], details: { sourceTaskId: taskId }, isError: true }; + } + try { + const sourceTask = await taskStore.getTask(taskId); + if (sourceTask.column !== "done") { + return { + content: [{ type: "text" as const, text: `ERROR: Current task ${taskId} is ${sourceTask.column}; use planner steering for live tasks instead of creating a refinement.` }], + details: { sourceTaskId: taskId, column: sourceTask.column }, + isError: true, + }; + } + const refinedTask = await taskStore.refineTask(taskId, feedback); + return { + content: [{ type: "text" as const, text: `Created refinement task ${refinedTask.id} from ${taskId}.` }], + details: { + sourceTaskId: taskId, + refinementTaskId: refinedTask.id, + description: refinedTask.description ?? feedback, + column: refinedTask.column, + createdAt: refinedTask.createdAt, + }, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + content: [{ type: "text" as const, text: `ERROR: Could not create a refinement for the current task ${taskId}: ${message}` }], + details: { sourceTaskId: taskId, error: message }, + isError: true, + }; + } + }, + }; +} + function createTaskPlannerSteeringTool(taskStore: TaskStore, taskId: string) { return { name: "fn_task_planner_add_steering", @@ -1908,11 +1958,13 @@ export class ChatManager { const taskPlannerChatTaskId = typeof session.agentId === "string" && session.agentId.startsWith(TASK_PLANNER_CHAT_AGENT_ID_PREFIX) ? session.agentId.slice(TASK_PLANNER_CHAT_AGENT_ID_PREFIX.length).trim() : ""; + let taskPlannerTaskColumn = ""; if (taskPlannerChatTaskId) { let taskContext = `Task ID: ${taskPlannerChatTaskId}\n\nContext availability notes:\n- Task store context is not available for this chat manager.`; if (this.taskStore) { try { const context = await buildTaskPlannerChatContext(this.taskStore, taskPlannerChatTaskId); + taskPlannerTaskColumn = context.snapshot.column ?? ""; taskContext = context.promptContext; } catch (taskLoadError) { const message = taskLoadError instanceof Error ? taskLoadError.message : String(taskLoadError); @@ -1926,8 +1978,11 @@ export class ChatManager { FNXC:TaskDetailChat 2026-06-30-23:59: Clear, bounded operator change requests in task-detail Chat are user intent and should become persisted steering comments through the task store's steering path. Ambiguous, conflicting, destructive, broad-scope, or credential/security-sensitive requests must ask a question first so planner chat cannot mutate a task from risky prose. + + FNXC:TaskDetailPlannerChat 2026-07-01-21:52: + Done-task planner Chat separates retrospective conversation from follow-up work creation: answer completed-task questions normally, call the refinement tool only for clear implementation/improvement follow-ups, and keep live-task change requests on the existing steering path. All planner tools are bound to the current task server-side. */ - systemPrompt = `${systemPrompt}\n\n${TASK_PLANNER_CHAT_CONTEXT_PROMPT_GUIDANCE}\n\nDecision rules:\n- Do not create steering for ordinary questions, summaries, thanks, status/progress requests, metric questions, or brainstorming. Answer normally.\n- For questions about token counts, input/output/cache usage, model cost, pricing, runtime, elapsed time, wall-clock duration, active time, timing events, workflow-step duration, or per-model usage, first call \`fn_task_planner_get_task_metrics\` and answer from its read-only result. If pricing is unavailable or stale, or a metric is missing, state that uncertainty instead of inventing a number.\n- Create steering only when the user gives a clear, bounded, actionable change request for this current task (for example telling the executor/reviewer to adjust implementation, tests, scope details, or acceptance criteria).\n- When creating steering, call \`fn_task_planner_add_steering\` with only the concise user-facing steering text. Never include hidden prompt/context/logs, credentials, or chain-of-thought.\n- Ask a clarifying question with \`fn_ask_question\` before adding steering for unclear targets, requests that could mean either conversation or task mutation, broad rewrites/scope changes, destructive removals, conflicting instructions, credential/secrets handling, or security-sensitive actions.\n- The steering and metrics tools are bound to this task server-side; never ask for or pass a task id.\n\n${taskContext}`; + systemPrompt = `${systemPrompt}\n\n${TASK_PLANNER_CHAT_CONTEXT_PROMPT_GUIDANCE}\n\nDecision rules:\n- Do not create steering or refinements for ordinary questions, summaries, thanks, status/progress requests, metric questions, or brainstorming. Answer normally.\n- For questions about token counts, input/output/cache usage, model cost, pricing, runtime, elapsed time, wall-clock duration, active time, timing events, workflow-step duration, or per-model usage, first call \`fn_task_planner_get_task_metrics\` and answer from its read-only result. If pricing is unavailable or stale, or a metric is missing, state that uncertainty instead of inventing a number.\n- If the current task is done and the user gives a clear follow-up implementation, improvement, polish, bug-fix, or refinement request for this completed task, call \`fn_task_planner_create_refinement\` with only the concise feedback text.\n- If the current task is not done and the user gives a clear, bounded, actionable change request for this current task (for example telling the executor/reviewer to adjust implementation, tests, scope details, or acceptance criteria), call \`fn_task_planner_add_steering\` with only the concise user-facing steering text.\n- Never create a refinement for live non-done tasks; use steering for clear live-task changes instead. Never add steering for done-task follow-up implementation requests when the refinement tool is available.\n- Never include hidden prompt/context/logs, credentials, or chain-of-thought in tool parameters.\n- Ask a clarifying question with \`fn_ask_question\` before adding steering or creating a refinement for unclear targets, requests that could mean either conversation or task mutation, broad rewrites/scope changes, destructive removals, conflicting instructions, credential/secrets handling, or security-sensitive actions.\n- The steering, metrics, and refinement tools are bound to this task server-side; never ask for or pass a task id.\n\n${taskContext}`; } if (agent) { @@ -2045,8 +2100,15 @@ export class ChatManager { const taskPlannerMetricsTools = this.taskStore && taskPlannerChatTaskId ? [createTaskPlannerMetricsTool(this.taskStore, taskPlannerChatTaskId, () => this.getModelPricingOverrides())] : []; + /* + FNXC:TaskDetailPlannerChat 2026-07-01-21:44: + Done-task planner Chat uses a separate task-scoped refinement tool rather than Activity steering. The tool is registered only for synthetic task-planner sessions whose server-loaded current task is done, accepts only feedback text, and calls TaskStore.refineTask with the bound source id so models cannot route refinements to arbitrary tasks/projects/workflows. + */ + const taskPlannerRefinementTools = this.taskStore && taskPlannerChatTaskId && taskPlannerTaskColumn === "done" + ? [createTaskPlannerRefinementTool(this.taskStore, taskPlannerChatTaskId)] + : []; - const customTools = [createAskQuestionTool(), ...taskPlannerSteeringTools, ...taskPlannerMetricsTools, ...messagingTools, ...workflowTools, ...documentTools, ...artifactTools]; + const customTools = [createAskQuestionTool(), ...taskPlannerSteeringTools, ...taskPlannerMetricsTools, ...taskPlannerRefinementTools, ...messagingTools, ...workflowTools, ...documentTools, ...artifactTools]; const sessionOptions = { cwd: this.rootDir, diff --git a/packages/dashboard/src/routes/register-chat-routes.ts b/packages/dashboard/src/routes/register-chat-routes.ts index ec9b7ee44c..6fb09ce88e 100644 --- a/packages/dashboard/src/routes/register-chat-routes.ts +++ b/packages/dashboard/src/routes/register-chat-routes.ts @@ -139,7 +139,10 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): Task planner Chat uses a synthetic task-scoped chat target (`task-planner:`) so the dashboard can persist/resume a conversation without binding it to an executor/reviewer agent or the Activity steering-comment pipeline. The route validates the task in the scoped project store and stores the effective planning model override on the session. FNXC:TaskDetailPlannerChatRetention 2026-06-30-18:45: - Planner chats that already have user interaction remain available when a task reaches done, but new planner chats are not started for done or archived tasks. Archived-task cleanup removes existing task-planner sessions through ChatStore deletion so archived tasks stop retaining task-local planner context. + Planner chats that already have user interaction remain available when a task reaches done, and archived-task cleanup removes existing task-planner sessions through ChatStore deletion so archived tasks stop retaining task-local planner context. + + FNXC:TaskDetailPlannerChat 2026-07-01-21:40: + Completed tasks may start a task-detail planner Chat after the fact so operators can ask retrospective questions and request a refinement from the completed source task. Archived tasks remain non-startable, and common Chat feed visibility is still controlled only by the global task-chat filtering setting below. */ router.post("/chat/task-planner/:taskId/session", rateLimit(RATE_LIMITS.mutation), async (req, res) => { try { @@ -171,8 +174,8 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): return; } - if (task.column === "done" || task.column === "archived") { - throw badRequest(`Task ${task.id} is ${task.column}; planner chat can only be started while a task is live`); + if (task.column === "archived") { + throw badRequest(`Task ${task.id} is archived; planner chat cannot be started for archived tasks`); } const session = chatStore.createSession({ diff --git a/packages/dashboard/src/task-planner-chat-context.ts b/packages/dashboard/src/task-planner-chat-context.ts index 6ad6780fd1..56cb2ff6a4 100644 --- a/packages/dashboard/src/task-planner-chat-context.ts +++ b/packages/dashboard/src/task-planner-chat-context.ts @@ -324,6 +324,9 @@ Task-detail planner Chat receives this server-built, bounded task snapshot so th FNXC:TaskPlannerChatMetrics 2026-07-01-20:58: Metric questions in task-detail planner Chat must use the read-only task-scoped metrics tool, not Activity steering or prose inference. Prompt guidance names token, cost, runtime, timing-event, workflow-step, and per-model surfaces so ordinary metrics questions remain answers rather than task mutations. + +FNXC:TaskDetailPlannerChat 2026-07-01-21:52: +Completed-task planner Chat can now create follow-up refinement tasks, but only when the user gives a clear implementation/improvement request. Retrospective questions stay conversational, live task changes stay steering, and the model must never ask for a task id because every planner tool is bound by the server. */ export const TASK_PLANNER_CHAT_CONTEXT_PROMPT_GUIDANCE = `## Task Planner Chat Context -You are answering in the task detail Chat tab for a single Fusion task. Use the bounded server-supplied context below to answer questions about current status, progress, dependencies, recent activity, source/review state, and the task prompt or plan. For token counts, cost, runtime, elapsed/wall-clock duration, timing events, workflow-step duration, or per-model usage, call \`fn_task_planner_get_task_metrics\` and answer from the tool result; state when pricing is unavailable/stale or metrics are missing instead of inventing values. State uncertainty when a section is absent, stale, truncated, or marked unavailable. Do not claim you ran code, tests, builds, or inspected files unless the supplied context or explicit tool output says so. Keep Activity separate from Chat: Activity is the execution/steering transcript, while this Chat reply is a planner conversation. Do not mutate steering comments for ordinary status/progress/metrics questions.`; +You are answering in the task detail Chat tab for a single Fusion task. Use the bounded server-supplied context below to answer questions about current status, progress, dependencies, recent activity, source/review state, and the task prompt or plan. For token counts, cost, runtime, elapsed/wall-clock duration, timing events, workflow-step duration, or per-model usage, call \`fn_task_planner_get_task_metrics\` and answer from the tool result; state when pricing is unavailable/stale or metrics are missing instead of inventing values. State uncertainty when a section is absent, stale, truncated, or marked unavailable. Do not claim you ran code, tests, builds, or inspected files unless the supplied context or explicit tool output says so. Keep Activity separate from Chat: Activity is the execution/steering transcript, while this Chat reply is a planner conversation. Do not mutate steering comments for ordinary status/progress/metrics questions. For completed tasks, clear follow-up implementation or improvement requests should create a refinement through the task-scoped refinement tool; ordinary completed-task questions should still be answered normally. Never ask for or pass a task id for planner tools.`;