diff --git a/.changeset/fn-7313-task-chat-steering.md b/.changeset/fn-7313-task-chat-steering.md new file mode 100644 index 0000000000..b6e912de2e --- /dev/null +++ b/.changeset/fn-7313-task-chat-steering.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Convert clear task chat change requests into steering comments. +category: feature +dev: Task-detail planner Chat now asks for clarification before ambiguous or risky steering. diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 5c05818cc6..60c9236f89 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -3311,6 +3311,7 @@ export function TaskDetailContent({ active={activeTab === "planner-chat"} planningModel={resolveEffectivePlanning(workingTask, agentLogEntries, settings)} addToast={addToast} + onTaskUpdated={onTaskUpdated} /> ) : activeTab === "chat" ? ( diff --git a/packages/dashboard/app/components/TaskPlannerChatTab.css b/packages/dashboard/app/components/TaskPlannerChatTab.css index 8639d23829..4019095507 100644 --- a/packages/dashboard/app/components/TaskPlannerChatTab.css +++ b/packages/dashboard/app/components/TaskPlannerChatTab.css @@ -152,6 +152,34 @@ margin-bottom: 0; } +.task-planner-chat-steering-confirmation { + margin-top: var(--space-sm); + border: var(--btn-border-width) solid var(--color-success); + border-radius: var(--radius-md); + padding: var(--space-sm); + color: var(--text); + background: var(--surface); +} + +.task-planner-chat-steering-confirmation--pending { + border-color: var(--color-warning); +} + +.task-planner-chat-steering-confirmation--error { + border-color: var(--color-error); + color: var(--color-error); +} + +.task-planner-chat-steering-confirmation strong, +.task-planner-chat-steering-confirmation p { + margin: 0; +} + +.task-planner-chat-steering-confirmation p { + margin-top: var(--space-xs); + color: var(--text-muted); +} + .task-planner-chat-composer { display: flex; align-items: flex-end; diff --git a/packages/dashboard/app/components/TaskPlannerChatTab.tsx b/packages/dashboard/app/components/TaskPlannerChatTab.tsx index 6a167eda19..363659b289 100644 --- a/packages/dashboard/app/components/TaskPlannerChatTab.tsx +++ b/packages/dashboard/app/components/TaskPlannerChatTab.tsx @@ -7,7 +7,7 @@ import { Loader2, Send } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { ToastType } from "../hooks/useToast"; import type { ToolCallInfo } from "../hooks/chatTypes"; -import { ensureTaskPlannerChatSession, fetchChatMessages, streamChatResponse } from "../api"; +import { ensureTaskPlannerChatSession, fetchChatMessages, fetchTaskDetail, streamChatResponse } from "../api"; import { parseQuestionToolCall } from "../utils/parseQuestionToolCall"; import { markdownComponents } from "./AgentLogViewer"; import { ChatQuestionResponse } from "./ChatQuestionResponse"; @@ -19,6 +19,7 @@ interface TaskPlannerChatTabProps { active: boolean; planningModel: ResolvedModelSelection; addToast: (msg: string, type?: ToastType) => void; + onTaskUpdated?: (task: Task) => void; } type ComposerState = "idle" | "sending"; @@ -104,6 +105,50 @@ function makeStreamingAssistantMessage(sessionId: string, content: string, toolC }; } +const TASK_PLANNER_STEERING_TOOL_NAME = "fn_task_planner_add_steering"; + +interface PlannerSteeringResult { + text: string; + id?: string; + createdAt?: string; +} + +function readRecord(value: unknown): Record | null { + return value && typeof value === "object" ? value as Record : null; +} + +function extractPlannerSteeringResult(toolCall: ToolCallInfo): PlannerSteeringResult | null { + if (toolCall.toolName !== TASK_PLANNER_STEERING_TOOL_NAME || toolCall.isError) return null; + const resultRecord = readRecord(toolCall.result); + const detailsRecord = readRecord(resultRecord?.details) ?? resultRecord; + const commentRecord = readRecord(detailsRecord?.steeringComment); + const text = typeof commentRecord?.text === "string" && commentRecord.text.trim() + ? commentRecord.text.trim() + : typeof detailsRecord?.text === "string" && detailsRecord.text.trim() + ? detailsRecord.text.trim() + : typeof toolCall.args?.text === "string" && toolCall.args.text.trim() + ? toolCall.args.text.trim() + : ""; + if (!text) return null; + return { + text, + ...(typeof commentRecord?.id === "string" && commentRecord.id.trim() ? { id: commentRecord.id.trim() } : {}), + ...(typeof commentRecord?.createdAt === "string" && commentRecord.createdAt.trim() ? { createdAt: commentRecord.createdAt.trim() } : {}), + }; +} + +function extractPlannerSteeringTextFromResult(result: unknown): string | null { + const resultRecord = readRecord(result); + const detailsRecord = readRecord(resultRecord?.details) ?? resultRecord; + const commentRecord = readRecord(detailsRecord?.steeringComment); + const text = typeof commentRecord?.text === "string" && commentRecord.text.trim() + ? commentRecord.text.trim() + : typeof detailsRecord?.text === "string" && detailsRecord.text.trim() + ? detailsRecord.text.trim() + : ""; + return text || null; +} + function extractToolCalls(message: ChatMessage): ToolCallInfo[] { const rawToolCalls = message.metadata?.toolCalls; if (!Array.isArray(rawToolCalls)) return []; @@ -125,7 +170,7 @@ function extractToolCalls(message: ChatMessage): ToolCallInfo[] { .filter((toolCall): toolCall is ToolCallInfo => toolCall !== null); } -export function TaskPlannerChatTab({ task, projectId, active, planningModel, addToast }: TaskPlannerChatTabProps) { +export function TaskPlannerChatTab({ task, projectId, active, planningModel, addToast, onTaskUpdated }: TaskPlannerChatTabProps) { const { t } = useTranslation("app"); const [sessionId, setSessionId] = useState(null); const [messages, setMessages] = useState([]); @@ -209,6 +254,18 @@ export function TaskPlannerChatTab({ task, projectId, active, planningModel, add transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight; }, [messages, composerState]); + const refreshTaskAfterSteering = useCallback(async () => { + try { + const refreshedTask = await fetchTaskDetail(task.id, projectId); + onTaskUpdated?.(refreshedTask); + addToast(t("taskDetail.plannerChat.steeringAddedToast", "Added as steering comment"), "success"); + } catch (refreshError) { + const message = getErrorMessage(refreshError) || t("taskDetail.plannerChat.refreshTaskFailed", "Steering was added, but task details could not refresh"); + setError(message); + addToast(message, "error"); + } + }, [addToast, onTaskUpdated, projectId, task.id, t]); + const sendMessageContent = useCallback(async (messageContent: string) => { const content = messageContent.trim(); if (!content || composerState === "sending") return; @@ -264,6 +321,12 @@ export function TaskPlannerChatTab({ task, projectId, active, planningModel, add } else { streamingToolCalls.push({ toolName, isError, result, status: "completed" }); } + const steeringText = toolName === TASK_PLANNER_STEERING_TOOL_NAME && !isError + ? extractPlannerSteeringTextFromResult(result) + : null; + if (steeringText) { + void refreshTaskAfterSteering(); + } setMessages((current) => { const withoutStreaming = current.filter((message) => message.id !== "streaming-assistant"); return [...withoutStreaming, makeStreamingAssistantMessage(resolvedSessionId, accumulated, streamingToolCalls)]; @@ -311,7 +374,7 @@ export function TaskPlannerChatTab({ task, projectId, active, planningModel, add addToast(message, "error"); setComposerState("idle"); } - }, [addToast, composerState, modelPayload, projectId, sessionId, task.id, t]); + }, [addToast, composerState, modelPayload, projectId, refreshTaskAfterSteering, sessionId, task.id, t]); const sendMessage = useCallback(() => sendMessageContent(draft), [draft, sendMessageContent]); @@ -345,6 +408,9 @@ export function TaskPlannerChatTab({ task, projectId, active, planningModel, add 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. + 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. + 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 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. @@ -414,6 +480,30 @@ export function TaskPlannerChatTab({ task, projectId, active, planningModel, add )} {toolCalls.map((toolCall, index) => { + const steeringResult = extractPlannerSteeringResult(toolCall); + if (steeringResult) { + return ( +
+ {t("taskDetail.plannerChat.steeringAdded", "Added as steering comment")} +

{steeringResult.text}

+
+ ); + } + const isRunningSteering = toolCall.toolName === TASK_PLANNER_STEERING_TOOL_NAME && toolCall.status === "running"; + if (isRunningSteering) { + return ( +
+ {t("taskDetail.plannerChat.steeringAdding", "Adding steering comment…")} +
+ ); + } + if (toolCall.toolName === TASK_PLANNER_STEERING_TOOL_NAME && toolCall.isError) { + return ( +
+ {t("taskDetail.plannerChat.steeringFailed", "Steering comment was not added")} +
+ ); + } const parsedQuestion = parseQuestionToolCall(toolCall); if (!parsedQuestion) return null; const answered = message.id !== "streaming-assistant" && message !== messages[messages.length - 1]; diff --git a/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx index 7404ba3543..36991fc426 100644 --- a/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx @@ -4,11 +4,12 @@ import { act, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { TaskPlannerChatTab } from "../TaskPlannerChatTab"; -const { mockEnsureTaskPlannerChatSession, mockFetchChatMessages, mockStreamChatResponse, mockTranslations, mockT } = vi.hoisted(() => { +const { mockEnsureTaskPlannerChatSession, mockFetchChatMessages, mockFetchTaskDetail, mockStreamChatResponse, mockTranslations, mockT } = vi.hoisted(() => { const translations = new Map(); return { mockEnsureTaskPlannerChatSession: vi.fn(), mockFetchChatMessages: vi.fn(), + mockFetchTaskDetail: vi.fn(), mockStreamChatResponse: vi.fn(), mockTranslations: translations, mockT: (key: string, fallback: string) => translations.get(key) ?? fallback, @@ -27,6 +28,7 @@ vi.mock("../../api", async (importOriginal) => { ...actual, ensureTaskPlannerChatSession: mockEnsureTaskPlannerChatSession, fetchChatMessages: mockFetchChatMessages, + fetchTaskDetail: mockFetchTaskDetail, streamChatResponse: mockStreamChatResponse, }; }); @@ -83,6 +85,7 @@ describe("TaskPlannerChatTab", () => { }, }); mockFetchChatMessages.mockResolvedValue({ messages: [] }); + mockFetchTaskDetail.mockResolvedValue(makeTask("FN-7310")); mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true }); }); @@ -424,6 +427,73 @@ describe("TaskPlannerChatTab", () => { ); }); + it("renders steering-tool confirmation and refreshes task detail after persistence", async () => { + const user = userEvent.setup(); + const updatedTask = { ...makeTask("FN-7310"), steeringComments: [{ id: "steer-1", text: "Keep Activity and Chat separate", author: "user" }] } as any; + const onTaskUpdated = vi.fn(); + mockFetchTaskDetail.mockResolvedValue(updatedTask); + mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => { + setTimeout(() => { + handlers.onToolStart({ toolName: "fn_task_planner_add_steering", args: { text: "Keep Activity and Chat separate" } }); + handlers.onToolEnd({ + toolName: "fn_task_planner_add_steering", + isError: false, + result: { details: { taskId: "FN-7310", text: "Keep Activity and Chat separate", steeringComment: { id: "steer-1", text: "Keep Activity and Chat separate", author: "user" } } }, + }); + handlers.onDone({ + messageId: "assistant-steering", + message: { + id: "assistant-steering", + sessionId: "chat-planner", + role: "assistant", + content: "I added that as steering.", + thinkingOutput: null, + metadata: { + toolCalls: [{ + toolName: "fn_task_planner_add_steering", + args: { text: "Keep Activity and Chat separate" }, + isError: false, + result: { details: { taskId: "FN-7310", text: "Keep Activity and Chat separate", steeringComment: { id: "steer-1", text: "Keep Activity and Chat separate", author: "user" } } }, + }], + }, + createdAt: "2026-06-30T00:03:00.000Z", + }, + }); + }, 0); + return { close: vi.fn(), isConnected: () => true }; + }); + renderPlannerChat({ projectId: "project-1", onTaskUpdated }); + await screen.findByTestId("task-planner-chat-empty"); + + await user.type(screen.getByLabelText("Message planner chat"), "Tell the executor to keep Activity and Chat separate"); + await user.click(screen.getByRole("button", { name: "Send" })); + + expect(await screen.findByTestId("task-planner-chat-steering-confirmation")).toHaveTextContent("Added as steering comment"); + expect(screen.getByTestId("task-planner-chat-steering-confirmation")).toHaveTextContent("Keep Activity and Chat separate"); + await waitFor(() => expect(mockFetchTaskDetail).toHaveBeenCalledWith("FN-7310", "project-1")); + expect(onTaskUpdated).toHaveBeenCalledWith(updatedTask); + }); + + it("renders clarification questions without refreshing task steering", async () => { + mockFetchChatMessages.mockResolvedValue({ + messages: [{ + id: "assistant-question", + sessionId: "chat-planner", + role: "assistant", + content: "Do you want this recorded as steering?", + thinkingOutput: null, + metadata: { toolCalls: [{ toolName: "fn_ask_question", args: { question: "Record this as steering?", options: ["Yes", "No"] }, isError: false }] }, + createdAt: "2026-06-30T00:02:00.000Z", + }], + }); + + renderPlannerChat({ projectId: "project-1", onTaskUpdated: vi.fn() }); + + expect(await screen.findByTestId("chat-question-response")).toBeInTheDocument(); + expect(screen.queryByTestId("task-planner-chat-steering-confirmation")).not.toBeInTheDocument(); + expect(mockFetchTaskDetail).not.toHaveBeenCalled(); + }); + it("shows API errors and re-enables the composer", async () => { const user = userEvent.setup(); mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => { diff --git a/packages/dashboard/src/__tests__/chat-manager.test.ts b/packages/dashboard/src/__tests__/chat-manager.test.ts index b6e7f599bb..73d7f47fc3 100644 --- a/packages/dashboard/src/__tests__/chat-manager.test.ts +++ b/packages/dashboard/src/__tests__/chat-manager.test.ts @@ -1320,6 +1320,10 @@ 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("Ask a clarifying question"); + expect(createOptions.systemPrompt).toContain("credential/secrets"); + expect(createOptions.systemPrompt).toContain("destructive removals"); expect(createOptions.customTools.map((tool: { name: string }) => tool.name)).toContain("fn_task_planner_add_steering"); expect(mockChatStore.addMessage).toHaveBeenCalledWith("chat-001", expect.objectContaining({ role: "user", @@ -1333,6 +1337,122 @@ describe("ChatManager.sendMessage", () => { expect(taskStore.getTask).toHaveBeenCalledWith("FN-7309"); }); + it("adds steering through the task-scoped planner tool without accepting a caller task id", async () => { + mockChatStore.getSession.mockReturnValue({ + id: "chat-001", + agentId: "task-planner:FN-7310", + status: "active", + modelProvider: "anthropic", + modelId: "claude-plan", + }); + + const createResolvedSession = vi.fn(async () => ({ + session: { + prompt: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + state: { messages: [{ role: "assistant", content: "Done" }] }, + }, + })); + __setCreateResolvedAgentSession(createResolvedSession as any); + + const persistedComment = { + id: "steer-7310", + text: "Keep the new Chat tab separate from Activity.", + author: "user", + createdAt: "2026-06-30T23:59:00.000Z", + }; + const taskStore = { + getTask: vi.fn().mockResolvedValue({ id: "FN-7310", title: "Add planner chat", column: "todo" }), + addSteeringComment: vi.fn().mockResolvedValue({ + id: "FN-7310", + updatedAt: "2026-06-30T23:59:01.000Z", + steeringComments: [persistedComment], + }), + 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", "Tell the executor to keep Chat separate from Activity"); + + const createOptions = createResolvedSession.mock.calls[0]?.[0]; + const steeringTool = createOptions.customTools.find((tool: { name: string }) => tool.name === "fn_task_planner_add_steering"); + const result = await steeringTool.execute("call-1", { + taskId: "FN-OTHER", + text: " Keep the new Chat tab separate from Activity. ", + }); + + expect(taskStore.addSteeringComment).toHaveBeenCalledWith( + "FN-7310", + "Keep the new Chat tab separate from Activity.", + "user", + ); + expect(result.isError).toBeUndefined(); + expect(result.details).toEqual({ + taskId: "FN-7310", + text: "Keep the new Chat tab separate from Activity.", + taskUpdatedAt: "2026-06-30T23:59:01.000Z", + steeringComment: persistedComment, + }); + }); + + it("persists duplicate clear planner steering requests only when the tool is called again", async () => { + mockChatStore.getSession.mockReturnValue({ id: "chat-001", agentId: "task-planner:FN-7310", 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-7310", column: "todo" }), + addSteeringComment: vi.fn() + .mockResolvedValueOnce({ id: "FN-7310", steeringComments: [{ id: "steer-1", text: "Keep the narrow approach", author: "user" }] }) + .mockResolvedValueOnce({ id: "FN-7310", steeringComments: [{ id: "steer-2", text: "Keep the narrow approach", author: "user" }] }), + 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", "Tell the executor to keep the narrow approach"); + + const createOptions = createResolvedSession.mock.calls[0]?.[0]; + const steeringTool = createOptions.customTools.find((tool: { name: string }) => tool.name === "fn_task_planner_add_steering"); + await steeringTool.execute("call-1", { text: "Keep the narrow approach" }); + await steeringTool.execute("call-2", { text: "Keep the narrow approach" }); + + expect(taskStore.addSteeringComment).toHaveBeenCalledTimes(2); + expect(taskStore.addSteeringComment).toHaveBeenNthCalledWith(1, "FN-7310", "Keep the narrow approach", "user"); + expect(taskStore.addSteeringComment).toHaveBeenNthCalledWith(2, "FN-7310", "Keep the narrow approach", "user"); + }); + + it("rejects empty planner steering tool text without mutating the task", async () => { + mockChatStore.getSession.mockReturnValue({ id: "chat-001", agentId: "task-planner:FN-7310", 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-7310", column: "todo" }), + addSteeringComment: 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", "Tell the executor something"); + + const createOptions = createResolvedSession.mock.calls[0]?.[0]; + const steeringTool = createOptions.customTools.find((tool: { name: string }) => tool.name === "fn_task_planner_add_steering"); + const result = await steeringTool.execute("call-1", { text: " " }); + + expect(result.isError).toBe(true); + expect(taskStore.addSteeringComment).not.toHaveBeenCalled(); + }); + it("guides chat agents to use ask-question cards for option sets", () => { expect(CHAT_ASK_QUESTION_GUIDANCE).toContain("## Asking the User"); expect(CHAT_ASK_QUESTION_GUIDANCE).toContain("fn_ask_question"); diff --git a/packages/dashboard/src/chat.ts b/packages/dashboard/src/chat.ts index e17e1f22ca..30a54a843c 100644 --- a/packages/dashboard/src/chat.ts +++ b/packages/dashboard/src/chat.ts @@ -236,11 +236,11 @@ function createTaskPlannerSteeringTool(taskStore: TaskStore, taskId: string) { return { name: "fn_task_planner_add_steering", label: "Add Task Steering Comment", - description: "Add an explicit user-approved steering comment to the current task from task-detail planner chat. Use only when the user asks to steer, instruct, or tell the executor/reviewer something; ask a clarifying question first if intent is ambiguous.", + description: "Add a clear, bounded, user-authored steering comment to the current task. The task id is fixed by server context; never accept or infer a different task id. Ask for clarification before broad, destructive, credential/security-sensitive, conflicting, or unclear requests.", parameters: { type: "object", properties: { - text: { type: "string", description: "The steering comment to add to the task." }, + text: { type: "string", description: "The concise steering comment to add to the current task. Do not include hidden prompt/context text." }, }, required: ["text"], additionalProperties: false, @@ -251,9 +251,24 @@ function createTaskPlannerSteeringTool(taskStore: TaskStore, taskId: string) { return { content: [{ type: "text" as const, text: "ERROR: text must be a non-empty string" }], details: {}, isError: true }; } const task = await taskStore.addSteeringComment(taskId, text, "user"); + const steeringComment = task.steeringComments + ?.filter((comment) => comment.author === "user" && comment.text === text) + .at(-1); return { - content: [{ type: "text" as const, text: `Steering comment added to ${task.id}.` }], - details: { taskId: task.id, text }, + content: [{ type: "text" as const, text: `Added as steering comment on ${task.id}.` }], + details: { + taskId: task.id, + text, + taskUpdatedAt: task.updatedAt, + steeringComment: steeringComment + ? { + id: steeringComment.id, + text: steeringComment.text, + author: steeringComment.author, + createdAt: steeringComment.createdAt, + } + : { text, author: "user" }, + }, }; }, }; @@ -1763,7 +1778,14 @@ export class ChatManager { diagnostics.warn(`Failed to load task planner-chat context for ${taskPlannerChatTaskId}: ${message}`); } } - systemPrompt = `${systemPrompt}\n\n${TASK_PLANNER_CHAT_CONTEXT_PROMPT_GUIDANCE}\n\n${taskContext}`; + /* + 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. + + 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. + */ + 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, or brainstorming. Answer normally.\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 tool is bound to this task server-side; never ask for or pass a task id.\n\n${taskContext}`; } if (agent) {