FN-7310: add task planner chat tab
Add a dedicated task-detail Chat surface for planner-model conversations separate from Activity steering. - Add task-scoped planner chat session creation and routing with planning-model overrides. - Render a new top-level Chat tab next to Activity, including streaming responses, tool-call cards, and retry/error states. - Cover planner chat tab ordering, session reuse, route validation, manager dispatch, and dashboard documentation. Files changed: .changeset/fn-7310-planner-chat.md | 7 + docs/dashboard-guide.md | 3 +- packages/dashboard/app/api/legacy.ts | 35 +++ .../dashboard/app/components/TaskDetailModal.tsx | 40 ++- .../app/components/TaskPlannerChatTab.css | 149 ++++++++++ .../app/components/TaskPlannerChatTab.tsx | 322 +++++++++++++++++++++ ...etailModal.responsive-and-dependencies.test.tsx | 13 +- .../__tests__/TaskDetailModal.test-helpers.ts | 3 + .../components/__tests__/TaskDetailModal.test.tsx | 58 ++++ .../__tests__/TaskPlannerChatTab.test.tsx | 193 ++++++++++++ .../dashboard/src/__tests__/chat-manager.test.ts | 65 +++++ .../dashboard/src/__tests__/chat-routes.test.ts | 106 +++++++ packages/dashboard/src/chat.ts | 132 ++++++++- .../dashboard/src/routes/register-chat-routes.ts | 68 +++++ 14 files changed, 1181 insertions(+), 13 deletions(-) Fusion-Task-Id: FN-7310 Fusion-Task-Lineage: a276c356-599e-4495-9879-472d157d95e5 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7310-planner-chat.md
Normal file
7
.changeset/fn-7310-planner-chat.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Add a task-detail Chat tab for planner-model conversations.
|
||||
category: feature
|
||||
dev: Adds task-scoped planner chat session routing and a dedicated TaskPlannerChatTab separate from Activity steering.
|
||||
@@ -1042,6 +1042,7 @@ Inspect task definition, logs, review feedback, comments, artifacts, workflow ou
|
||||
- Editable tasks with descriptions show **Summarize as title** beside the read-mode title; it asks AI to generate a concise title from the description and saves it without opening the edit form.
|
||||
- The **Summary** tab appears first for `done` tasks and is their default landing tab. It shows the recorded completion summary, changed-file/merge stats when available, completed steps, workflow results, retry counts, and a token usage & cost section broken down by model from the already-loaded task detail; unpriced models show cost as unavailable rather than `$0`. Non-`done` tasks still open on **Activity** by default.
|
||||
- The **Activity → Current** segment includes an expand/collapse control that lets the transcript and composer fill the task-detail modal, then restores the normal header, tabs, and action footer when collapsed.
|
||||
- The top-level **Chat** tab appears immediately after **Activity** and starts a task-scoped planner-model conversation. It uses the task's effective planning model, persists messages in a resumable chat session, shows starter prompts for common planning questions, can render structured planner questions, and converts only explicit operator steering intent through the scoped steering tool. Activity remains the primary operational transcript/feed/raw-log surface.
|
||||
- Task-detail Activity steering comments are persisted as user comments/steering guidance and surfaced to every relevant agent lane: live executor sessions receive steering injection, while planner, reviewer (spec/plan/code), and merger agents (standard and clean-room AI merge/review) receive the latest user comments in their next prompt/pass.
|
||||
- The priority chip in task metadata is an inline picker: you can change priority directly without entering full edit mode.
|
||||
- Execution mode has a read-mode inline lightning-bolt toggle for Fast mode on/off without opening the full edit form.
|
||||
@@ -1117,7 +1118,7 @@ Recommended workflow: ordinary chains stay as `Blocks N` so noise stays low, hig
|
||||
|
||||
### Activity → Raw Logs view
|
||||
|
||||
<!-- FNXC:TaskDetailActivity 2026-06-30-21:55: Activity Current is the explicit operational steering-comment entry surface. Feed and Raw Logs remain read-only Activity segments, and the future planner-model Chat tab is intentionally separate. -->
|
||||
<!-- FNXC:TaskDetailPlannerChat 2026-06-30-22:30: Activity Current is the explicit operational steering-comment entry surface. Feed and Raw Logs remain read-only Activity segments, and the top-level Chat tab is intentionally separate planner-model conversation rather than steering. -->
|
||||
The **Activity** tab is the first task-detail tab and presents a segmented control for **Current**, **Feed**, and **Raw Logs**. Current contains the live, chat-styled transcript of task agent output. Consecutive entries are grouped by role and labeled as Planner, Executor, Reviewer, or Merger; legacy log rows without an agent role use the neutral Agent fallback. Agent group headers and user message headers show a small muted relative timestamp (for example, “just now”, “1m ago”, or “2h ago”) based on the transcript timestamp, while agent group metadata still includes the entry count. Consecutive text/message chunks inside a role group render as one continuous markdown bubble, while consecutive tool/tool-result/tool-error rows collapse into one expandable, compact tool-call summary that stays collapsed by default and mirrors regular Chat's dense treatment; the summary stays single-line/ellipsis-friendly on desktop and mobile, counts tool invocations, lists deduped tool names with overflow, and shows an error count when failures are present, while the expanded body pairs each call with its result or error in dense entry cards. Thinking entries render in a collapsible block that starts expanded. The transcript opens at the latest output whenever the tab loads or becomes active, then follows new live output when you are already near the bottom while preserving your scroll position when you review older messages. When older task-agent history exists, scrolling to the top or selecting **Load previous messages** prepends earlier transcript entries without moving the message you were reading. When you scroll away from the bottom of a populated transcript, a sticky **Latest** button appears inside the transcript so you can jump back to the newest message and resume live follow. For non-`done` tasks, the **Steering comment** composer sends guidance through the same steering path used by comments, including active planning/triage, `in-progress`, and `in-review` sessions, plus live CLI-agent sessions reported by the session bridge; an `in-review` Activity Current message or Comments-tab task comment re-engages an executor unless an open PR blocks moving the task back, and other messages are still saved as queued guidance when no session is currently live. Feed and Raw Logs do not show a steering composer. On a `done` task, the composer switches to **Refinement request** copy; sending starts a refinement task using the typed text as feedback and shows a success toast with the new task ID, while the current task detail modal remains on the completed task. The task-detail Activity Current segment keeps the composer pinned and visible on mobile and desktop while the transcript scrolls internally; its textarea placeholder reads “Steer the currently executing agent” for steering mode and switches to refinement copy for completed tasks, with the same inline, icon-only send affordance to the right of the input at every breakpoint. In the composer, plain **Enter** sends, **Shift+Enter** inserts a newline, and **Cmd/Ctrl+Enter** remains a supported send shortcut.
|
||||
|
||||
The **Raw Logs** segment is designed for debugging long-running and tool-heavy sessions, while legacy links that requested the former top-level Logs tab land on Activity → Feed:
|
||||
|
||||
@@ -9815,6 +9815,11 @@ export interface ChatMessageListResponse {
|
||||
messages: ChatMessage[];
|
||||
}
|
||||
|
||||
export interface TaskPlannerChatSessionInput {
|
||||
modelProvider?: string;
|
||||
modelId?: string;
|
||||
}
|
||||
|
||||
export interface ChatRoomListResponse {
|
||||
rooms: ChatRoom[];
|
||||
}
|
||||
@@ -9900,6 +9905,36 @@ export function fetchChatSession(id: string, projectId?: string): Promise<ChatSe
|
||||
return api<ChatSessionResponse>(withProjectId(`/chat/sessions/${encodeURIComponent(id)}`, projectId));
|
||||
}
|
||||
|
||||
export function ensureTaskPlannerChatSession(
|
||||
taskId: string,
|
||||
input: TaskPlannerChatSessionInput = {},
|
||||
projectId?: string,
|
||||
): Promise<ChatSessionResponse> {
|
||||
const normalizedTaskId = taskId.trim();
|
||||
if (!normalizedTaskId) {
|
||||
throw new Error("taskId is required");
|
||||
}
|
||||
const normalizedProvider = input.modelProvider?.trim();
|
||||
const normalizedModelId = input.modelId?.trim();
|
||||
if ((normalizedProvider && !normalizedModelId) || (!normalizedProvider && normalizedModelId)) {
|
||||
throw new Error("Both modelProvider and modelId must be provided together, or neither should be provided");
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:TaskDetailPlannerChat 2026-06-30-22:30:
|
||||
Task planner chat uses a task-scoped session seam instead of the generic agent-chat creator so it can bind the conversation to the task and planning model without requiring a real executor/reviewer agent or turning the message into steering.
|
||||
*/
|
||||
return api<ChatSessionResponse>(
|
||||
withProjectId(`/chat/task-planner/${encodeURIComponent(normalizedTaskId)}/session`, projectId),
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
...(normalizedProvider && normalizedModelId ? { modelProvider: normalizedProvider, modelId: normalizedModelId } : {}),
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Update a chat session (title, status) */
|
||||
export function updateChatSession(
|
||||
id: string,
|
||||
|
||||
@@ -33,6 +33,7 @@ import { PrPanel } from "./PrPanel";
|
||||
import { PrCreateModal } from "./PrCreateModal";
|
||||
import { TaskComments } from "./TaskComments";
|
||||
import { TaskChatTab } from "./TaskChatTab";
|
||||
import { TaskPlannerChatTab } from "./TaskPlannerChatTab";
|
||||
import { TaskReviewTab } from "./TaskReviewTab";
|
||||
import { MergeDetails } from "./MergeDetails";
|
||||
import { TaskChangesTab } from "./TaskChangesTab";
|
||||
@@ -191,18 +192,21 @@ function formatDurationCompact(ageMs: number): string {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
type TabId = "summary" | "definition" | "chat" | "logs" | "changes" | "review" | "pr" | "comments" | "model" | "workflow" | "documents" | "stats" | "routing" | "retries" | "terminal" | `plugin-${string}`;
|
||||
type TabId = "summary" | "definition" | "chat" | "planner-chat" | "logs" | "changes" | "review" | "pr" | "comments" | "model" | "workflow" | "documents" | "stats" | "routing" | "retries" | "terminal" | `plugin-${string}`;
|
||||
type ActivitySegment = "current" | "feed" | "raw-logs";
|
||||
|
||||
/*
|
||||
FNXC:TaskDetailActivityTab 2026-06-30-00:00:
|
||||
The existing task activity/steering surface keeps the stable internal `chat` tab id for deep-link/plugin compatibility, but its top-level user-facing label is Activity. Activity is the implicit default for active task columns; done tasks keep Summary as their omitted-initial-tab landing surface so completed work still opens on the completion report while Activity remains first in tab order.
|
||||
|
||||
FNXC:TaskDetailPlannerChat 2026-06-30-22:30:
|
||||
Task detail now separates Activity from planner-model Chat. `chat` remains the legacy Activity id for old links and Activity → Current/Feed/Raw Logs/steering, while `planner-chat` is the new top-level Chat tab for task-aware planning conversation and must render immediately after Activity.
|
||||
|
||||
FNXC:TaskDetailActivity 2026-06-30-15:50:
|
||||
Only an omitted initial tab is the implicit default. Preserve explicit `initialTab="chat"` requests from plugins and task-detail entrypoints so existing links continue to open Activity → Current. Legacy `initialTab="logs"` now routes to Activity → Feed, and Raw Logs remains an Activity segment, because the legacy top-level Logs tab must not return while the later planner-model Chat tab remains out of scope.
|
||||
Only an omitted initial tab is the implicit default. Preserve explicit `initialTab="chat"` requests from plugins and task-detail entrypoints so existing links continue to open Activity → Current. Legacy `initialTab="logs"` now routes to Activity → Feed, and Raw Logs remains an Activity segment.
|
||||
|
||||
FNXC:TaskDetailActivity 2026-06-30-21:55:
|
||||
The first Activity segment keeps the stable Current label for legacy segment tests and links, but its embedded composer labels the operational steering-comment affordance explicitly. Do not reuse this segment as the future planner-model Chat conversation; that belongs to a later top-level tab.
|
||||
The first Activity segment keeps the stable Current label for legacy segment tests and links, but its embedded composer labels the operational steering-comment affordance explicitly. Do not reuse this segment as planner-model Chat conversation; that belongs to the `planner-chat` top-level tab.
|
||||
*/
|
||||
function resolveDefaultTab(initialTab: TabId | undefined, column: ColumnId): TabId {
|
||||
if (initialTab === "retries") {
|
||||
@@ -512,8 +516,8 @@ export function TaskDetailContent({
|
||||
autoMergeEnabled: autoMergeEnabledProp,
|
||||
onOpenWorkflowEditor,
|
||||
/**
|
||||
* FNXC:TaskDetailActivityTab 2026-06-30-00:00:
|
||||
* The Activity tab is still addressed as `chat` internally so existing callers and deep links do not break while the label/order changes ahead of the future planner Chat tab.
|
||||
* FNXC:TaskDetailPlannerChat 2026-06-30-22:30:
|
||||
* The Activity tab is still addressed as `chat` internally so existing callers and deep links do not break; the visible Chat tab uses `planner-chat` for planner-model conversation.
|
||||
*/
|
||||
initialTab,
|
||||
mobileHeaderMode = "close",
|
||||
@@ -3149,8 +3153,8 @@ export function TaskDetailContent({
|
||||
<>
|
||||
<div className="detail-tabs">
|
||||
{/*
|
||||
FNXC:TaskDetailActivityTab 2026-06-30-00:00:
|
||||
The existing task activity/steering surface is now labelled Activity and always renders first. Keep the `chat` tab id because a later subtask will add the separate planner-model Chat surface; this rename must not break existing `initialTab="chat"` callers.
|
||||
FNXC:TaskDetailPlannerChat 2026-06-30-22:30:
|
||||
The existing task activity/steering surface is labelled Activity and always renders first with the legacy `chat` tab id. The adjacent `planner-chat` tab is the separate planner-model Chat destination, so `initialTab="chat"` remains Activity while visible Chat opens task-aware planning conversation.
|
||||
*/}
|
||||
<button
|
||||
className={`detail-tab${activeTab === "chat" ? " detail-tab-active" : ""}`}
|
||||
@@ -3158,6 +3162,12 @@ export function TaskDetailContent({
|
||||
>
|
||||
{t("taskDetail.tabs.activity", "Activity")}
|
||||
</button>
|
||||
<button
|
||||
className={`detail-tab${activeTab === "planner-chat" ? " detail-tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("planner-chat")}
|
||||
>
|
||||
{t("taskDetail.tabs.chat", "Chat")}
|
||||
</button>
|
||||
{task.column === "done" && (
|
||||
<button
|
||||
className={`detail-tab${activeTab === "summary" ? " detail-tab-active" : ""}`}
|
||||
@@ -3288,14 +3298,24 @@ export function TaskDetailContent({
|
||||
<div className="detail-section detail-section--summary">
|
||||
<TaskSummaryTab task={workingTask} pricingOverrides={globalSettings?.modelPricingOverrides} />
|
||||
</div>
|
||||
) : activeTab === "planner-chat" ? (
|
||||
<div className="detail-section detail-section--planner-chat">
|
||||
<TaskPlannerChatTab
|
||||
task={workingTask}
|
||||
projectId={projectId}
|
||||
active={activeTab === "planner-chat"}
|
||||
planningModel={resolveEffectivePlanning(workingTask, agentLogEntries, settings)}
|
||||
addToast={addToast}
|
||||
/>
|
||||
</div>
|
||||
) : activeTab === "chat" ? (
|
||||
<div className={`detail-section detail-section--activity${activitySegment === "current" ? " detail-section--chat" : ""}${activitySegment === "raw-logs" ? " detail-section--agent-log" : ""}`}>
|
||||
{/*
|
||||
FNXC:TaskDetailActivity 2026-06-30-15:50:
|
||||
Activity owns the existing steering/current view, Feed, and Raw Logs inside one segmented control. The later planner-model Chat tab is intentionally out of scope, so the stable top-level tab id remains `chat`, legacy `logs` callers land on Feed, and Raw Logs is the only segment that enables raw agent-log fetching.
|
||||
FNXC:TaskDetailPlannerChat 2026-06-30-22:30:
|
||||
Activity owns the existing steering/current view, Feed, and Raw Logs inside one segmented control. The stable Activity tab id remains `chat`, legacy `logs` callers land on Feed, and Raw Logs is the only segment that enables raw agent-log fetching. Planner-model conversation belongs to the separate `planner-chat` tab and must not route into steering comments.
|
||||
|
||||
FNXC:TaskDetailActivity 2026-06-30-21:55:
|
||||
The first Activity segment keeps the stable Current label for legacy segment tests and links, but its embedded composer labels the operational steering-comment affordance explicitly. Do not reuse this segment as the future planner-model Chat conversation; that belongs to a later top-level tab.
|
||||
The first Activity segment keeps the stable Current label for legacy segment tests and links, but its embedded composer labels the operational steering-comment affordance explicitly. Do not reuse this segment as planner-model Chat conversation.
|
||||
*/}
|
||||
<div className="activity-segmented-control" role="tablist" aria-label={t("taskDetail.activity.segmentsLabel", "Activity views")}>
|
||||
<button
|
||||
|
||||
149
packages/dashboard/app/components/TaskPlannerChatTab.css
Normal file
149
packages/dashboard/app/components/TaskPlannerChatTab.css
Normal file
@@ -0,0 +1,149 @@
|
||||
.task-planner-chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
min-height: min(60vh, 42rem);
|
||||
}
|
||||
|
||||
.task-planner-chat-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.task-planner-chat-header h4 {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.task-planner-chat-header p {
|
||||
margin: var(--space-xs) 0 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.task-planner-chat-model {
|
||||
flex: 0 0 auto;
|
||||
border: var(--btn-border-width) solid var(--border);
|
||||
border-radius: var(--radius-pill);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
color: var(--text-muted);
|
||||
background: var(--surface-subtle);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.task-planner-chat-error {
|
||||
border: var(--btn-border-width) solid var(--color-error);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
color: var(--color-error);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.task-planner-chat-transcript {
|
||||
flex: 1 1 auto;
|
||||
min-height: 16rem;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
border: var(--btn-border-width) solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-md);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.task-planner-chat-state,
|
||||
.task-planner-chat-empty {
|
||||
margin: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.task-planner-chat-empty {
|
||||
flex-direction: column;
|
||||
max-width: 36rem;
|
||||
}
|
||||
|
||||
.task-planner-chat-empty p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.task-planner-chat-starters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.task-planner-chat-starter {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.task-planner-chat-message {
|
||||
max-width: min(42rem, 92%);
|
||||
border: var(--btn-border-width) solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.task-planner-chat-message--user {
|
||||
align-self: flex-end;
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.task-planner-chat-message--assistant,
|
||||
.task-planner-chat-message--system {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.task-planner-chat-message-role {
|
||||
margin-bottom: var(--space-xs);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.task-planner-chat-message-content > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.task-planner-chat-message-content > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.task-planner-chat-composer {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.task-planner-chat-input {
|
||||
flex: 1 1 auto;
|
||||
min-height: 5rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.task-planner-chat-send {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.task-planner-chat {
|
||||
min-height: 70vh;
|
||||
}
|
||||
|
||||
.task-planner-chat-header,
|
||||
.task-planner-chat-composer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.task-planner-chat-message {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
322
packages/dashboard/app/components/TaskPlannerChatTab.tsx
Normal file
322
packages/dashboard/app/components/TaskPlannerChatTab.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import type { ChatMessage, ResolvedModelSelection, Task, TaskDetail } from "@fusion/core";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
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 { parseQuestionToolCall } from "../utils/parseQuestionToolCall";
|
||||
import { markdownComponents } from "./AgentLogViewer";
|
||||
import { ChatQuestionResponse } from "./ChatQuestionResponse";
|
||||
import "./TaskPlannerChatTab.css";
|
||||
|
||||
interface TaskPlannerChatTabProps {
|
||||
task: Task | TaskDetail;
|
||||
projectId?: string;
|
||||
active: boolean;
|
||||
planningModel: ResolvedModelSelection;
|
||||
addToast: (msg: string, type?: ToastType) => void;
|
||||
}
|
||||
|
||||
type ComposerState = "idle" | "sending";
|
||||
|
||||
function isUsableModel(model: ResolvedModelSelection): model is ResolvedModelSelection & { provider: string; modelId: string } {
|
||||
return Boolean(model.provider?.trim() && model.modelId?.trim());
|
||||
}
|
||||
|
||||
function sortMessages(messages: ChatMessage[]): ChatMessage[] {
|
||||
return [...messages].sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
|
||||
}
|
||||
|
||||
function makeOptimisticUserMessage(sessionId: string, content: string): ChatMessage {
|
||||
return {
|
||||
id: `optimistic-${Date.now()}`,
|
||||
sessionId,
|
||||
role: "user",
|
||||
content,
|
||||
thinkingOutput: null,
|
||||
metadata: { optimistic: true },
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeStreamingAssistantMessage(sessionId: string, content: string, toolCalls: ToolCallInfo[] = []): ChatMessage {
|
||||
return {
|
||||
id: "streaming-assistant",
|
||||
sessionId,
|
||||
role: "assistant",
|
||||
content,
|
||||
thinkingOutput: null,
|
||||
metadata: { streaming: true, ...(toolCalls.length > 0 ? { toolCalls } : {}) },
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function extractToolCalls(message: ChatMessage): ToolCallInfo[] {
|
||||
const rawToolCalls = message.metadata?.toolCalls;
|
||||
if (!Array.isArray(rawToolCalls)) return [];
|
||||
return rawToolCalls
|
||||
.map((toolCall): ToolCallInfo | null => {
|
||||
if (!toolCall || typeof toolCall !== "object") return null;
|
||||
const record = toolCall as Record<string, unknown>;
|
||||
const toolName = typeof record.toolName === "string" ? record.toolName : "";
|
||||
if (!toolName) return null;
|
||||
const args = record.args;
|
||||
return {
|
||||
toolName,
|
||||
...(args && typeof args === "object" ? { args: args as Record<string, unknown> } : {}),
|
||||
isError: Boolean(record.isError),
|
||||
result: record.result,
|
||||
status: record.status === "running" ? "running" : "completed",
|
||||
};
|
||||
})
|
||||
.filter((toolCall): toolCall is ToolCallInfo => toolCall !== null);
|
||||
}
|
||||
|
||||
export function TaskPlannerChatTab({ task, projectId, active, planningModel, addToast }: TaskPlannerChatTabProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [composerState, setComposerState] = useState<ComposerState>("idle");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const streamRef = useRef<{ close: () => void } | null>(null);
|
||||
const transcriptRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const modelPayload = useMemo(() => {
|
||||
return isUsableModel(planningModel)
|
||||
? { modelProvider: planningModel.provider, modelId: planningModel.modelId }
|
||||
: {};
|
||||
}, [planningModel]);
|
||||
|
||||
const loadSession = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { session } = await ensureTaskPlannerChatSession(task.id, modelPayload, projectId);
|
||||
setSessionId(session.id);
|
||||
const { messages: loadedMessages } = await fetchChatMessages(session.id, { order: "asc" }, projectId);
|
||||
setMessages(sortMessages(loadedMessages));
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err) || t("taskDetail.plannerChat.loadFailed", "Failed to load planner chat");
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [modelPayload, projectId, task.id, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
void loadSession();
|
||||
}, [active, loadSession]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
streamRef.current?.close();
|
||||
streamRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!transcriptRef.current) return;
|
||||
transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight;
|
||||
}, [messages, composerState]);
|
||||
|
||||
const sendMessageContent = useCallback(async (messageContent: string) => {
|
||||
const content = messageContent.trim();
|
||||
if (!content || composerState === "sending") return;
|
||||
|
||||
setDraft("");
|
||||
setComposerState("sending");
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const { session } = sessionId
|
||||
? { session: { id: sessionId } }
|
||||
: await ensureTaskPlannerChatSession(task.id, modelPayload, projectId);
|
||||
const resolvedSessionId = session.id;
|
||||
setSessionId(resolvedSessionId);
|
||||
setMessages((current) => [...current, makeOptimisticUserMessage(resolvedSessionId, content)]);
|
||||
let accumulated = "";
|
||||
const streamingToolCalls: ToolCallInfo[] = [];
|
||||
|
||||
streamRef.current?.close();
|
||||
streamRef.current = streamChatResponse(
|
||||
resolvedSessionId,
|
||||
content,
|
||||
{
|
||||
onText: (delta) => {
|
||||
accumulated += delta;
|
||||
setMessages((current) => {
|
||||
const withoutStreaming = current.filter((message) => message.id !== "streaming-assistant");
|
||||
return [...withoutStreaming, makeStreamingAssistantMessage(resolvedSessionId, accumulated, streamingToolCalls)];
|
||||
});
|
||||
},
|
||||
onToolStart: ({ toolName, args }) => {
|
||||
streamingToolCalls.push({ toolName, args, isError: false, status: "running" });
|
||||
setMessages((current) => {
|
||||
const withoutStreaming = current.filter((message) => message.id !== "streaming-assistant");
|
||||
return [...withoutStreaming, makeStreamingAssistantMessage(resolvedSessionId, accumulated, streamingToolCalls)];
|
||||
});
|
||||
},
|
||||
onToolEnd: ({ toolName, isError, result }) => {
|
||||
const running = [...streamingToolCalls].reverse().find((toolCall) => toolCall.toolName === toolName && toolCall.status === "running");
|
||||
if (running) {
|
||||
running.status = "completed";
|
||||
running.isError = isError;
|
||||
running.result = result;
|
||||
} else {
|
||||
streamingToolCalls.push({ toolName, isError, result, status: "completed" });
|
||||
}
|
||||
setMessages((current) => {
|
||||
const withoutStreaming = current.filter((message) => message.id !== "streaming-assistant");
|
||||
return [...withoutStreaming, makeStreamingAssistantMessage(resolvedSessionId, accumulated, streamingToolCalls)];
|
||||
});
|
||||
},
|
||||
onDone: (data) => {
|
||||
setComposerState("idle");
|
||||
streamRef.current = null;
|
||||
if (data.message) {
|
||||
setMessages((current) => {
|
||||
const withoutTemporary = current.filter((message) => message.id !== "streaming-assistant");
|
||||
return sortMessages([...withoutTemporary, data.message!]);
|
||||
});
|
||||
} else {
|
||||
void fetchChatMessages(resolvedSessionId, { order: "asc" }, projectId).then(({ messages: refreshed }) => {
|
||||
setMessages(sortMessages(refreshed));
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (streamError) => {
|
||||
const message = typeof streamError === "string" ? streamError : streamError.summary;
|
||||
setError(message || t("taskDetail.plannerChat.sendFailed", "Planner chat failed to respond"));
|
||||
setComposerState("idle");
|
||||
streamRef.current = null;
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
projectId,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err) || t("taskDetail.plannerChat.sendFailed", "Planner chat failed to respond");
|
||||
setError(message);
|
||||
addToast(message, "error");
|
||||
setComposerState("idle");
|
||||
}
|
||||
}, [addToast, composerState, modelPayload, projectId, sessionId, task.id, t]);
|
||||
|
||||
const sendMessage = useCallback(() => sendMessageContent(draft), [draft, sendMessageContent]);
|
||||
|
||||
const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key !== "Enter" || event.shiftKey) return;
|
||||
event.preventDefault();
|
||||
void sendMessage();
|
||||
}, [sendMessage]);
|
||||
|
||||
const canSend = draft.trim().length > 0 && composerState !== "sending";
|
||||
const starterPrompts = useMemo(() => [
|
||||
t("taskDetail.plannerChat.starterStatus", "What is the current state of this task?"),
|
||||
t("taskDetail.plannerChat.starterNext", "What should happen next?"),
|
||||
t("taskDetail.plannerChat.starterRisk", "What risks or dependencies should I watch?"),
|
||||
t("taskDetail.plannerChat.starterSteer", "Help me turn this into clear steering for the executor."),
|
||||
], [t]);
|
||||
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
return (
|
||||
<section className="task-planner-chat" aria-label={t("taskDetail.plannerChat.label", "Planner chat")} data-testid="task-planner-chat-panel">
|
||||
<div className="task-planner-chat-header">
|
||||
<div>
|
||||
<h4>{t("taskDetail.plannerChat.heading", "Planner Chat")}</h4>
|
||||
<p>{t("taskDetail.plannerChat.description", "Ask planning questions about this task, clarify next steps, or request steering for the executor.")}</p>
|
||||
</div>
|
||||
{isUsableModel(planningModel) && (
|
||||
<span className="task-planner-chat-model" data-testid="task-planner-chat-model">
|
||||
{planningModel.provider}/{planningModel.modelId}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="task-planner-chat-error" role="alert">{error}</div>}
|
||||
|
||||
<div className="task-planner-chat-transcript" ref={transcriptRef} data-testid="task-planner-chat-transcript">
|
||||
{loading ? (
|
||||
<div className="task-planner-chat-state" role="status" aria-live="polite">
|
||||
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||
<span>{t("taskDetail.plannerChat.loading", "Loading planner chat…")}</span>
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="task-planner-chat-empty" data-testid="task-planner-chat-empty">
|
||||
<p>{t("taskDetail.plannerChat.empty", "No planner-chat messages yet.")}</p>
|
||||
<div className="task-planner-chat-starters" aria-label={t("taskDetail.plannerChat.startersLabel", "Planner chat starter prompts")}>
|
||||
{starterPrompts.map((prompt) => (
|
||||
<button
|
||||
key={prompt}
|
||||
type="button"
|
||||
className="btn task-planner-chat-starter"
|
||||
onClick={() => void sendMessageContent(prompt)}
|
||||
disabled={composerState === "sending"}
|
||||
>
|
||||
{prompt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((message) => {
|
||||
const toolCalls = extractToolCalls(message);
|
||||
return (
|
||||
<article key={message.id} className={`task-planner-chat-message task-planner-chat-message--${message.role}`} data-testid={`task-planner-chat-message-${message.role}`}>
|
||||
<div className="task-planner-chat-message-role">
|
||||
{message.role === "user" ? t("taskDetail.plannerChat.user", "You") : t("taskDetail.plannerChat.assistant", "Planner")}
|
||||
</div>
|
||||
{message.content && (
|
||||
<div className="task-planner-chat-message-content markdown-body">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>{message.content}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
{toolCalls.map((toolCall, index) => {
|
||||
const parsedQuestion = parseQuestionToolCall(toolCall);
|
||||
if (!parsedQuestion) return null;
|
||||
const answered = message.id !== "streaming-assistant" && message !== messages[messages.length - 1];
|
||||
return (
|
||||
<ChatQuestionResponse
|
||||
key={`${toolCall.toolName}-${index}`}
|
||||
parsed={parsedQuestion}
|
||||
answered={answered}
|
||||
disabled={composerState === "sending" || answered}
|
||||
compact
|
||||
onSubmit={(answerText) => void sendMessageContent(answerText)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</article>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="task-planner-chat-composer">
|
||||
<textarea
|
||||
className="input task-planner-chat-input"
|
||||
aria-label={t("taskDetail.plannerChat.inputLabel", "Message planner chat")}
|
||||
placeholder={t("taskDetail.plannerChat.placeholder", "Ask the planner about this task…")}
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={composerState === "sending"}
|
||||
/>
|
||||
<button type="button" className="btn btn-primary task-planner-chat-send" onClick={() => void sendMessage()} disabled={!canSend}>
|
||||
{composerState === "sending" ? <Loader2 className="animate-spin" aria-hidden="true" /> : <Send aria-hidden="true" />}
|
||||
<span>{composerState === "sending" ? t("taskDetail.plannerChat.sending", "Sending") : t("taskDetail.plannerChat.send", "Send")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -90,6 +90,17 @@ function expectTabTouchAction(ruleBlock: string, surface: string): void {
|
||||
|
||||
describe("TaskDetailModal", () => {
|
||||
describe("mobile responsive structure", () => {
|
||||
it("keeps planner chat composer usable on narrow task-detail layouts", () => {
|
||||
const css = readDashboardStylesSource();
|
||||
const mobileBlock = getCssAtRuleBlockContaining(css, "@media (max-width: 768px)", ".task-planner-chat-composer");
|
||||
|
||||
expectBaseRule(css, ".task-planner-chat", "display: flex;");
|
||||
expectBaseRule(css, ".task-planner-chat-transcript", "overflow: auto;");
|
||||
expect(mobileBlock).toContain(".task-planner-chat-composer");
|
||||
expect(mobileBlock).toContain("flex-direction: column;");
|
||||
expect(mobileBlock).toContain("align-items: stretch;");
|
||||
});
|
||||
|
||||
it("keeps detail metadata as a single wrapping flex row without mobile column fallbacks", () => {
|
||||
const css = readDashboardStylesSource();
|
||||
|
||||
@@ -226,8 +237,8 @@ describe("TaskDetailModal", () => {
|
||||
const tabs = container.querySelectorAll(".detail-tab");
|
||||
expect(Array.from(tabs).map((tab) => tab.textContent?.trim())).toEqual([
|
||||
"Activity",
|
||||
"Chat",
|
||||
"Plan",
|
||||
"Logs",
|
||||
"Changes",
|
||||
"Review",
|
||||
"Comments",
|
||||
|
||||
@@ -64,6 +64,9 @@ vi.mock("../../api", async (importOriginal) => {
|
||||
fetchTaskReview: vi.fn().mockResolvedValue({ reviewState: { source: "reviewer-agent", items: [], addressing: [] }, automationStatus: null, emptyMessage: "No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode." }),
|
||||
refreshTaskReview: vi.fn().mockResolvedValue({ reviewState: undefined, automationStatus: null }),
|
||||
reviseTaskReviewItems: vi.fn().mockResolvedValue({ task: makeTask(), reviewState: undefined }),
|
||||
ensureTaskPlannerChatSession: vi.fn().mockResolvedValue({ session: { id: "chat-task-planner", agentId: "task-planner:FN-099", title: "FN-099 planner chat", status: "active", projectId: null, modelProvider: null, modelId: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", cliSessionFile: null, cliExecutorAdapterId: null, inFlightGeneration: null } }),
|
||||
fetchChatMessages: vi.fn().mockResolvedValue({ messages: [] }),
|
||||
streamChatResponse: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -75,6 +75,64 @@ function createDeferred<T>() {
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe("TaskDetailModal planner Chat tab", () => {
|
||||
function renderTask(column: any = "in-progress", initialTab?: ComponentProps<typeof TaskDetailModal>["initialTab"]) {
|
||||
return render(
|
||||
<TaskDetailModal
|
||||
initialTab={initialTab}
|
||||
task={makeTask({ column })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function tabLabels(): string[] {
|
||||
return Array.from(document.querySelectorAll<HTMLButtonElement>(".detail-tabs .detail-tab"))
|
||||
.map((button) => button.textContent?.trim() ?? "");
|
||||
}
|
||||
|
||||
it("renders Activity then Chat as the first task-detail conversation tabs for active tasks", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTask("in-progress");
|
||||
|
||||
expect(tabLabels().slice(0, 2)).toEqual(["Activity", "Chat"]);
|
||||
expect(screen.getAllByRole("button", { name: "Chat" })).toHaveLength(1);
|
||||
expect(screen.getByRole("button", { name: "Activity" })).toHaveClass("detail-tab-active");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Chat" }));
|
||||
|
||||
expect(screen.getByTestId("task-planner-chat-panel")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Chat" })).toHaveClass("detail-tab-active");
|
||||
expect(screen.getByRole("button", { name: "Activity" })).not.toHaveClass("detail-tab-active");
|
||||
});
|
||||
|
||||
it("preserves Summary as the default for done tasks while keeping Activity then Chat order", () => {
|
||||
renderTask("done");
|
||||
|
||||
expect(tabLabels().slice(0, 3)).toEqual(["Activity", "Chat", "Summary"]);
|
||||
expect(screen.getByRole("button", { name: "Summary" })).toHaveClass("detail-tab-active");
|
||||
});
|
||||
|
||||
it("keeps explicit legacy chat deep links routed to Activity", () => {
|
||||
renderTask("in-progress", "chat");
|
||||
|
||||
expect(screen.getByRole("button", { name: "Activity" })).toHaveClass("detail-tab-active");
|
||||
expect(screen.getByRole("tab", { name: "Current" })).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
it("routes explicit planner-chat requests to the new Chat tab", () => {
|
||||
renderTask("todo", "planner-chat");
|
||||
|
||||
expect(screen.getByRole("button", { name: "Chat" })).toHaveClass("detail-tab-active");
|
||||
expect(screen.getByTestId("task-planner-chat-panel")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskDetailModal summarize title action", () => {
|
||||
it("orders board detail header actions as edit, expand, then Back to board", () => {
|
||||
const onBackToBoard = vi.fn();
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import React from "react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { TaskPlannerChatTab } from "../TaskPlannerChatTab";
|
||||
|
||||
const { mockEnsureTaskPlannerChatSession, mockFetchChatMessages, mockStreamChatResponse } = vi.hoisted(() => ({
|
||||
mockEnsureTaskPlannerChatSession: vi.fn(),
|
||||
mockFetchChatMessages: vi.fn(),
|
||||
mockStreamChatResponse: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../api")>();
|
||||
return {
|
||||
...actual,
|
||||
ensureTaskPlannerChatSession: mockEnsureTaskPlannerChatSession,
|
||||
fetchChatMessages: mockFetchChatMessages,
|
||||
streamChatResponse: mockStreamChatResponse,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
Loader2: (props: any) => React.createElement("svg", { "data-testid": "loader2-icon", ...props }),
|
||||
Send: (props: any) => React.createElement("svg", { "data-testid": "send-icon", ...props }),
|
||||
}));
|
||||
|
||||
function renderPlannerChat(overrides: Partial<React.ComponentProps<typeof TaskPlannerChatTab>> = {}) {
|
||||
return render(
|
||||
<TaskPlannerChatTab
|
||||
task={{ id: "FN-7310", description: "Test task", column: "todo", dependencies: [], steps: [], currentStep: 0, createdAt: "2026-06-30T00:00:00.000Z", updatedAt: "2026-06-30T00:00:00.000Z", planningModelProvider: "anthropic", planningModelId: "claude-plan" } as any}
|
||||
active
|
||||
planningModel={{ provider: "anthropic", modelId: "claude-plan" }}
|
||||
addToast={vi.fn()}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("TaskPlannerChatTab", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockEnsureTaskPlannerChatSession.mockResolvedValue({
|
||||
session: {
|
||||
id: "chat-planner",
|
||||
agentId: "task-planner:FN-7310",
|
||||
title: "FN-7310 planner chat",
|
||||
status: "active",
|
||||
projectId: null,
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-plan",
|
||||
createdAt: "2026-06-30T00:00:00.000Z",
|
||||
updatedAt: "2026-06-30T00:00:00.000Z",
|
||||
cliSessionFile: null,
|
||||
cliExecutorAdapterId: null,
|
||||
inFlightGeneration: null,
|
||||
},
|
||||
});
|
||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||
mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true });
|
||||
});
|
||||
|
||||
it("loads a task-scoped planner session and renders the empty state", async () => {
|
||||
renderPlannerChat();
|
||||
|
||||
expect(await screen.findByTestId("task-planner-chat-empty")).toHaveTextContent("No planner-chat messages yet.");
|
||||
expect(mockEnsureTaskPlannerChatSession).toHaveBeenCalledWith(
|
||||
"FN-7310",
|
||||
{ modelProvider: "anthropic", modelId: "claude-plan" },
|
||||
undefined,
|
||||
);
|
||||
expect(mockFetchChatMessages).toHaveBeenCalledWith("chat-planner", { order: "asc" }, undefined);
|
||||
expect(screen.getByTestId("task-planner-chat-model")).toHaveTextContent("anthropic/claude-plan");
|
||||
expect(screen.getByRole("button", { name: "What is the current state of this task?" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Help me turn this into clear steering for the executor." })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits model override when the effective planning model is undefined", async () => {
|
||||
renderPlannerChat({ planningModel: {} });
|
||||
|
||||
await screen.findByTestId("task-planner-chat-empty");
|
||||
expect(mockEnsureTaskPlannerChatSession).toHaveBeenCalledWith("FN-7310", {}, undefined);
|
||||
expect(screen.queryByTestId("task-planner-chat-model")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders persisted planner-chat messages", async () => {
|
||||
mockFetchChatMessages.mockResolvedValue({
|
||||
messages: [
|
||||
{ id: "m2", sessionId: "chat-planner", role: "assistant", content: "Planner answer", thinkingOutput: null, metadata: null, createdAt: "2026-06-30T00:02:00.000Z" },
|
||||
{ id: "m1", sessionId: "chat-planner", role: "user", content: "Question", thinkingOutput: null, metadata: null, createdAt: "2026-06-30T00:01:00.000Z" },
|
||||
],
|
||||
});
|
||||
|
||||
renderPlannerChat();
|
||||
|
||||
expect(await screen.findByText("Question")).toBeInTheDocument();
|
||||
expect(screen.getByText("Planner answer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sends messages through the chat stream and appends success responses", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
|
||||
setTimeout(() => {
|
||||
handlers.onText("Hello");
|
||||
handlers.onDone({
|
||||
messageId: "assistant-1",
|
||||
message: { id: "assistant-1", sessionId: "chat-planner", role: "assistant", content: "Hello", thinkingOutput: null, metadata: null, createdAt: "2026-06-30T00:03:00.000Z" },
|
||||
});
|
||||
}, 0);
|
||||
return { close: vi.fn(), isConnected: () => true };
|
||||
});
|
||||
renderPlannerChat();
|
||||
await screen.findByTestId("task-planner-chat-empty");
|
||||
|
||||
await user.type(screen.getByLabelText("Message planner chat"), "Help plan this");
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
expect(mockStreamChatResponse).toHaveBeenCalledWith(
|
||||
"chat-planner",
|
||||
"Help plan this",
|
||||
expect.any(Object),
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(screen.getByText("Help plan this")).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByText("Hello")).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("sends starter prompts through the planner chat stream", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPlannerChat();
|
||||
await screen.findByTestId("task-planner-chat-empty");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "What should happen next?" }));
|
||||
|
||||
expect(mockStreamChatResponse).toHaveBeenCalledWith(
|
||||
"chat-planner",
|
||||
"What should happen next?",
|
||||
expect.any(Object),
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("renders planner question tool calls with the shared answer UI", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockFetchChatMessages.mockResolvedValue({
|
||||
messages: [
|
||||
{
|
||||
id: "assistant-question",
|
||||
sessionId: "chat-planner",
|
||||
role: "assistant",
|
||||
content: "Which path should we use?",
|
||||
thinkingOutput: null,
|
||||
metadata: {
|
||||
toolCalls: [{ toolName: "fn_ask_question", args: { question: "Pick a path", options: ["Conservative", "Aggressive"] }, isError: false }],
|
||||
},
|
||||
createdAt: "2026-06-30T00:02:00.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
renderPlannerChat();
|
||||
|
||||
expect(await screen.findByTestId("chat-question-response")).toBeInTheDocument();
|
||||
await user.click(screen.getByTestId("chat-question-response-option-q-0-opt-0"));
|
||||
await user.click(screen.getByTestId("chat-question-response-submit"));
|
||||
|
||||
expect(mockStreamChatResponse).toHaveBeenCalledWith(
|
||||
"chat-planner",
|
||||
"> Q: Pick a path\nConservative",
|
||||
expect.any(Object),
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("shows API errors and re-enables the composer", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
|
||||
setTimeout(() => handlers.onError("Planner unavailable"), 0);
|
||||
return { close: vi.fn(), isConnected: () => true };
|
||||
});
|
||||
renderPlannerChat();
|
||||
await screen.findByTestId("task-planner-chat-empty");
|
||||
|
||||
await user.type(screen.getByLabelText("Message planner chat"), "Question");
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Planner unavailable");
|
||||
await waitFor(() => expect(screen.getByLabelText("Message planner chat")).toBeEnabled());
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -1257,6 +1257,71 @@ describe("ChatManager.sendMessage", () => {
|
||||
expect(createOptions?.systemPrompt).toContain(CHAT_ASK_QUESTION_GUIDANCE);
|
||||
});
|
||||
|
||||
it("adds rich task context and steering tools for synthetic task planner chat sessions", 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: "Planning response" }],
|
||||
},
|
||||
},
|
||||
}));
|
||||
__setCreateResolvedAgentSession(createResolvedSession as any);
|
||||
|
||||
const taskStore = {
|
||||
getTask: vi.fn().mockResolvedValue({
|
||||
id: "FN-7310",
|
||||
title: "Add planner chat",
|
||||
description: "Short list description should not replace the task prompt",
|
||||
prompt: "# PROMPT.md\n\nImplement the planner-model Chat tab from the detailed task plan.",
|
||||
column: "todo",
|
||||
status: "planning",
|
||||
dependencies: ["FN-7309"],
|
||||
steps: [{ title: "Polish", status: "in-progress" }],
|
||||
comments: [{ text: "User wants planner chat", author: "user" }],
|
||||
steeringComments: [{ text: "Keep Activity intact", author: "user" }],
|
||||
log: [{ level: "info", message: "Activity transcript loaded" }],
|
||||
}),
|
||||
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", "How should I plan this?");
|
||||
|
||||
const createOptions = createResolvedSession.mock.calls[0]?.[0];
|
||||
expect(createOptions.defaultProvider).toBe("anthropic");
|
||||
expect(createOptions.defaultModelId).toBe("claude-plan");
|
||||
expect(createOptions.systemPrompt).toContain("## Task Planner Chat Context");
|
||||
expect(createOptions.systemPrompt).toContain("Task ID: FN-7310");
|
||||
expect(createOptions.systemPrompt).toContain("Title: Add planner chat");
|
||||
expect(createOptions.systemPrompt).toContain("Prompt:\n# PROMPT.md");
|
||||
expect(createOptions.systemPrompt).toContain("Implement the planner-model Chat tab from the detailed task plan.");
|
||||
expect(createOptions.systemPrompt).toContain("Dependencies: FN-7309");
|
||||
expect(createOptions.systemPrompt).toContain("Polish: in-progress");
|
||||
expect(createOptions.systemPrompt).toContain("Activity transcript loaded");
|
||||
expect(createOptions.systemPrompt).toContain("fn_task_planner_add_steering");
|
||||
expect(createOptions.systemPrompt).toContain("fn_ask_question");
|
||||
expect(createOptions.customTools.map((tool: { name: string }) => tool.name)).toContain("fn_task_planner_add_steering");
|
||||
expect(taskStore.getTask).toHaveBeenCalledWith("FN-7310", { activityLogLimit: 20 });
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
@@ -143,6 +143,7 @@ const mockAddMessage = vi.fn();
|
||||
const mockGetMessages = vi.fn();
|
||||
const mockGetMessage = vi.fn();
|
||||
const mockGetLastMessageForSessions = vi.fn().mockReturnValue(new Map());
|
||||
const mockFindLatestActiveSessionForTarget = vi.fn();
|
||||
const mockDeleteMessage = vi.fn();
|
||||
|
||||
// Mock AgentStore
|
||||
@@ -164,6 +165,7 @@ vi.mock("@fusion/core", async (importOriginal) => createCoreMock(
|
||||
getMessages = mockGetMessages;
|
||||
getMessage = mockGetMessage;
|
||||
getLastMessageForSessions = mockGetLastMessageForSessions;
|
||||
findLatestActiveSessionForTarget = mockFindLatestActiveSessionForTarget;
|
||||
deleteMessage = mockDeleteMessage;
|
||||
},
|
||||
AgentStore: class MockAgentStore {
|
||||
@@ -184,6 +186,7 @@ vi.mock("../chat.js", () => {
|
||||
getActiveGenerationId = mockGetActiveGenerationId;
|
||||
},
|
||||
chatStreamManager: mockChatStreamManager,
|
||||
TASK_PLANNER_CHAT_AGENT_ID_PREFIX: "task-planner:",
|
||||
checkRateLimit: vi.fn().mockReturnValue(true),
|
||||
getRateLimitResetTime: vi.fn().mockReturnValue(null),
|
||||
__setCreateFnAgent: vi.fn(),
|
||||
@@ -247,6 +250,25 @@ class MockStore extends EventEmitter {
|
||||
return "/tmp/fn-chat-test/.fusion";
|
||||
}
|
||||
|
||||
async getTask(id: string) {
|
||||
if (id === "FN-MISSING") {
|
||||
throw new Error("Task FN-MISSING not found");
|
||||
}
|
||||
return {
|
||||
id,
|
||||
title: "Planner task",
|
||||
description: "Task description",
|
||||
column: "todo",
|
||||
status: "planning",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-06-30T00:00:00.000Z",
|
||||
updatedAt: "2026-06-30T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
getDatabase() {
|
||||
return {
|
||||
exec: vi.fn(),
|
||||
@@ -273,6 +295,7 @@ const mockChatStoreInstance = {
|
||||
getMessages: mockGetMessages,
|
||||
getMessage: mockGetMessage,
|
||||
getLastMessageForSessions: mockGetLastMessageForSessions,
|
||||
findLatestActiveSessionForTarget: mockFindLatestActiveSessionForTarget,
|
||||
deleteMessage: mockDeleteMessage,
|
||||
emit: vi.fn(),
|
||||
on: vi.fn(),
|
||||
@@ -352,6 +375,7 @@ describe("Chat API Routes", () => {
|
||||
mockGetMessages.mockReset();
|
||||
mockGetMessage.mockReset();
|
||||
mockGetLastMessageForSessions.mockReset();
|
||||
mockFindLatestActiveSessionForTarget.mockReset();
|
||||
mockDeleteMessage.mockReset();
|
||||
mockSendMessage.mockReset();
|
||||
mockCancelGeneration.mockReset();
|
||||
@@ -363,6 +387,7 @@ describe("Chat API Routes", () => {
|
||||
|
||||
// Setup default mocks
|
||||
mockListSessions.mockReturnValue([]);
|
||||
mockFindLatestActiveSessionForTarget.mockReturnValue(null);
|
||||
mockGetMessages.mockReturnValue([]);
|
||||
mockGetLastMessageForSessions.mockReturnValue(new Map());
|
||||
mockCancelGeneration.mockReturnValue(false);
|
||||
@@ -404,6 +429,87 @@ describe("Chat API Routes", () => {
|
||||
|
||||
// ── Session CRUD Tests ──────────────────────────────────────────────────────
|
||||
|
||||
describe("POST /api/chat/task-planner/:taskId/session", () => {
|
||||
it("creates a task-scoped planner chat session with the planning model override", async () => {
|
||||
const created = {
|
||||
...sampleSession,
|
||||
id: "chat-planner-001",
|
||||
agentId: "task-planner:FN-7310",
|
||||
title: "FN-7310 planner chat",
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-plan",
|
||||
};
|
||||
mockCreateSession.mockReturnValue(created);
|
||||
|
||||
const response = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/chat/task-planner/FN-7310/session",
|
||||
JSON.stringify({ modelProvider: "anthropic", modelId: "claude-plan" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect((response.body as any).session).toEqual(created);
|
||||
expect(mockFindLatestActiveSessionForTarget).toHaveBeenCalledWith({ agentId: "task-planner:FN-7310" });
|
||||
expect(mockCreateSession).toHaveBeenCalledWith({
|
||||
agentId: "task-planner:FN-7310",
|
||||
title: "FN-7310 planner chat",
|
||||
projectId: null,
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-plan",
|
||||
});
|
||||
});
|
||||
|
||||
it("resumes and updates an existing planner chat session", async () => {
|
||||
const existing = { ...sampleSession, id: "chat-existing", agentId: "task-planner:FN-7310", modelProvider: null, modelId: null };
|
||||
const updated = { ...existing, modelProvider: "openai", modelId: "gpt-plan" };
|
||||
mockFindLatestActiveSessionForTarget.mockReturnValue(existing);
|
||||
mockUpdateSession.mockReturnValue(updated);
|
||||
|
||||
const response = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/chat/task-planner/FN-7310/session?projectId=proj-001",
|
||||
JSON.stringify({ modelProvider: "openai", modelId: "gpt-plan" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect((response.body as any).session).toEqual(updated);
|
||||
expect(mockFindLatestActiveSessionForTarget).toHaveBeenCalledWith({ agentId: "task-planner:FN-7310", projectId: "proj-001" });
|
||||
expect(mockUpdateSession).toHaveBeenCalledWith("chat-existing", { modelProvider: "openai", modelId: "gpt-plan" });
|
||||
expect(mockCreateSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects incomplete model override pairs", async () => {
|
||||
const response = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/chat/task-planner/FN-7310/session",
|
||||
JSON.stringify({ modelProvider: "anthropic" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect((response.body as any).error).toContain("Both modelProvider and modelId");
|
||||
expect(mockCreateSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns not found for missing tasks in the scoped project store", async () => {
|
||||
const response = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/chat/task-planner/FN-MISSING/session",
|
||||
JSON.stringify({ modelProvider: "anthropic", modelId: "claude-plan" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect((response.body as any).error).toContain("Task FN-MISSING not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/chat/sessions", () => {
|
||||
it("returns all sessions", async () => {
|
||||
mockListSessions.mockReturnValue([sampleSession]);
|
||||
|
||||
@@ -228,8 +228,114 @@ const MAX_MESSAGES_PER_IP_PER_MINUTE = 30;
|
||||
|
||||
/** Maximum file size for # mentions (50KB). Files larger than this are skipped. */
|
||||
const MAX_REFERENCED_FILE_SIZE = 50 * 1024;
|
||||
export const TASK_PLANNER_CHAT_AGENT_ID_PREFIX = "task-planner:";
|
||||
const ROOM_AMBIENT_MAX_RESPONDERS = 5;
|
||||
|
||||
function summarizeList<T>(items: T[] | undefined, format: (item: T, index: number) => string | null, limit: number): string[] {
|
||||
const values = (items ?? []).map(format).filter((value): value is string => Boolean(value));
|
||||
const selected = values.slice(-limit);
|
||||
if (values.length > selected.length) {
|
||||
return [`…${values.length - selected.length} older entries omitted`, ...selected];
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function truncatePlannerContext(value: string, max = 1_200): string {
|
||||
return value.length > max ? `${value.slice(0, max)}…` : value;
|
||||
}
|
||||
|
||||
interface PlannerContextTask {
|
||||
id: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
prompt?: string;
|
||||
plan?: string;
|
||||
column?: string;
|
||||
status?: string;
|
||||
dependencies?: string[];
|
||||
steps?: Array<{ title?: string; name?: string; status?: string }>;
|
||||
comments?: Array<{ text?: string; author?: string }>;
|
||||
steeringComments?: Array<{ text?: string; author?: string }>;
|
||||
log?: Array<{ message?: string; text?: string; level?: string; type?: string }>;
|
||||
}
|
||||
|
||||
function buildTaskPlannerContext(task: PlannerContextTask): string {
|
||||
const promptText = typeof task.prompt === "string" && task.prompt.trim() ? task.prompt.trim() : "";
|
||||
const planText = typeof task.plan === "string" && task.plan.trim() ? task.plan.trim() : "";
|
||||
const descriptionText = typeof task.description === "string" && task.description.trim() ? task.description.trim() : "";
|
||||
const primaryTaskSpec = promptText || planText || descriptionText;
|
||||
const primaryTaskSpecLabel = promptText ? "Prompt" : planText ? "Plan" : "Description";
|
||||
|
||||
/*
|
||||
FNXC:TaskDetailPlannerChat 2026-06-30-23:59:
|
||||
Planner Chat must answer from the task's PROMPT.md/plan when available, not only the shorter description shown in lists. Use description only as the fallback so normal task-detail conversations see the same implementation plan the executor saw.
|
||||
*/
|
||||
const parts = [
|
||||
`Task ID: ${task.id}`,
|
||||
task.title ? `Title: ${task.title}` : null,
|
||||
`Column: ${task.column}`,
|
||||
task.status ? `Status: ${task.status}` : null,
|
||||
Array.isArray(task.dependencies) && task.dependencies.length > 0 ? `Dependencies: ${task.dependencies.join(", ")}` : "Dependencies: none",
|
||||
primaryTaskSpec ? `${primaryTaskSpecLabel}:\n${truncatePlannerContext(primaryTaskSpec)}` : null,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
if (promptText && planText && planText !== promptText) {
|
||||
parts.push(`Plan:\n${truncatePlannerContext(planText)}`);
|
||||
}
|
||||
|
||||
const steps = summarizeList(task.steps, (step, index) => {
|
||||
const title = typeof step.title === "string" ? step.title : typeof step.name === "string" ? step.name : `Step ${index}`;
|
||||
const status = typeof step.status === "string" ? step.status : "unknown";
|
||||
return `- ${title}: ${status}`;
|
||||
}, 10);
|
||||
if (steps.length > 0) parts.push(`Steps:\n${steps.join("\n")}`);
|
||||
|
||||
const comments = summarizeList([...(task.comments ?? []), ...(task.steeringComments ?? [])], (comment) => {
|
||||
const text = typeof comment.text === "string" ? comment.text.trim() : "";
|
||||
if (!text) return null;
|
||||
const author = typeof comment.author === "string" ? comment.author : "user";
|
||||
return `- ${author}: ${truncatePlannerContext(text, 500)}`;
|
||||
}, 8);
|
||||
if (comments.length > 0) parts.push(`Recent comments / steering:\n${comments.join("\n")}`);
|
||||
|
||||
const activity = summarizeList(task.log, (entry) => {
|
||||
const message = typeof entry.message === "string" ? entry.message : typeof entry.text === "string" ? entry.text : "";
|
||||
if (!message.trim()) return null;
|
||||
const level = typeof entry.level === "string" ? entry.level : typeof entry.type === "string" ? entry.type : "log";
|
||||
return `- ${level}: ${truncatePlannerContext(message.trim(), 500)}`;
|
||||
}, 12);
|
||||
if (activity.length > 0) parts.push(`Recent activity:\n${activity.join("\n")}`);
|
||||
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
function createTaskPlannerSteeringTool(taskStore: TaskStore, taskId: string) {
|
||||
return {
|
||||
name: "fn_task_planner_add_steering",
|
||||
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.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
text: { type: "string", description: "The steering comment to add to the task." },
|
||||
},
|
||||
required: ["text"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
execute: async (_id: string, params: { text?: unknown }) => {
|
||||
const text = typeof params.text === "string" ? params.text.trim() : "";
|
||||
if (!text) {
|
||||
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");
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Steering comment added to ${task.id}.` }],
|
||||
details: { taskId: task.id, text },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Sentinel response from room responders indicating an intentional no-op/silence. */
|
||||
export const ROOM_SKIP_SENTINEL = "__SKIP__";
|
||||
|
||||
@@ -1719,6 +1825,27 @@ export class ChatManager {
|
||||
}
|
||||
systemPrompt = `${systemPrompt}\n\n${CHAT_ASK_QUESTION_GUIDANCE}`;
|
||||
|
||||
const taskPlannerChatTaskId = typeof session.agentId === "string" && session.agentId.startsWith(TASK_PLANNER_CHAT_AGENT_ID_PREFIX)
|
||||
? session.agentId.slice(TASK_PLANNER_CHAT_AGENT_ID_PREFIX.length).trim()
|
||||
: "";
|
||||
if (taskPlannerChatTaskId) {
|
||||
let taskContext = `Task ID: ${taskPlannerChatTaskId}`;
|
||||
if (this.taskStore) {
|
||||
try {
|
||||
const task = await this.taskStore.getTask(taskPlannerChatTaskId, { activityLogLimit: 20 });
|
||||
taskContext = buildTaskPlannerContext(task as PlannerContextTask);
|
||||
} catch (taskLoadError) {
|
||||
const message = taskLoadError instanceof Error ? taskLoadError.message : String(taskLoadError);
|
||||
diagnostics.warn(`Failed to load task planner-chat context for ${taskPlannerChatTaskId}: ${message}`);
|
||||
}
|
||||
}
|
||||
/*
|
||||
FNXC:TaskDetailPlannerChat 2026-06-30-23:58:
|
||||
Task-detail planner Chat sessions use a synthetic task-planner agent id and the planning-model lane. Include compact task state, dependency, comment, step, and recent activity context so the planner can answer status questions; convert only explicit steering intent through the scoped steering tool and use `fn_ask_question` for ambiguous clarification.
|
||||
*/
|
||||
systemPrompt = `${systemPrompt}\n\n## Task Planner Chat Context\nYou are answering in the task detail Chat tab for a single Fusion task. Use the task context below to answer status, dependency, recent-activity, and planning questions. If the user explicitly asks you to tell, steer, instruct, or update the executor/reviewer, call \`fn_task_planner_add_steering\` with the concise steering text. If the user's intent might be either conversation or steering, call \`fn_ask_question\` to clarify before adding steering. Do not add steering for ordinary questions or brainstorming.\n\n${taskContext}`;
|
||||
}
|
||||
|
||||
if (agent) {
|
||||
const runtimeModel = extractRuntimeModel(agent.runtimeConfig);
|
||||
if (runtimeModel.provider && runtimeModel.modelId) {
|
||||
@@ -1825,8 +1952,11 @@ export class ChatManager {
|
||||
const artifactTools = this.taskStore
|
||||
? createChatArtifactTools(this.taskStore, this.messageStore)
|
||||
: [];
|
||||
const taskPlannerSteeringTools = this.taskStore && taskPlannerChatTaskId
|
||||
? [createTaskPlannerSteeringTool(this.taskStore, taskPlannerChatTaskId)]
|
||||
: [];
|
||||
|
||||
const customTools = [createAskQuestionTool(), ...messagingTools, ...workflowTools, ...documentTools, ...artifactTools];
|
||||
const customTools = [createAskQuestionTool(), ...taskPlannerSteeringTools, ...messagingTools, ...workflowTools, ...documentTools, ...artifactTools];
|
||||
|
||||
const sessionOptions = {
|
||||
cwd: this.rootDir,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { resolveProjectChatContext } from "../chat-project-services.js";
|
||||
import { CHAT_ALLOWED_MIME_TYPES, CHAT_MAX_ATTACHMENT_SIZE } from "./chat-attachment-config.js";
|
||||
import { rateLimit, RATE_LIMITS } from "../rate-limit.js";
|
||||
import { writeSSEEvent, type SessionBufferedEvent } from "../sse-buffer.js";
|
||||
import { TASK_PLANNER_CHAT_AGENT_ID_PREFIX } from "../chat.js";
|
||||
import type { ApiRoutesContext } from "./types.js";
|
||||
import { getOrCreateScopedChatManager, getOrCreateScopedChatStore } from "../chat-project-services.js";
|
||||
import { getOrCreateProjectStore } from "../project-store-resolver.js";
|
||||
@@ -116,6 +117,73 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
||||
const chatStore = getOrCreateScopedChatStore(projectStore);
|
||||
return getOrCreateScopedChatManager(projectStore, chatStore, options?.pluginRunner);
|
||||
}
|
||||
function validateModelPair(modelProvider: unknown, modelId: unknown): { modelProvider?: string; modelId?: string } {
|
||||
let normalizedProvider: string | undefined;
|
||||
let normalizedModelId: string | undefined;
|
||||
try {
|
||||
normalizedProvider = validateOptionalModelField(modelProvider, "modelProvider");
|
||||
normalizedModelId = validateOptionalModelField(modelId, "modelId");
|
||||
} catch (err) {
|
||||
throw badRequest(err instanceof Error ? err.message : "Invalid model override");
|
||||
}
|
||||
if (Boolean(normalizedProvider) !== Boolean(normalizedModelId)) {
|
||||
throw badRequest("Both modelProvider and modelId must be provided together, or neither should be provided");
|
||||
}
|
||||
return normalizedProvider && normalizedModelId
|
||||
? { modelProvider: normalizedProvider, modelId: normalizedModelId }
|
||||
: {};
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:TaskDetailPlannerChat 2026-06-30-22:30:
|
||||
Task planner Chat uses a synthetic task-scoped chat target (`task-planner:<taskId>`) so the dashboard can persist/resume a conversation without binding it to an executor/reviewer agent or the Activity steering-comment pipeline. The route validates the task in the scoped project store and stores the effective planning model override on the session.
|
||||
*/
|
||||
router.post("/chat/task-planner/:taskId/session", rateLimit(RATE_LIMITS.mutation), async (req, res) => {
|
||||
try {
|
||||
const rawTaskId = req.params.taskId;
|
||||
const taskId = typeof rawTaskId === "string" ? rawTaskId.trim() : "";
|
||||
if (!taskId) {
|
||||
throw badRequest("taskId is required");
|
||||
}
|
||||
|
||||
const { modelProvider, modelId } = validateModelPair(req.body?.modelProvider, req.body?.modelId);
|
||||
const { store: scopedStore, projectId } = await getProjectContext(req);
|
||||
const { chatStore } = await resolveScopedChatStore(projectId);
|
||||
const task = await scopedStore.getTask(taskId).catch(() => null);
|
||||
if (!task) {
|
||||
throw notFound(`Task ${taskId} not found`);
|
||||
}
|
||||
|
||||
const agentId = `${TASK_PLANNER_CHAT_AGENT_ID_PREFIX}${task.id}`;
|
||||
const existing = chatStore.findLatestActiveSessionForTarget({
|
||||
agentId,
|
||||
...(projectId ? { projectId } : {}),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
const session = modelProvider && modelId
|
||||
? chatStore.updateSession(existing.id, { modelProvider, modelId })
|
||||
: existing;
|
||||
res.json({ session });
|
||||
return;
|
||||
}
|
||||
|
||||
const session = chatStore.createSession({
|
||||
agentId,
|
||||
title: `${task.id} planner chat`,
|
||||
projectId: projectId ?? null,
|
||||
modelProvider: modelProvider ?? null,
|
||||
modelId: modelId ?? null,
|
||||
});
|
||||
res.status(201).json({ session });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err, "Failed to create task planner chat session");
|
||||
}
|
||||
});
|
||||
|
||||
// ── Chat Routes ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user