From ddfd841b53546e794daf69d244a905331a672302 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 30 Jun 2026 18:51:41 -0700 Subject: [PATCH] FN-7312: send task context to planner chat Send bounded task state into task-detail planner chat so status and progress questions can be answered in context. - Build reusable server-side planner chat context from task metadata, dependencies, steps, comments, activity, source, and review state. - Pass and validate the task id on planner chat sends to keep sessions scoped to the current task. - Update planner chat copy, docs, tests, and changeset coverage for task-aware status/progress questions. Files changed: .changeset/fn-7312-task-planner-chat-context.md | 7 + docs/dashboard-guide.md | 4 +- packages/dashboard/app/api/legacy.ts | 5 +- .../app/components/TaskPlannerChatTab.tsx | 5 +- .../__tests__/TaskPlannerChatTab.test.tsx | 22 ++ .../dashboard/src/__tests__/chat-manager.test.ts | 17 +- .../dashboard/src/__tests__/chat-routes.test.ts | 76 +++++ .../__tests__/task-planner-chat-context.test.ts | 165 +++++++++++ packages/dashboard/src/chat.ts | 92 +----- .../dashboard/src/routes/register-chat-routes.ts | 11 +- .../dashboard/src/task-planner-chat-context.ts | 326 +++++++++++++++++++++ 11 files changed, 634 insertions(+), 96 deletions(-) Fusion-Task-Id: FN-7312 Fusion-Task-Lineage: 90cf0fe3-3984-4a67-bb44-fce492c256eb Co-authored-by: Fusion (runfusion.ai) --- .../fn-7312-task-planner-chat-context.md | 7 + docs/dashboard-guide.md | 4 +- packages/dashboard/app/api/legacy.ts | 5 +- .../app/components/TaskPlannerChatTab.tsx | 5 +- .../__tests__/TaskPlannerChatTab.test.tsx | 22 ++ .../src/__tests__/chat-manager.test.ts | 17 +- .../src/__tests__/chat-routes.test.ts | 76 ++++ .../task-planner-chat-context.test.ts | 165 +++++++++ packages/dashboard/src/chat.ts | 92 +---- .../src/routes/register-chat-routes.ts | 11 +- .../src/task-planner-chat-context.ts | 326 ++++++++++++++++++ 11 files changed, 634 insertions(+), 96 deletions(-) create mode 100644 .changeset/fn-7312-task-planner-chat-context.md create mode 100644 packages/dashboard/src/__tests__/task-planner-chat-context.test.ts create mode 100644 packages/dashboard/src/task-planner-chat-context.ts diff --git a/.changeset/fn-7312-task-planner-chat-context.md b/.changeset/fn-7312-task-planner-chat-context.md new file mode 100644 index 0000000000..9109935e02 --- /dev/null +++ b/.changeset/fn-7312-task-planner-chat-context.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Make task-detail Chat answer status and progress questions from bounded task context. +category: feature +dev: Adds server-built task planner chat context and task-scoped send validation. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 0d36514c0d..06725f1910 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -1042,7 +1042,7 @@ Inspect task definition, logs, review feedback, comments, artifacts, workflow ou - Editable tasks with descriptions show **Summarize as title** beside the read-mode title; it asks AI to generate a concise title from the description and saves it without opening the edit form. - The **Summary** tab appears first for `done` tasks and is their default landing tab. It shows the recorded completion summary, changed-file/merge stats when available, completed steps, workflow results, retry counts, and a token usage & cost section broken down by model from the already-loaded task detail; unpriced models show cost as unavailable rather than `$0`. Non-`done` tasks still open on **Activity** by default. - The **Activity → Current** segment includes an expand/collapse control that lets the transcript and composer fill the task-detail modal, then restores the normal header, tabs, and action footer when collapsed. -- The top-level **Chat** tab appears immediately after **Activity** and starts a task-scoped planner-model conversation. It uses the task's effective planning model, persists messages in a resumable chat session, shows starter prompts for common planning questions, can render structured planner questions, and converts only explicit operator steering intent through the scoped steering tool. Activity remains the primary operational transcript/feed/raw-log surface. +- The top-level **Chat** tab appears immediately after **Activity** and starts a task-scoped planner-model conversation. It uses the task's effective planning model, persists messages in a resumable chat session, and sends bounded server-built task context so the planner can answer current status, progress, recent activity, dependency, and task definition questions without polluting visible chat history. It shows starter prompts for common planning questions, can render structured planner questions, and converts only explicit operator steering intent through the scoped steering tool. Activity remains the primary operational transcript/feed/raw-log surface. - Task-detail Activity steering comments are persisted as user comments/steering guidance and surfaced to every relevant agent lane: live executor sessions receive steering injection, while planner, reviewer (spec/plan/code), and merger agents (standard and clean-room AI merge/review) receive the latest user comments in their next prompt/pass. - The priority chip in task metadata is an inline picker: you can change priority directly without entering full edit mode. - Execution mode has a read-mode inline lightning-bolt toggle for Fast mode on/off without opening the full edit form. @@ -1121,7 +1121,7 @@ Recommended workflow: ordinary chains stay as `Blocks N` so noise stays low, hig The **Activity** tab is the first task-detail tab and presents a segmented control for **Current**, **Feed**, and **Raw Logs**. Current 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 **Steering comment** composer sends 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 Current 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 a steering composer. On a `done` task, the composer switches to **Refinement request** copy; sending 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 Current 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 adjacent top-level **Chat** tab opens the planner-model conversation for the same task instead of posting steering comments. When no planner-chat history has loaded yet, Chat shows a guided empty state with starter prompts for recent activity, current status/blockers, next best action, and plan/definition review; selecting one sends that prompt as an ordinary chat message through the planner-chat composer/stream path. The starter prompts disappear while history is loading or after conversation history exists, so Activity Current, Feed, Raw Logs, and the steering composer remain separate. +The adjacent top-level **Chat** tab opens the planner-model conversation for the same task instead of posting steering comments. 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. When no planner-chat history has loaded yet, Chat shows a guided empty state with starter prompts for recent activity, current status/blockers, next best action, and plan/definition review; selecting one 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 Current, Feed, Raw Logs, and the steering composer remain separate. 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/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index aedcf8208c..8fb59cf5aa 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -10241,7 +10241,7 @@ export function streamChatResponse( handlers: ChatStreamHandlers, attachments?: File[], projectId?: string, - options?: { maxReconnectAttempts?: number; firstEventTimeoutMs?: number }, + options?: { maxReconnectAttempts?: number; firstEventTimeoutMs?: number; taskId?: string }, ): { close: () => void; isConnected: () => boolean } { const url = buildApiUrl(withProjectId(`/chat/sessions/${encodeURIComponent(sessionId)}/messages`, projectId)); @@ -10337,10 +10337,11 @@ export function streamChatResponse( ? (() => { const formData = new FormData(); formData.append("content", content); + if (options?.taskId) formData.append("taskId", options.taskId); attachments.forEach((file) => formData.append("attachments", file)); return formData; })() - : JSON.stringify({ content }); + : JSON.stringify({ content, ...(options?.taskId ? { taskId: options.taskId } : {}) }); const res = await fetch(url, { method: "POST", diff --git a/packages/dashboard/app/components/TaskPlannerChatTab.tsx b/packages/dashboard/app/components/TaskPlannerChatTab.tsx index b58904e14e..6a167eda19 100644 --- a/packages/dashboard/app/components/TaskPlannerChatTab.tsx +++ b/packages/dashboard/app/components/TaskPlannerChatTab.tsx @@ -302,6 +302,7 @@ export function TaskPlannerChatTab({ task, projectId, active, planningModel, add }, undefined, projectId, + { taskId: task.id }, ); } catch (err) { if (!isCurrentStreamRequest()) return; @@ -345,7 +346,7 @@ export function TaskPlannerChatTab({ task, projectId, active, planningModel, add 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. FNXC:TaskDetailPlannerChat 2026-06-30-23:59: - The empty Chat tab starts with guided task-state prompts that submit ordinary user messages through the same planner-chat stream as the composer. Context enrichment, steering conversion, and structured question rendering remain owned by later planner-chat subtasks, so starter prompts are only message text plus accessible affordances here. + The empty Chat tab starts with guided task-state prompts that submit ordinary user messages through the same task-context-aware planner-chat stream as the composer. Steering conversion and structured question-modal rendering remain owned by later planner-chat subtasks, so starter prompts are only message text plus accessible affordances here. FNXC:TaskDetailPlannerChat 2026-06-30-23:59: Session loads are scoped to the current task/project/model and stale responses are ignored so a delayed previous task load cannot attach starter-prompt sends to the wrong planner-chat session. @@ -358,7 +359,7 @@ export function TaskPlannerChatTab({ task, projectId, active, planningModel, add

{t("taskDetail.plannerChat.heading", "Planner Chat")}

-

{t("taskDetail.plannerChat.description", "Ask planning questions about this task, clarify next steps, or request steering for the executor.")}

+

{t("taskDetail.plannerChat.description", "Ask planning questions about this task's current status, recent activity, blockers, next steps, or definition.")}

{isUsableModel(planningModel) && ( diff --git a/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx index 5d00b1cfd8..7404ba3543 100644 --- a/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx @@ -199,6 +199,7 @@ describe("TaskPlannerChatTab", () => { expect.any(Object), undefined, undefined, + { taskId: "FN-7312" }, ); }); @@ -326,6 +327,7 @@ describe("TaskPlannerChatTab", () => { expect.any(Object), undefined, undefined, + { taskId: "FN-7310" }, ); expect(screen.getByText("Help plan this")).toBeInTheDocument(); await waitFor(() => expect(screen.getByText("Hello")).toBeInTheDocument()); @@ -353,6 +355,24 @@ describe("TaskPlannerChatTab", () => { expect(screen.getByLabelText("Message planner chat")).toBeEnabled(); }); + it("sends manual status/progress questions with the current task identity", async () => { + const user = userEvent.setup(); + renderPlannerChat({ task: makeTask("FN-STATUS") }); + await screen.findByTestId("task-planner-chat-empty"); + + await user.type(screen.getByLabelText("Message planner chat"), "What is the current status and progress?"); + await user.click(screen.getByRole("button", { name: "Send" })); + + expect(mockStreamChatResponse).toHaveBeenCalledWith( + "chat-planner", + "What is the current status and progress?", + expect.any(Object), + undefined, + undefined, + { taskId: "FN-STATUS" }, + ); + }); + it("sends starter prompts through the planner chat stream", async () => { const user = userEvent.setup(); renderPlannerChat(); @@ -366,6 +386,7 @@ describe("TaskPlannerChatTab", () => { expect.any(Object), undefined, undefined, + { taskId: "FN-7310" }, ); expect(screen.queryByTestId("task-planner-chat-empty")).not.toBeInTheDocument(); }); @@ -399,6 +420,7 @@ describe("TaskPlannerChatTab", () => { expect.any(Object), undefined, undefined, + { taskId: "FN-7310" }, ); }); diff --git a/packages/dashboard/src/__tests__/chat-manager.test.ts b/packages/dashboard/src/__tests__/chat-manager.test.ts index d255cbfb75..b6e7f599bb 100644 --- a/packages/dashboard/src/__tests__/chat-manager.test.ts +++ b/packages/dashboard/src/__tests__/chat-manager.test.ts @@ -1285,6 +1285,7 @@ describe("ChatManager.sendMessage", () => { prompt: "# PROMPT.md\n\nImplement the planner-model Chat tab from the detailed task plan.", column: "todo", status: "planning", + currentStep: 0, dependencies: ["FN-7309"], steps: [{ title: "Polish", status: "in-progress" }], comments: [{ text: "User wants planner chat", author: "user" }], @@ -1313,13 +1314,23 @@ describe("ChatManager.sendMessage", () => { expect(createOptions.systemPrompt).toContain("Title: Add planner chat"); expect(createOptions.systemPrompt).toContain("Prompt:\n# PROMPT.md"); expect(createOptions.systemPrompt).toContain("Implement the planner-model Chat tab from the detailed task plan."); - expect(createOptions.systemPrompt).toContain("Dependencies: FN-7309"); + expect(createOptions.systemPrompt).toContain("Dependencies:\n- FN-7309:"); + expect(createOptions.systemPrompt).toContain("Progress: step 1 of 1"); + expect(createOptions.systemPrompt).toContain("Current step: Polish: in-progress"); expect(createOptions.systemPrompt).toContain("Polish: in-progress"); expect(createOptions.systemPrompt).toContain("Activity transcript loaded"); - expect(createOptions.systemPrompt).toContain("fn_task_planner_add_steering"); expect(createOptions.systemPrompt).toContain("fn_ask_question"); expect(createOptions.customTools.map((tool: { name: string }) => tool.name)).toContain("fn_task_planner_add_steering"); - expect(taskStore.getTask).toHaveBeenCalledWith("FN-7310", { activityLogLimit: 20 }); + expect(mockChatStore.addMessage).toHaveBeenCalledWith("chat-001", expect.objectContaining({ + role: "user", + content: "How should I plan this?", + })); + expect(mockChatStore.addMessage).not.toHaveBeenCalledWith("chat-001", expect.objectContaining({ + role: "user", + content: expect.stringContaining("Task Planner Chat Context"), + })); + expect(taskStore.getTask).toHaveBeenNthCalledWith(1, "FN-7310", { activityLogLimit: 20 }); + expect(taskStore.getTask).toHaveBeenCalledWith("FN-7309"); }); it("guides chat agents to use ask-question cards for option sets", () => { diff --git a/packages/dashboard/src/__tests__/chat-routes.test.ts b/packages/dashboard/src/__tests__/chat-routes.test.ts index df3a81fd6e..00d616d077 100644 --- a/packages/dashboard/src/__tests__/chat-routes.test.ts +++ b/packages/dashboard/src/__tests__/chat-routes.test.ts @@ -482,6 +482,38 @@ describe("Chat API Routes", () => { expect(mockCreateSession).not.toHaveBeenCalled(); }); + it("does not resume another task's planner chat session", async () => { + const created = { + ...sampleSession, + id: "chat-planner-7312", + agentId: "task-planner:FN-7312", + title: "FN-7312 planner chat", + }; + mockFindLatestActiveSessionForTarget.mockImplementation((input: { agentId: string }) => { + return input.agentId === "task-planner:FN-7311" + ? { ...sampleSession, id: "chat-planner-7311", agentId: "task-planner:FN-7311" } + : null; + }); + mockCreateSession.mockReturnValue(created); + + const response = await request( + app, + "POST", + "/api/chat/task-planner/FN-7312/session", + JSON.stringify({}), + { "content-type": "application/json" }, + ); + + expect(response.status).toBe(201); + expect((response.body as any).session).toEqual(created); + expect(mockFindLatestActiveSessionForTarget).toHaveBeenCalledWith({ agentId: "task-planner:FN-7312" }); + expect(mockCreateSession).toHaveBeenCalledWith(expect.objectContaining({ + agentId: "task-planner:FN-7312", + title: "FN-7312 planner chat", + })); + expect(mockUpdateSession).not.toHaveBeenCalled(); + }); + it("rejects incomplete model override pairs", async () => { const response = await request( app, @@ -1252,6 +1284,50 @@ describe("Chat API Routes", () => { expect((response.body as any).error).toContain("content is required"); }); + it("rejects a taskId that does not match a task-planner chat session", async () => { + mockGetSession.mockReturnValue({ ...sampleSession, agentId: "task-planner:FN-7312" }); + + const response = await request( + app, + "POST", + "/api/chat/sessions/chat-abc123/messages", + JSON.stringify({ content: "What is the status?", taskId: "FN-OTHER" }), + { "content-type": "application/json" }, + ); + + expect(response.status).toBe(400); + expect((response.body as any).error).toContain("taskId does not match"); + expect(mockSendMessage).not.toHaveBeenCalled(); + }); + + it("accepts matching taskId metadata while preserving the clean user content sent to ChatManager", async () => { + mockGetSession.mockReturnValue({ ...sampleSession, agentId: "task-planner:FN-7312" }); + mockSendMessage.mockImplementation(async (sessionId: string) => { + mockChatStreamManager.broadcast(sessionId, { + type: "done", + data: { messageId: "msg-task-planner" }, + }); + }); + + const response = await request( + app, + "POST", + "/api/chat/sessions/chat-abc123/messages", + JSON.stringify({ content: "What is the status?", taskId: "FN-7312" }), + { "content-type": "application/json" }, + ); + + expect(response.status).toBe(200); + expect(mockSendMessage).toHaveBeenCalledWith( + "chat-abc123", + "What is the status?", + undefined, + undefined, + undefined, + { generationId: expect.any(Number) }, + ); + }); + it("accepts multipart content with attachments without crashing", async () => { mockGetSession.mockReturnValue(sampleSession); mockSendMessage.mockImplementation(async (sessionId: string) => { diff --git a/packages/dashboard/src/__tests__/task-planner-chat-context.test.ts b/packages/dashboard/src/__tests__/task-planner-chat-context.test.ts new file mode 100644 index 0000000000..858ebf5778 --- /dev/null +++ b/packages/dashboard/src/__tests__/task-planner-chat-context.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it, vi } from "vitest"; +import { buildTaskPlannerChatContext, formatTaskPlannerChatContext, TASK_PLANNER_CHAT_CONTEXT_PROMPT_GUIDANCE } from "../task-planner-chat-context.js"; + +function createStore(tasks: Record>) { + return { + getTask: vi.fn(async (taskId: string) => { + const task = tasks[taskId]; + if (!task) throw new Error(`Task ${taskId} not found`); + return task; + }), + } as any; +} + +describe("task planner chat context", () => { + it("builds bounded task status, dependency, activity, and prompt context server-side", async () => { + const store = createStore({ + "FN-7312": { + id: "FN-7312", + title: "Send relevant task context to planner chat", + description: "Short description", + prompt: "# PROMPT.md\n\nAnswer status questions from task context.", + column: "in-progress", + status: "running", + priority: "high", + assignedAgent: "agent-planner", + currentStep: 1, + dependencies: ["FN-7311", "FN-7311", "FN-MISSING"], + steps: [ + { title: "Preflight", status: "done" }, + { title: "Context contract", status: "in-progress" }, + ], + comments: [{ author: "user", text: "What is happening?", createdAt: "2026-06-30T00:00:00.000Z" }], + steeringComments: [{ author: "operator", text: "Keep Activity separate." }], + log: [ + { level: "info", message: "Planner chat shell loaded", timestamp: "2026-06-30T00:01:00.000Z" }, + { type: "task:moved", details: "Moved from todo to in-progress" }, + { type: "legacy", action: "Executor picked up the task" }, + { type: "tool", text: "Fetched task detail" }, + ], + sourceIssue: { title: "Planner Chat", state: "open", url: "https://example.test/issues/1" }, + reviewStatus: "not-started", + }, + "FN-7311": { + id: "FN-7311", + title: "Add starter prompts", + column: "done", + status: "complete", + }, + }); + + const context = await buildTaskPlannerChatContext(store, "FN-7312"); + + expect(store.getTask).toHaveBeenNthCalledWith(1, "FN-7312", { activityLogLimit: 20 }); + expect(store.getTask).toHaveBeenCalledWith("FN-7311"); + expect(store.getTask).toHaveBeenCalledWith("FN-MISSING"); + expect(context.snapshot.dependencies).toEqual([ + { id: "FN-7311", title: "Add starter prompts", column: "done", status: "complete" }, + { id: "FN-MISSING", missing: true }, + ]); + expect(context.promptContext).toContain("Task ID: FN-7312"); + expect(context.promptContext).toContain("Column: in-progress"); + expect(context.promptContext).toContain("Status: running"); + expect(context.promptContext).toContain("Progress: step 2 of 2"); + expect(context.promptContext).toContain("Current step: Context contract: in-progress"); + expect(context.promptContext).toContain("Priority: high"); + expect(context.promptContext).toContain("Assigned agent: agent-planner"); + expect(context.promptContext).toContain("- FN-7311: Add starter prompts; done; complete"); + expect(context.promptContext).toContain("- FN-MISSING: details unavailable"); + expect(context.promptContext).toContain("Prompt:\n# PROMPT.md"); + expect(context.promptContext).toContain("- Context contract: in-progress"); + expect(context.promptContext).toContain("Recent activity:"); + expect(context.promptContext).toContain("Planner chat shell loaded"); + expect(context.promptContext).toContain("Moved from todo to in-progress"); + expect(context.promptContext).toContain("Executor picked up the task"); + expect(context.promptContext).toContain("Recent comments / steering:"); + expect(context.promptContext).toContain("Keep Activity separate."); + expect(context.promptContext).toContain("Source / PR context:"); + expect(context.promptContext).toContain("reviewStatus: not-started"); + }); + + it("marks absent prompt, logs, and dependencies as unavailable instead of omitting them", async () => { + const store = createStore({ + "FN-EMPTY": { + id: "FN-EMPTY", + column: "todo", + status: "pending", + dependencies: [], + steps: [], + }, + }); + + const context = await buildTaskPlannerChatContext(store, "FN-EMPTY"); + + expect(context.promptContext).toContain("Title: not available"); + expect(context.promptContext).toContain("Dependencies: none"); + expect(context.promptContext).toContain("Prompt/plan: not available"); + expect(context.promptContext).toContain("Steps: not available"); + expect(context.promptContext).toContain("Recent activity: not available"); + expect(context.promptContext).toContain("Recent comments / steering: not available"); + expect(context.promptContext).toContain("Task prompt/plan content is not available"); + expect(context.promptContext).toContain("Recent activity/log context is not available"); + expect(context.promptContext).toContain("No dependencies are listed for this task"); + }); + + it("truncates long prompt, activity, and comment excerpts to bounded limits", async () => { + const longPrompt = "P".repeat(80); + const longLog = "L".repeat(80); + const longComment = "C".repeat(80); + const store = createStore({ + "FN-LONG": { + id: "FN-LONG", + prompt: longPrompt, + column: "todo", + status: "pending", + log: [{ message: longLog, level: "info" }], + comments: [{ text: longComment, author: "user" }], + }, + }); + + const context = await buildTaskPlannerChatContext(store, "FN-LONG", { + taskSpecMaxChars: 12, + recentActivityMaxChars: 10, + recentCommentMaxChars: 9, + }); + + expect(context.promptContext).toContain(`${"P".repeat(11)}…`); + expect(context.promptContext).toContain(`${"L".repeat(9)}…`); + expect(context.promptContext).toContain(`${"C".repeat(8)}…`); + expect(context.promptContext).not.toContain(longPrompt); + expect(context.promptContext).not.toContain(longLog); + expect(context.promptContext).not.toContain(longComment); + }); + + it("formats duplicate-free dependency snapshots passed directly to the formatter", () => { + const promptContext = formatTaskPlannerChatContext({ + taskId: "FN-FORMAT", + title: "Formatter task", + column: "failed", + status: "error", + progress: "0/3 steps done", + currentStep: "Preflight: failed", + dependencies: [ + { id: "FN-1", title: "Ready", column: "done", status: "complete" }, + { id: "FN-2", missing: true }, + ], + steps: [], + recentActivity: [], + recentComments: [], + source: [], + review: [], + notes: [], + }); + + expect(promptContext).toContain("- FN-1: Ready; done; complete"); + expect(promptContext).toContain("- FN-2: details unavailable"); + expect(promptContext).toContain("Prompt/plan: not available"); + }); + + it("tells the planner to answer from context 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"); + }); +}); diff --git a/packages/dashboard/src/chat.ts b/packages/dashboard/src/chat.ts index a0eca4a505..e17e1f22ca 100644 --- a/packages/dashboard/src/chat.ts +++ b/packages/dashboard/src/chat.ts @@ -34,6 +34,7 @@ import { join, resolve, relative } from "node:path"; import { SessionManager } from "@earendil-works/pi-coding-agent"; import { SessionEventBuffer } from "./sse-buffer.js"; import { formatChatAttachmentContents, readChatAttachmentContents } from "./chat-attachment-content.js"; +import { buildTaskPlannerChatContext, TASK_PLANNER_CHAT_CONTEXT_PROMPT_GUIDANCE } from "./task-planner-chat-context.js"; import { createFnAgent as engineCreateFnAgent, @@ -231,84 +232,6 @@ const MAX_REFERENCED_FILE_SIZE = 50 * 1024; export const TASK_PLANNER_CHAT_AGENT_ID_PREFIX = "task-planner:"; const ROOM_AMBIENT_MAX_RESPONDERS = 5; -function summarizeList(items: T[] | undefined, format: (item: T, index: number) => string | null, limit: number): string[] { - const values = (items ?? []).map(format).filter((value): value is string => Boolean(value)); - const selected = values.slice(-limit); - if (values.length > selected.length) { - return [`…${values.length - selected.length} older entries omitted`, ...selected]; - } - return selected; -} - -function truncatePlannerContext(value: string, max = 1_200): string { - return value.length > max ? `${value.slice(0, max)}…` : value; -} - -interface PlannerContextTask { - id: string; - title?: string; - description?: string; - prompt?: string; - plan?: string; - column?: string; - status?: string; - dependencies?: string[]; - steps?: Array<{ title?: string; name?: string; status?: string }>; - comments?: Array<{ text?: string; author?: string }>; - steeringComments?: Array<{ text?: string; author?: string }>; - log?: Array<{ message?: string; text?: string; level?: string; type?: string }>; -} - -function buildTaskPlannerContext(task: PlannerContextTask): string { - const promptText = typeof task.prompt === "string" && task.prompt.trim() ? task.prompt.trim() : ""; - const planText = typeof task.plan === "string" && task.plan.trim() ? task.plan.trim() : ""; - const descriptionText = typeof task.description === "string" && task.description.trim() ? task.description.trim() : ""; - const primaryTaskSpec = promptText || planText || descriptionText; - const primaryTaskSpecLabel = promptText ? "Prompt" : planText ? "Plan" : "Description"; - - /* - FNXC:TaskDetailPlannerChat 2026-06-30-23:59: - Planner Chat must answer from the task's PROMPT.md/plan when available, not only the shorter description shown in lists. Use description only as the fallback so normal task-detail conversations see the same implementation plan the executor saw. - */ - const parts = [ - `Task ID: ${task.id}`, - task.title ? `Title: ${task.title}` : null, - `Column: ${task.column}`, - task.status ? `Status: ${task.status}` : null, - Array.isArray(task.dependencies) && task.dependencies.length > 0 ? `Dependencies: ${task.dependencies.join(", ")}` : "Dependencies: none", - primaryTaskSpec ? `${primaryTaskSpecLabel}:\n${truncatePlannerContext(primaryTaskSpec)}` : null, - ].filter(Boolean) as string[]; - - if (promptText && planText && planText !== promptText) { - parts.push(`Plan:\n${truncatePlannerContext(planText)}`); - } - - const steps = summarizeList(task.steps, (step, index) => { - const title = typeof step.title === "string" ? step.title : typeof step.name === "string" ? step.name : `Step ${index}`; - const status = typeof step.status === "string" ? step.status : "unknown"; - return `- ${title}: ${status}`; - }, 10); - if (steps.length > 0) parts.push(`Steps:\n${steps.join("\n")}`); - - const comments = summarizeList([...(task.comments ?? []), ...(task.steeringComments ?? [])], (comment) => { - const text = typeof comment.text === "string" ? comment.text.trim() : ""; - if (!text) return null; - const author = typeof comment.author === "string" ? comment.author : "user"; - return `- ${author}: ${truncatePlannerContext(text, 500)}`; - }, 8); - if (comments.length > 0) parts.push(`Recent comments / steering:\n${comments.join("\n")}`); - - const activity = summarizeList(task.log, (entry) => { - const message = typeof entry.message === "string" ? entry.message : typeof entry.text === "string" ? entry.text : ""; - if (!message.trim()) return null; - const level = typeof entry.level === "string" ? entry.level : typeof entry.type === "string" ? entry.type : "log"; - return `- ${level}: ${truncatePlannerContext(message.trim(), 500)}`; - }, 12); - if (activity.length > 0) parts.push(`Recent activity:\n${activity.join("\n")}`); - - return parts.join("\n\n"); -} - function createTaskPlannerSteeringTool(taskStore: TaskStore, taskId: string) { return { name: "fn_task_planner_add_steering", @@ -1829,21 +1752,18 @@ export class ChatManager { ? session.agentId.slice(TASK_PLANNER_CHAT_AGENT_ID_PREFIX.length).trim() : ""; if (taskPlannerChatTaskId) { - let taskContext = `Task ID: ${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 task = await this.taskStore.getTask(taskPlannerChatTaskId, { activityLogLimit: 20 }); - taskContext = buildTaskPlannerContext(task as PlannerContextTask); + const context = await buildTaskPlannerChatContext(this.taskStore, taskPlannerChatTaskId); + taskContext = context.promptContext; } catch (taskLoadError) { const message = taskLoadError instanceof Error ? taskLoadError.message : String(taskLoadError); + taskContext = `Task ID: ${taskPlannerChatTaskId}\n\nContext availability notes:\n- Task context could not be loaded: ${message}`; diagnostics.warn(`Failed to load task planner-chat context for ${taskPlannerChatTaskId}: ${message}`); } } - /* - FNXC:TaskDetailPlannerChat 2026-06-30-23:58: - Task-detail planner Chat sessions use a synthetic task-planner agent id and the planning-model lane. Include compact task state, dependency, comment, step, and recent activity context so the planner can answer status questions; convert only explicit steering intent through the scoped steering tool and use `fn_ask_question` for ambiguous clarification. - */ - systemPrompt = `${systemPrompt}\n\n## Task Planner Chat Context\nYou are answering in the task detail Chat tab for a single Fusion task. Use the task context below to answer status, dependency, recent-activity, and planning questions. If the user explicitly asks you to tell, steer, instruct, or update the executor/reviewer, call \`fn_task_planner_add_steering\` with the concise steering text. If the user's intent might be either conversation or steering, call \`fn_ask_question\` to clarify before adding steering. Do not add steering for ordinary questions or brainstorming.\n\n${taskContext}`; + systemPrompt = `${systemPrompt}\n\n${TASK_PLANNER_CHAT_CONTEXT_PROMPT_GUIDANCE}\n\n${taskContext}`; } if (agent) { diff --git a/packages/dashboard/src/routes/register-chat-routes.ts b/packages/dashboard/src/routes/register-chat-routes.ts index 3111944b42..c87f8f97fd 100644 --- a/packages/dashboard/src/routes/register-chat-routes.ts +++ b/packages/dashboard/src/routes/register-chat-routes.ts @@ -651,8 +651,9 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): modelProvider?: string; modelId?: string; attachments?: ChatAttachment[]; + taskId?: string; }; - const { content, modelProvider, modelId, attachments } = body; + const { content, modelProvider, modelId, attachments, taskId } = body; const sessionId = String(req.params.id); const uploadedFiles = Array.isArray(req.files) ? (req.files as Express.Multer.File[]) : []; const referencedAttachments = Array.isArray(attachments) ? attachments : undefined; @@ -675,6 +676,14 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): throw notFound(`Chat session ${sessionId} not found`); } + const normalizedTaskId = typeof taskId === "string" ? taskId.trim() : ""; + if (normalizedTaskId) { + const expectedAgentId = `${TASK_PLANNER_CHAT_AGENT_ID_PREFIX}${normalizedTaskId}`; + if (session.agentId !== expectedAgentId) { + throw badRequest("taskId does not match the chat session task scope"); + } + } + const { store: scopedStore } = await getProjectContext(req); const uploadedAttachments = uploadedFiles.length > 0 ? await Promise.all(uploadedFiles.map((file) => persistChatAttachment(file, scopedStore.getRootDir(), sessionId))) diff --git a/packages/dashboard/src/task-planner-chat-context.ts b/packages/dashboard/src/task-planner-chat-context.ts new file mode 100644 index 0000000000..77d5646bc5 --- /dev/null +++ b/packages/dashboard/src/task-planner-chat-context.ts @@ -0,0 +1,326 @@ +import type { TaskStore } from "@fusion/core"; + +const DEFAULT_TASK_SPEC_MAX_CHARS = 3_000; +const DEFAULT_RECENT_ACTIVITY_LIMIT = 12; +const DEFAULT_RECENT_ACTIVITY_MAX_CHARS = 500; +const DEFAULT_RECENT_COMMENT_LIMIT = 8; +const DEFAULT_RECENT_COMMENT_MAX_CHARS = 500; +const DEFAULT_STEP_LIMIT = 20; +const DEFAULT_DEPENDENCY_LIMIT = 20; + +type UnknownRecord = Record; + +interface TaskPlannerChatContextOptions { + taskSpecMaxChars?: number; + recentActivityLimit?: number; + recentActivityMaxChars?: number; + recentCommentLimit?: number; + recentCommentMaxChars?: number; + stepLimit?: number; + dependencyLimit?: number; +} + +export interface TaskPlannerChatDependencyContext { + id: string; + title?: string; + column?: string; + status?: string; + missing?: boolean; +} + +export interface TaskPlannerChatContextSnapshot { + taskId: string; + title?: string; + description?: string; + prompt?: string; + plan?: string; + column?: string; + status?: string; + progress?: string; + currentStep?: string; + priority?: string; + assignedAgent?: string; + dependencies: TaskPlannerChatDependencyContext[]; + steps: string[]; + recentComments: string[]; + recentActivity: string[]; + source: string[]; + review: string[]; + notes: string[]; +} + +function isRecord(value: unknown): value is UnknownRecord { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function readString(record: UnknownRecord, ...keys: string[]): string | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + return undefined; +} + +function readNumber(record: UnknownRecord, ...keys: string[]): number | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + } + return undefined; +} + +function truncate(value: string, maxChars: number): string { + if (value.length <= maxChars) return value; + return `${value.slice(0, Math.max(0, maxChars - 1))}…`; +} + +function summarizeList(items: T[] | undefined, format: (item: T, index: number) => string | null, limit: number): string[] { + const values = (items ?? []).map(format).filter((value): value is string => Boolean(value)); + const selected = values.slice(-limit); + if (values.length > selected.length) { + return [`…${values.length - selected.length} older entries omitted`, ...selected]; + } + return selected; +} + +function uniqueStrings(values: unknown): string[] { + if (!Array.isArray(values)) return []; + const seen = new Set(); + const result: string[] = []; + for (const value of values) { + if (typeof value !== "string") continue; + const trimmed = value.trim(); + if (!trimmed || seen.has(trimmed)) continue; + seen.add(trimmed); + result.push(trimmed); + } + return result; +} + +function formatTimestampPrefix(record: UnknownRecord): string { + const timestamp = readString(record, "timestamp", "createdAt", "updatedAt"); + return timestamp ? `[${timestamp}] ` : ""; +} + +function formatProgress(task: UnknownRecord): string | undefined { + const explicitProgress = readString(task, "progress"); + if (explicitProgress) return explicitProgress; + + const currentStep = readNumber(task, "currentStep"); + const steps = Array.isArray(task.steps) ? task.steps : []; + if (typeof currentStep === "number" && steps.length > 0) { + return `step ${currentStep + 1} of ${steps.length}`; + } + + const done = steps.filter((step) => isRecord(step) && readString(step, "status") === "done").length; + if (steps.length > 0) { + return `${done}/${steps.length} steps done`; + } + return undefined; +} + +function formatCurrentStep(task: UnknownRecord): string | undefined { + const currentStep = readNumber(task, "currentStep"); + const steps = Array.isArray(task.steps) ? task.steps : []; + if (typeof currentStep !== "number" || currentStep < 0 || currentStep >= steps.length) { + return undefined; + } + const step = steps[currentStep]; + if (!isRecord(step)) return `Step ${currentStep + 1}`; + const title = readString(step, "title", "name") ?? `Step ${currentStep + 1}`; + const status = readString(step, "status") ?? "unknown"; + return `${title}: ${status}`; +} + +function collectSourceContext(task: UnknownRecord): string[] { + const source: string[] = []; + const sourceIssue = isRecord(task.sourceIssue) ? task.sourceIssue : undefined; + if (sourceIssue) { + const title = readString(sourceIssue, "title"); + const url = readString(sourceIssue, "url", "htmlUrl"); + const state = readString(sourceIssue, "state", "status"); + source.push(`Issue${title ? ` ${title}` : ""}${state ? ` (${state})` : ""}${url ? ` — ${url}` : ""}`); + } + const pr = isRecord(task.pr) ? task.pr : isRecord(task.prInfo) ? task.prInfo : undefined; + if (pr) { + const title = readString(pr, "title"); + const url = readString(pr, "url", "htmlUrl"); + const state = readString(pr, "state", "status"); + source.push(`PR${title ? ` ${title}` : ""}${state ? ` (${state})` : ""}${url ? ` — ${url}` : ""}`); + } + return source; +} + +function collectReviewContext(task: UnknownRecord): string[] { + const review: string[] = []; + for (const key of ["reviewState", "reviewStatus", "mergeStatus", "prState"]) { + const value = readString(task, key); + if (value) review.push(`${key}: ${value}`); + } + return review; +} + +function buildSnapshot(task: unknown, dependencies: TaskPlannerChatDependencyContext[], options: Required): TaskPlannerChatContextSnapshot { + const record = isRecord(task) ? task : {}; + const taskId = readString(record, "id") ?? "unknown"; + const prompt = readString(record, "prompt"); + const plan = readString(record, "plan"); + const description = readString(record, "description"); + + const steps = summarizeList(Array.isArray(record.steps) ? record.steps : [], (step, index) => { + if (!isRecord(step)) return null; + const title = readString(step, "title", "name") ?? `Step ${index + 1}`; + const status = readString(step, "status") ?? "unknown"; + return `- ${title}: ${status}`; + }, options.stepLimit); + + const recentComments = summarizeList([ + ...(Array.isArray(record.comments) ? record.comments : []), + ...(Array.isArray(record.steeringComments) ? record.steeringComments : []), + ], (comment) => { + if (!isRecord(comment)) return null; + const text = readString(comment, "text", "message", "content"); + if (!text) return null; + const author = readString(comment, "author", "authorId", "from") ?? "user"; + return `- ${formatTimestampPrefix(comment)}${author}: ${truncate(text, options.recentCommentMaxChars)}`; + }, options.recentCommentLimit); + + const activitySource = Array.isArray(record.activityLog) + ? record.activityLog + : Array.isArray(record.log) + ? record.log + : Array.isArray(record.agentLog) + ? record.agentLog + : []; + const recentActivity = summarizeList(activitySource, (entry) => { + if (!isRecord(entry)) return null; + const message = readString(entry, "message", "text", "summary", "details", "outcome", "action"); + if (!message) return null; + const level = readString(entry, "level", "type", "event", "agent") ?? "log"; + return `- ${formatTimestampPrefix(entry)}${level}: ${truncate(message, options.recentActivityMaxChars)}`; + }, options.recentActivityLimit); + + return { + taskId, + title: readString(record, "title"), + description, + prompt, + plan, + column: readString(record, "column"), + status: readString(record, "status"), + progress: formatProgress(record), + currentStep: formatCurrentStep(record), + priority: readString(record, "priority"), + assignedAgent: readString(record, "assignedAgent", "assignedAgentId", "agentId", "executorAgentId"), + dependencies, + steps, + recentComments, + recentActivity, + source: collectSourceContext(record), + review: collectReviewContext(record), + notes: [ + prompt || plan || description ? "" : "Task prompt/plan content is not available in the current context.", + recentActivity.length > 0 ? "" : "Recent activity/log context is not available.", + dependencies.length > 0 ? "" : "No dependencies are listed for this task.", + ].filter(Boolean), + }; +} + +async function resolveDependencies(taskStore: TaskStore, task: unknown, options: Required): Promise { + const record = isRecord(task) ? task : {}; + const dependencyIds = uniqueStrings(record.dependencies).slice(0, options.dependencyLimit); + const dependencies: TaskPlannerChatDependencyContext[] = []; + for (const dependencyId of dependencyIds) { + try { + const dependency = await taskStore.getTask(dependencyId); + const dependencyRecord = isRecord(dependency) ? dependency : {}; + dependencies.push({ + id: dependencyId, + title: readString(dependencyRecord, "title"), + column: readString(dependencyRecord, "column"), + status: readString(dependencyRecord, "status"), + }); + } catch { + dependencies.push({ id: dependencyId, missing: true }); + } + } + return dependencies; +} + +function optionsWithDefaults(options: TaskPlannerChatContextOptions = {}): Required { + return { + taskSpecMaxChars: options.taskSpecMaxChars ?? DEFAULT_TASK_SPEC_MAX_CHARS, + recentActivityLimit: options.recentActivityLimit ?? DEFAULT_RECENT_ACTIVITY_LIMIT, + recentActivityMaxChars: options.recentActivityMaxChars ?? DEFAULT_RECENT_ACTIVITY_MAX_CHARS, + recentCommentLimit: options.recentCommentLimit ?? DEFAULT_RECENT_COMMENT_LIMIT, + recentCommentMaxChars: options.recentCommentMaxChars ?? DEFAULT_RECENT_COMMENT_MAX_CHARS, + stepLimit: options.stepLimit ?? DEFAULT_STEP_LIMIT, + dependencyLimit: options.dependencyLimit ?? DEFAULT_DEPENDENCY_LIMIT, + }; +} + +export function formatTaskPlannerChatContext(snapshot: TaskPlannerChatContextSnapshot, options: TaskPlannerChatContextOptions = {}): string { + const resolvedOptions = optionsWithDefaults(options); + const promptText = snapshot.prompt?.trim() ?? ""; + const planText = snapshot.plan?.trim() ?? ""; + const descriptionText = snapshot.description?.trim() ?? ""; + const primaryTaskSpec = promptText || planText || descriptionText; + const primaryTaskSpecLabel = promptText ? "Prompt" : planText ? "Plan" : "Description"; + + const parts = [ + `Task ID: ${snapshot.taskId}`, + snapshot.title ? `Title: ${snapshot.title}` : "Title: not available", + `Column: ${snapshot.column ?? "unknown"}`, + `Status: ${snapshot.status ?? "unknown"}`, + snapshot.progress ? `Progress: ${snapshot.progress}` : "Progress: not available", + snapshot.currentStep ? `Current step: ${snapshot.currentStep}` : "Current step: not available", + snapshot.priority ? `Priority: ${snapshot.priority}` : "Priority: not available", + snapshot.assignedAgent ? `Assigned agent: ${snapshot.assignedAgent}` : "Assigned agent: not available", + ]; + + if (snapshot.dependencies.length > 0) { + parts.push(`Dependencies:\n${snapshot.dependencies.map((dependency) => { + if (dependency.missing) return `- ${dependency.id}: details unavailable`; + const details = [dependency.title, dependency.column, dependency.status].filter(Boolean).join("; "); + return `- ${dependency.id}${details ? `: ${details}` : ": details unavailable"}`; + }).join("\n")}`); + } else { + parts.push("Dependencies: none"); + } + + parts.push(primaryTaskSpec ? `${primaryTaskSpecLabel}:\n${truncate(primaryTaskSpec, resolvedOptions.taskSpecMaxChars)}` : "Prompt/plan: not available"); + if (promptText && planText && planText !== promptText) { + parts.push(`Plan:\n${truncate(planText, resolvedOptions.taskSpecMaxChars)}`); + } + parts.push(snapshot.steps.length > 0 ? `Steps:\n${snapshot.steps.join("\n")}` : "Steps: not available"); + parts.push(snapshot.recentActivity.length > 0 ? `Recent activity:\n${snapshot.recentActivity.join("\n")}` : "Recent activity: not available"); + parts.push(snapshot.recentComments.length > 0 ? `Recent comments / steering:\n${snapshot.recentComments.join("\n")}` : "Recent comments / steering: not available"); + if (snapshot.source.length > 0) parts.push(`Source / PR context:\n${snapshot.source.map((item) => `- ${item}`).join("\n")}`); + if (snapshot.review.length > 0) parts.push(`Review / merge state:\n${snapshot.review.map((item) => `- ${item}`).join("\n")}`); + if (snapshot.notes.length > 0) parts.push(`Context availability notes:\n${snapshot.notes.map((note) => `- ${note}`).join("\n")}`); + return parts.join("\n\n"); +} + +export async function buildTaskPlannerChatContext(taskStore: TaskStore, taskId: string, options: TaskPlannerChatContextOptions = {}): Promise<{ snapshot: TaskPlannerChatContextSnapshot; promptContext: string }> { + const normalizedTaskId = taskId.trim(); + if (!normalizedTaskId) { + throw new Error("taskId is required"); + } + const resolvedOptions = optionsWithDefaults(options); + const task = await taskStore.getTask(normalizedTaskId, { activityLogLimit: resolvedOptions.recentActivityLimit + 8 } as never); + const dependencies = await resolveDependencies(taskStore, task, resolvedOptions); + const snapshot = buildSnapshot(task, dependencies, resolvedOptions); + const promptContext = formatTaskPlannerChatContext(snapshot, resolvedOptions); + return { snapshot, promptContext }; +} + +/* +FNXC:TaskDetailPlannerChat 2026-06-30-23:58: +Task-detail planner Chat receives this server-built, bounded task snapshot so the planner can answer current status, progress, dependency, recent-activity, and prompt/plan questions without trusting client-supplied context. Activity remains the operational steering/execution transcript; this formatter exposes comments/log excerpts as read-only context and records unavailable sections explicitly so the planner states uncertainty instead of inventing fresh execution evidence. +*/ +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. 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 questions.`;