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"}
|
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" ? (
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user