FN-7313: add guarded planner chat steering

Task-detail planner chat now turns only clear task change requests into persisted steering comments while keeping ambiguous requests in clarification flow.

- Add a scoped planner-chat steering tool with task-bound persistence and richer result details.
- Refresh task details and show steering confirmation, pending, and error states in the planner chat transcript.
- Expand planner chat prompt guidance and tests for clear steering, ambiguous clarification, and ordinary status questions.
- Add a published Fusion changeset for the new task chat steering behavior.

Files changed:
 .changeset/fn-7313-task-chat-steering.md           |   7 ++
 .../dashboard/app/components/TaskDetailModal.tsx   |   1 +
 .../app/components/TaskPlannerChatTab.css          |  28 +++++
 .../app/components/TaskPlannerChatTab.tsx          |  96 ++++++++++++++++-
 .../__tests__/TaskPlannerChatTab.test.tsx          |  72 ++++++++++++-
 .../dashboard/src/__tests__/chat-manager.test.ts   | 120 +++++++++++++++++++++
 packages/dashboard/src/chat.ts                     |  32 +++++-
 7 files changed, 347 insertions(+), 9 deletions(-)

Fusion-Task-Id: FN-7313
Fusion-Task-Lineage: 29da54cc-c379-4fe5-8419-be3acc712495
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-30 19:15:11 -07:00
parent ddfd841b53
commit cb0d38a0c6
7 changed files with 347 additions and 9 deletions

View File

@@ -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.

View File

@@ -3311,6 +3311,7 @@ export function TaskDetailContent({
active={activeTab === "planner-chat"} active={activeTab === "planner-chat"}
planningModel={resolveEffectivePlanning(workingTask, agentLogEntries, settings)} planningModel={resolveEffectivePlanning(workingTask, agentLogEntries, settings)}
addToast={addToast} addToast={addToast}
onTaskUpdated={onTaskUpdated}
/> />
</div> </div>
) : activeTab === "chat" ? ( ) : activeTab === "chat" ? (

View File

@@ -152,6 +152,34 @@
margin-bottom: 0; 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 { .task-planner-chat-composer {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;

View File

@@ -7,7 +7,7 @@ import { Loader2, Send } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { ToastType } from "../hooks/useToast"; import type { ToastType } from "../hooks/useToast";
import type { ToolCallInfo } from "../hooks/chatTypes"; 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 { parseQuestionToolCall } from "../utils/parseQuestionToolCall";
import { markdownComponents } from "./AgentLogViewer"; import { markdownComponents } from "./AgentLogViewer";
import { ChatQuestionResponse } from "./ChatQuestionResponse"; import { ChatQuestionResponse } from "./ChatQuestionResponse";
@@ -19,6 +19,7 @@ interface TaskPlannerChatTabProps {
active: boolean; active: boolean;
planningModel: ResolvedModelSelection; planningModel: ResolvedModelSelection;
addToast: (msg: string, type?: ToastType) => void; addToast: (msg: string, type?: ToastType) => void;
onTaskUpdated?: (task: Task) => void;
} }
type ComposerState = "idle" | "sending"; 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<string, unknown> | null {
return value && typeof value === "object" ? value as Record<string, unknown> : 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[] { function extractToolCalls(message: ChatMessage): ToolCallInfo[] {
const rawToolCalls = message.metadata?.toolCalls; const rawToolCalls = message.metadata?.toolCalls;
if (!Array.isArray(rawToolCalls)) return []; if (!Array.isArray(rawToolCalls)) return [];
@@ -125,7 +170,7 @@ function extractToolCalls(message: ChatMessage): ToolCallInfo[] {
.filter((toolCall): toolCall is ToolCallInfo => toolCall !== null); .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 { t } = useTranslation("app");
const [sessionId, setSessionId] = useState<string | null>(null); const [sessionId, setSessionId] = useState<string | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]); const [messages, setMessages] = useState<ChatMessage[]>([]);
@@ -209,6 +254,18 @@ export function TaskPlannerChatTab({ task, projectId, active, planningModel, add
transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight; transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight;
}, [messages, composerState]); }, [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 sendMessageContent = useCallback(async (messageContent: string) => {
const content = messageContent.trim(); const content = messageContent.trim();
if (!content || composerState === "sending") return; if (!content || composerState === "sending") return;
@@ -264,6 +321,12 @@ export function TaskPlannerChatTab({ task, projectId, active, planningModel, add
} else { } else {
streamingToolCalls.push({ toolName, isError, result, status: "completed" }); 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) => { setMessages((current) => {
const withoutStreaming = current.filter((message) => message.id !== "streaming-assistant"); const withoutStreaming = current.filter((message) => message.id !== "streaming-assistant");
return [...withoutStreaming, makeStreamingAssistantMessage(resolvedSessionId, accumulated, streamingToolCalls)]; return [...withoutStreaming, makeStreamingAssistantMessage(resolvedSessionId, accumulated, streamingToolCalls)];
@@ -311,7 +374,7 @@ export function TaskPlannerChatTab({ task, projectId, active, planningModel, add
addToast(message, "error"); addToast(message, "error");
setComposerState("idle"); 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]); 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: 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 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: 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. 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
</div> </div>
)} )}
{toolCalls.map((toolCall, index) => { {toolCalls.map((toolCall, index) => {
const steeringResult = extractPlannerSteeringResult(toolCall);
if (steeringResult) {
return (
<div key={`${toolCall.toolName}-${index}`} className="task-planner-chat-steering-confirmation" data-testid="task-planner-chat-steering-confirmation">
<strong>{t("taskDetail.plannerChat.steeringAdded", "Added as steering comment")}</strong>
<p>{steeringResult.text}</p>
</div>
);
}
const isRunningSteering = toolCall.toolName === TASK_PLANNER_STEERING_TOOL_NAME && toolCall.status === "running";
if (isRunningSteering) {
return (
<div key={`${toolCall.toolName}-${index}`} className="task-planner-chat-steering-confirmation task-planner-chat-steering-confirmation--pending" data-testid="task-planner-chat-steering-pending">
<strong>{t("taskDetail.plannerChat.steeringAdding", "Adding steering comment…")}</strong>
</div>
);
}
if (toolCall.toolName === TASK_PLANNER_STEERING_TOOL_NAME && toolCall.isError) {
return (
<div key={`${toolCall.toolName}-${index}`} className="task-planner-chat-steering-confirmation task-planner-chat-steering-confirmation--error" role="alert" data-testid="task-planner-chat-steering-error">
<strong>{t("taskDetail.plannerChat.steeringFailed", "Steering comment was not added")}</strong>
</div>
);
}
const parsedQuestion = parseQuestionToolCall(toolCall); const parsedQuestion = parseQuestionToolCall(toolCall);
if (!parsedQuestion) return null; if (!parsedQuestion) return null;
const answered = message.id !== "streaming-assistant" && message !== messages[messages.length - 1]; const answered = message.id !== "streaming-assistant" && message !== messages[messages.length - 1];

View File

@@ -4,11 +4,12 @@ import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { TaskPlannerChatTab } from "../TaskPlannerChatTab"; 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<string, string>(); const translations = new Map<string, string>();
return { return {
mockEnsureTaskPlannerChatSession: vi.fn(), mockEnsureTaskPlannerChatSession: vi.fn(),
mockFetchChatMessages: vi.fn(), mockFetchChatMessages: vi.fn(),
mockFetchTaskDetail: vi.fn(),
mockStreamChatResponse: vi.fn(), mockStreamChatResponse: vi.fn(),
mockTranslations: translations, mockTranslations: translations,
mockT: (key: string, fallback: string) => translations.get(key) ?? fallback, mockT: (key: string, fallback: string) => translations.get(key) ?? fallback,
@@ -27,6 +28,7 @@ vi.mock("../../api", async (importOriginal) => {
...actual, ...actual,
ensureTaskPlannerChatSession: mockEnsureTaskPlannerChatSession, ensureTaskPlannerChatSession: mockEnsureTaskPlannerChatSession,
fetchChatMessages: mockFetchChatMessages, fetchChatMessages: mockFetchChatMessages,
fetchTaskDetail: mockFetchTaskDetail,
streamChatResponse: mockStreamChatResponse, streamChatResponse: mockStreamChatResponse,
}; };
}); });
@@ -83,6 +85,7 @@ describe("TaskPlannerChatTab", () => {
}, },
}); });
mockFetchChatMessages.mockResolvedValue({ messages: [] }); mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockFetchTaskDetail.mockResolvedValue(makeTask("FN-7310"));
mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true }); 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 () => { it("shows API errors and re-enables the composer", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => { mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {

View File

@@ -1320,6 +1320,10 @@ describe("ChatManager.sendMessage", () => {
expect(createOptions.systemPrompt).toContain("Polish: in-progress"); expect(createOptions.systemPrompt).toContain("Polish: in-progress");
expect(createOptions.systemPrompt).toContain("Activity transcript loaded"); expect(createOptions.systemPrompt).toContain("Activity transcript loaded");
expect(createOptions.systemPrompt).toContain("fn_ask_question"); 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(createOptions.customTools.map((tool: { name: string }) => tool.name)).toContain("fn_task_planner_add_steering");
expect(mockChatStore.addMessage).toHaveBeenCalledWith("chat-001", expect.objectContaining({ expect(mockChatStore.addMessage).toHaveBeenCalledWith("chat-001", expect.objectContaining({
role: "user", role: "user",
@@ -1333,6 +1337,122 @@ describe("ChatManager.sendMessage", () => {
expect(taskStore.getTask).toHaveBeenCalledWith("FN-7309"); 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", () => { 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("## Asking the User");
expect(CHAT_ASK_QUESTION_GUIDANCE).toContain("fn_ask_question"); expect(CHAT_ASK_QUESTION_GUIDANCE).toContain("fn_ask_question");

View File

@@ -236,11 +236,11 @@ function createTaskPlannerSteeringTool(taskStore: TaskStore, taskId: string) {
return { return {
name: "fn_task_planner_add_steering", name: "fn_task_planner_add_steering",
label: "Add Task Steering Comment", 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: { parameters: {
type: "object", type: "object",
properties: { 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"], required: ["text"],
additionalProperties: false, 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 }; 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 task = await taskStore.addSteeringComment(taskId, text, "user");
const steeringComment = task.steeringComments
?.filter((comment) => comment.author === "user" && comment.text === text)
.at(-1);
return { return {
content: [{ type: "text" as const, text: `Steering comment added to ${task.id}.` }], content: [{ type: "text" as const, text: `Added as steering comment on ${task.id}.` }],
details: { taskId: task.id, text }, 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}`); 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) { if (agent) {