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:
7
.changeset/fn-7313-task-chat-steering.md
Normal file
7
.changeset/fn-7313-task-chat-steering.md
Normal 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.
|
||||
@@ -3311,6 +3311,7 @@ export function TaskDetailContent({
|
||||
active={activeTab === "planner-chat"}
|
||||
planningModel={resolveEffectivePlanning(workingTask, agentLogEntries, settings)}
|
||||
addToast={addToast}
|
||||
onTaskUpdated={onTaskUpdated}
|
||||
/>
|
||||
</div>
|
||||
) : activeTab === "chat" ? (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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[] {
|
||||
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<string | null>(null);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
@@ -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
|
||||
</div>
|
||||
)}
|
||||
{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);
|
||||
if (!parsedQuestion) return null;
|
||||
const answered = message.id !== "streaming-assistant" && message !== messages[messages.length - 1];
|
||||
|
||||
@@ -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<string, string>();
|
||||
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) => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user