FN-6345: show user steering messages in task chat
Display user steering comments alongside agent output and wake immediate-response agents when new messages are sent. - Render persisted and optimistic user messages in the task chat transcript with mobile styling. - Propagate updated task detail data after sends and roll back optimistic messages on failures. - Cover steering route validation, immediate heartbeat wakeups, chat rendering, and workflow alias round-trips. - Add a patch changeset for the published CLI package. Files changed: .changeset/fn-6345-task-chat-user-messages.md | 5 + packages/dashboard/app/components/TaskChatTab.css | 32 ++++ packages/dashboard/app/components/TaskChatTab.tsx | 170 ++++++++++++++++----- .../dashboard/app/components/TaskDetailModal.tsx | 8 +- .../app/components/__tests__/TaskChatTab.test.tsx | 150 +++++++++++++++++- .../app/components/__tests__/board-mobile.test.tsx | 2 +- .../__tests__/workflow-flow-mapping.test.ts | 4 +- .../app/components/workflow-flow-mapping.ts | 4 +- .../src/__tests__/routes-tasks-ops.test.ts | 86 +++++++++++ 9 files changed, 414 insertions(+), 47 deletions(-) Fusion-Task-Id: FN-6345 Fusion-Task-Lineage: 0355cc67-bd23-4e90-9034-bbaae277cfd6
This commit is contained in:
5
.changeset/fn-6345-task-chat-user-messages.md
Normal file
5
.changeset/fn-6345-task-chat-user-messages.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Show user-sent task-detail Chat steering messages as You bubbles and keep them visible after steering requests persist.
|
||||
@@ -65,6 +65,19 @@
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.task-chat-user-group {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.task-chat-user-header {
|
||||
padding-inline: var(--space-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.task-chat-entry {
|
||||
min-width: 0;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
@@ -75,6 +88,13 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.task-chat-entry--user {
|
||||
max-width: min(100%, calc(var(--space-2xl) * 18));
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--surface));
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.task-chat-tool-group,
|
||||
.task-chat-thinking {
|
||||
min-width: 0;
|
||||
@@ -274,6 +294,18 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-chat-user-group {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.task-chat-user-header {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.task-chat-entry--user {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.task-chat-tool-group-summary,
|
||||
.task-chat-thinking-summary {
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AgentLogEntry, AgentRole, Task, TaskDetail } from "@fusion/core";
|
||||
import type { AgentLogEntry, AgentRole, SteeringComment, Task, TaskDetail } from "@fusion/core";
|
||||
import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
@@ -19,15 +19,16 @@ interface TaskChatTabProps {
|
||||
active: boolean;
|
||||
addToast: (msg: string, type?: ToastType) => void;
|
||||
sessionLive?: boolean;
|
||||
onTaskUpdated?: (task: Task) => void;
|
||||
}
|
||||
|
||||
type AgentLogRole = AgentRole | undefined;
|
||||
|
||||
interface AgentLogGroup {
|
||||
role: AgentLogRole;
|
||||
label: string;
|
||||
entries: AgentLogEntry[];
|
||||
}
|
||||
type UserChatMessage = Pick<SteeringComment, "id" | "text" | "createdAt"> & { optimistic?: boolean };
|
||||
|
||||
type TaskChatTranscriptItem =
|
||||
| { kind: "agent"; role: AgentLogRole; label: string; entries: AgentLogEntry[] }
|
||||
| { kind: "user"; message: UserChatMessage };
|
||||
|
||||
type TaskChatSegment =
|
||||
| { kind: "tool"; entries: AgentLogEntry[]; startIndex: number }
|
||||
@@ -83,16 +84,63 @@ function getEntryKey(entry: AgentLogEntry, index: number): string {
|
||||
return [entry.taskId, entry.timestamp, entry.agent ?? "agent", entry.type, index].join(":");
|
||||
}
|
||||
|
||||
function groupEntriesByAgent(entries: AgentLogEntry[]): AgentLogGroup[] {
|
||||
return entries.reduce<AgentLogGroup[]>((groups, entry) => {
|
||||
const previousGroup = groups[groups.length - 1];
|
||||
const role = entry.agent;
|
||||
if (previousGroup && previousGroup.role === role) {
|
||||
previousGroup.entries.push(entry);
|
||||
return groups;
|
||||
function getTimestampMs(value: string): number {
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function getUserMessageDedupKey(message: Pick<SteeringComment, "id" | "text" | "createdAt">): string {
|
||||
return message.id ? `id:${message.id}` : `fallback:${message.text}:${message.createdAt}`;
|
||||
}
|
||||
|
||||
function getUserMessageFallbackKey(message: Pick<SteeringComment, "text" | "createdAt">): string {
|
||||
return `fallback:${message.text}:${message.createdAt}`;
|
||||
}
|
||||
|
||||
function mergeUserMessages(persistedComments: readonly SteeringComment[] | undefined, optimisticMessages: readonly UserChatMessage[]): UserChatMessage[] {
|
||||
const messages: UserChatMessage[] = [];
|
||||
const seen = new Set<string>();
|
||||
const seenFallbacks = new Set<string>();
|
||||
const addMessage = (message: UserChatMessage) => {
|
||||
const idKey = getUserMessageDedupKey(message);
|
||||
const fallbackKey = getUserMessageFallbackKey(message);
|
||||
if (seen.has(idKey) || seenFallbacks.has(fallbackKey)) return;
|
||||
seen.add(idKey);
|
||||
seenFallbacks.add(fallbackKey);
|
||||
messages.push(message);
|
||||
};
|
||||
|
||||
for (const comment of persistedComments ?? []) {
|
||||
if (comment.author !== "user") continue;
|
||||
addMessage({ id: comment.id, text: comment.text, createdAt: comment.createdAt });
|
||||
}
|
||||
for (const message of optimisticMessages) {
|
||||
addMessage(message);
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
function buildTranscriptItems(entries: readonly AgentLogEntry[], userMessages: readonly UserChatMessage[]): TaskChatTranscriptItem[] {
|
||||
const orderedItems = [
|
||||
...entries.map((entry, index) => ({ kind: "agent" as const, entry, index, timestamp: getTimestampMs(entry.timestamp) })),
|
||||
...userMessages.map((message, index) => ({ kind: "user" as const, message, index, timestamp: getTimestampMs(message.createdAt) })),
|
||||
].sort((a, b) => a.timestamp - b.timestamp || a.index - b.index || (a.kind === "agent" ? -1 : 1));
|
||||
|
||||
return orderedItems.reduce<TaskChatTranscriptItem[]>((items, item) => {
|
||||
if (item.kind === "user") {
|
||||
items.push({ kind: "user", message: item.message });
|
||||
return items;
|
||||
}
|
||||
groups.push({ role, label: getRoleLabel(role), entries: [entry] });
|
||||
return groups;
|
||||
|
||||
const previousItem = items[items.length - 1];
|
||||
const role = item.entry.agent;
|
||||
if (previousItem?.kind === "agent" && previousItem.role === role) {
|
||||
previousItem.entries.push(item.entry);
|
||||
return items;
|
||||
}
|
||||
items.push({ kind: "agent", role, label: getRoleLabel(role), entries: [item.entry] });
|
||||
return items;
|
||||
}, []);
|
||||
}
|
||||
|
||||
@@ -338,10 +386,28 @@ function TaskChatSegmentView({ segment }: { segment: TaskChatSegment }) {
|
||||
return <TaskChatText entries={segment.entries} />;
|
||||
}
|
||||
|
||||
export function TaskChatTab({ task, projectId, active, addToast, sessionLive }: TaskChatTabProps) {
|
||||
function TaskChatUserMessage({ message }: { message: UserChatMessage }) {
|
||||
return (
|
||||
<section className="task-chat-user-group" aria-label="You message">
|
||||
<div className="task-chat-user-header">
|
||||
<div className="task-chat-role-label">You</div>
|
||||
</div>
|
||||
<article className="task-chat-entry task-chat-entry--user" data-testid="task-chat-entry-user">
|
||||
<div className="markdown-body task-chat-markdown">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
{message.text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskChatTab({ task, projectId, active, addToast, sessionLive, onTaskUpdated }: TaskChatTabProps) {
|
||||
const { entries, loading } = useAgentLogs(task.id, active, projectId);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [optimisticMessages, setOptimisticMessages] = useState<UserChatMessage[]>([]);
|
||||
const transcriptRef = useRef<HTMLDivElement>(null);
|
||||
const previousEntryCountRef = useRef(0);
|
||||
const previousScrollHeightRef = useRef(0);
|
||||
@@ -349,7 +415,12 @@ export function TaskChatTab({ task, projectId, active, addToast, sessionLive }:
|
||||
const anchorFrameRef = useRef<number | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const groups = useMemo(() => groupEntriesByAgent(entries), [entries]);
|
||||
const userMessages = useMemo(
|
||||
() => mergeUserMessages(task.steeringComments, optimisticMessages),
|
||||
[optimisticMessages, task.steeringComments],
|
||||
);
|
||||
const transcriptItems = useMemo(() => buildTranscriptItems(entries, userMessages), [entries, userMessages]);
|
||||
const transcriptItemCount = entries.length + userMessages.length;
|
||||
const activeSession = isActiveAgentSession(task, { sessionLive });
|
||||
const canSend = activeSession && draft.trim().length > 0 && !sending;
|
||||
|
||||
@@ -414,43 +485,43 @@ export function TaskChatTab({ task, projectId, active, addToast, sessionLive }:
|
||||
const container = transcriptRef.current;
|
||||
const wasActive = previousActiveRef.current;
|
||||
previousActiveRef.current = active;
|
||||
if (!container || !active || entries.length === 0) return;
|
||||
if (!container || !active || transcriptItemCount === 0) return;
|
||||
|
||||
const becameActive = !wasActive;
|
||||
const receivedInitialEntries = previousEntryCountRef.current === 0;
|
||||
if (!becameActive && !receivedInitialEntries) return;
|
||||
const receivedInitialItems = previousEntryCountRef.current === 0;
|
||||
if (!becameActive && !receivedInitialItems) return;
|
||||
|
||||
anchorTranscriptToBottom(container);
|
||||
previousEntryCountRef.current = entries.length;
|
||||
previousEntryCountRef.current = transcriptItemCount;
|
||||
previousScrollHeightRef.current = container.scrollHeight;
|
||||
|
||||
return () => {
|
||||
cancelAnchorTranscriptFrame();
|
||||
};
|
||||
}, [active, anchorTranscriptToBottom, cancelAnchorTranscriptFrame, entries.length]);
|
||||
}, [active, anchorTranscriptToBottom, cancelAnchorTranscriptFrame, transcriptItemCount]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = transcriptRef.current;
|
||||
if (!container) return;
|
||||
|
||||
if (!active) {
|
||||
previousEntryCountRef.current = entries.length;
|
||||
previousEntryCountRef.current = transcriptItemCount;
|
||||
previousScrollHeightRef.current = container.scrollHeight;
|
||||
return;
|
||||
}
|
||||
|
||||
const previousCount = previousEntryCountRef.current;
|
||||
const previousScrollHeight = previousScrollHeightRef.current || container.scrollHeight;
|
||||
if (entries.length > previousCount) {
|
||||
if (transcriptItemCount > previousCount) {
|
||||
const shouldFollow = previousCount === 0 || previousScrollHeight - (container.scrollTop + container.clientHeight) <= BOTTOM_FOLLOW_THRESHOLD;
|
||||
if (shouldFollow) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
previousEntryCountRef.current = entries.length;
|
||||
previousEntryCountRef.current = transcriptItemCount;
|
||||
previousScrollHeightRef.current = container.scrollHeight;
|
||||
}, [active, entries]);
|
||||
}, [active, transcriptItemCount]);
|
||||
|
||||
const handleTranscriptScroll = useCallback(() => {
|
||||
const container = transcriptRef.current;
|
||||
@@ -463,16 +534,35 @@ export function TaskChatTab({ task, projectId, active, addToast, sessionLive }:
|
||||
const text = draft.trim();
|
||||
if (!text || !activeSession || sending) return;
|
||||
|
||||
const optimisticMessage: UserChatMessage = {
|
||||
id: `optimistic-${task.id}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
text,
|
||||
createdAt: new Date().toISOString(),
|
||||
optimistic: true,
|
||||
};
|
||||
setOptimisticMessages((current) => [...current, optimisticMessage]);
|
||||
setSending(true);
|
||||
try {
|
||||
await addSteeringComment(task.id, text, projectId);
|
||||
const updatedTask = await addSteeringComment(task.id, text, projectId);
|
||||
const persistedComment = updatedTask.steeringComments
|
||||
?.filter((comment) => comment.author === "user" && comment.text === text)
|
||||
.at(-1);
|
||||
if (persistedComment) {
|
||||
setOptimisticMessages((current) => current.map((message) => (
|
||||
message.id === optimisticMessage.id
|
||||
? { id: persistedComment.id, text: persistedComment.text, createdAt: persistedComment.createdAt, optimistic: true }
|
||||
: message
|
||||
)));
|
||||
}
|
||||
onTaskUpdated?.(updatedTask);
|
||||
setDraft("");
|
||||
} catch (error) {
|
||||
setOptimisticMessages((current) => current.filter((message) => message.id !== optimisticMessage.id));
|
||||
addToast(`Unable to send message: ${getErrorMessage(error)}`, "error");
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, [activeSession, addToast, draft, projectId, sending, task.id]);
|
||||
}, [activeSession, addToast, draft, onTaskUpdated, projectId, sending, task.id]);
|
||||
|
||||
const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
||||
@@ -489,28 +579,32 @@ export function TaskChatTab({ task, projectId, active, addToast, sessionLive }:
|
||||
aria-live="polite"
|
||||
data-testid="task-chat-transcript"
|
||||
>
|
||||
{loading && entries.length === 0 ? (
|
||||
{loading && transcriptItemCount === 0 ? (
|
||||
<div className="task-chat-empty" role="status">
|
||||
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||
<span>Loading agent output…</span>
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
) : transcriptItemCount === 0 ? (
|
||||
<div className="task-chat-empty">No agent output yet. Live messages from Planner, Executor, Reviewer, and Merger agents will appear here.</div>
|
||||
) : (
|
||||
groups.map((group, groupIndex) => {
|
||||
transcriptItems.map((item, itemIndex) => {
|
||||
if (item.kind === "user") {
|
||||
return <TaskChatUserMessage key={`user-${item.message.id}-${itemIndex}`} message={item.message} />;
|
||||
}
|
||||
|
||||
const avatarAgent = {
|
||||
id: group.role ?? "agent",
|
||||
name: group.label,
|
||||
icon: getRoleIcon(group.role),
|
||||
id: item.role ?? "agent",
|
||||
name: item.label,
|
||||
icon: getRoleIcon(item.role),
|
||||
};
|
||||
const segments = segmentGroupEntries(group.entries);
|
||||
const segments = segmentGroupEntries(item.entries);
|
||||
return (
|
||||
<section className="task-chat-group" key={`${group.role ?? "agent"}-${groupIndex}`} aria-label={`${group.label} messages`}>
|
||||
<section className="task-chat-group" key={`${item.role ?? "agent"}-${itemIndex}`} aria-label={`${item.label} messages`}>
|
||||
<header className="task-chat-group-header">
|
||||
<AgentAvatar agent={avatarAgent} className="task-chat-avatar" />
|
||||
<div>
|
||||
<div className="task-chat-role-label">{group.label}</div>
|
||||
<div className="task-chat-group-meta">{group.entries.length === 1 ? "1 entry" : `${group.entries.length} entries`}</div>
|
||||
<div className="task-chat-role-label">{item.label}</div>
|
||||
<div className="task-chat-group-meta">{item.entries.length === 1 ? "1 entry" : `${item.entries.length} entries`}</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="task-chat-group-bubbles">
|
||||
|
||||
@@ -2516,6 +2516,11 @@ export function TaskDetailContent({
|
||||
overlapBlockerTask && (overlapBlockerTask.column === "in-progress" || overlapBlockerTask.column === "in-review"),
|
||||
);
|
||||
|
||||
const handleChatTaskUpdated = useCallback((updatedTask: Task) => {
|
||||
setFullDetail((prev) => prev ? ({ ...prev, ...updatedTask } as TaskDetail) : (updatedTask as TaskDetail));
|
||||
onTaskUpdated?.(updatedTask);
|
||||
}, [onTaskUpdated]);
|
||||
|
||||
const assignedAgentLabel = assignedAgent?.name ?? task.assignedAgentId ?? null;
|
||||
const detailProviders = useMemo(() => {
|
||||
const providers: string[] = [];
|
||||
@@ -3126,11 +3131,12 @@ export function TaskDetailContent({
|
||||
) : activeTab === "chat" ? (
|
||||
<div className="detail-section">
|
||||
<TaskChatTab
|
||||
task={task}
|
||||
task={workingTask}
|
||||
projectId={projectId}
|
||||
active={activeTab === "chat"}
|
||||
addToast={addToast}
|
||||
sessionLive={isCliSessionLive(cliSession)}
|
||||
onTaskUpdated={handleChatTaskUpdated}
|
||||
/>
|
||||
</div>
|
||||
) : activeTab === "logs" ? (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||
import { act, render, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
@@ -61,6 +61,26 @@ function makeEntry(overrides: Partial<AgentLogEntry>): AgentLogEntry {
|
||||
} as AgentLogEntry;
|
||||
}
|
||||
|
||||
function makeSteeringComment(overrides: Partial<NonNullable<Task["steeringComments"]>[number]> = {}): NonNullable<Task["steeringComments"]>[number] {
|
||||
return {
|
||||
id: "steer-1",
|
||||
text: "Persisted user guidance",
|
||||
createdAt: "2026-06-12T00:00:01.000Z",
|
||||
author: "user",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve;
|
||||
reject = promiseReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function mockLogs(entries: AgentLogEntry[] = [], loading = false) {
|
||||
mockedUseAgentLogs.mockReturnValue({
|
||||
entries,
|
||||
@@ -666,6 +686,114 @@ describe("TaskChatTab", () => {
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("renders a sent user message in the chat transcript", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockLogs([
|
||||
makeEntry({ agent: "executor", text: "I am checking the failure", timestamp: "2026-06-12T00:00:00.000Z" }),
|
||||
]);
|
||||
const send = deferred<Task>();
|
||||
mockedAddSteeringComment.mockReturnValue(send.promise);
|
||||
render(<TaskChatTab task={makeTask({ column: "in-progress", assignedAgentId: "agent-1" })} projectId="project-1" active addToast={vi.fn()} />);
|
||||
|
||||
const input = screen.getByLabelText("Message active agent session");
|
||||
await user.type(input, "Please inspect the failing test");
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
const transcript = screen.getByTestId("task-chat-transcript");
|
||||
expect(within(transcript).getByText("You")).toBeVisible();
|
||||
expect(within(transcript).getByText("Please inspect the failing test")).toBeVisible();
|
||||
expect(within(transcript).getByTestId("task-chat-entry-user")).toBeVisible();
|
||||
expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", "Please inspect the failing test", "project-1");
|
||||
|
||||
await act(async () => {
|
||||
send.resolve(makeTask({ steeringComments: [makeSteeringComment({ id: "steer-sent", text: "Please inspect the failing test" })] }));
|
||||
await send.promise;
|
||||
});
|
||||
|
||||
expect(within(transcript).getByText("Please inspect the failing test")).toBeVisible();
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("renders persisted user steering comments but not agent-authored steering comments", () => {
|
||||
render(
|
||||
<TaskChatTab
|
||||
task={makeTask({
|
||||
steeringComments: [
|
||||
makeSteeringComment({ id: "user-steer", text: "Persisted user guidance", author: "user" }),
|
||||
makeSteeringComment({ id: "agent-steer", text: "Internal agent note", author: "agent" }),
|
||||
],
|
||||
})}
|
||||
active
|
||||
addToast={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const transcript = screen.getByTestId("task-chat-transcript");
|
||||
expect(within(transcript).getByText("You")).toBeVisible();
|
||||
expect(within(transcript).getByText("Persisted user guidance")).toBeVisible();
|
||||
expect(within(transcript).queryByText("Internal agent note")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("deduplicates optimistic messages when matching persisted comments arrive", async () => {
|
||||
const user = userEvent.setup();
|
||||
const persistedComment = makeSteeringComment({ id: "steer-dedup", text: "Do not duplicate me" });
|
||||
mockedAddSteeringComment.mockResolvedValue(makeTask({ steeringComments: [persistedComment] }));
|
||||
const { rerender } = render(<TaskChatTab task={makeTask()} projectId="project-1" active addToast={vi.fn()} />);
|
||||
|
||||
await user.type(screen.getByLabelText("Message active agent session"), "Do not duplicate me");
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
await waitFor(() => {
|
||||
expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", "Do not duplicate me", "project-1");
|
||||
});
|
||||
|
||||
rerender(<TaskChatTab task={makeTask({ steeringComments: [persistedComment] })} projectId="project-1" active addToast={vi.fn()} />);
|
||||
|
||||
expect(within(screen.getByTestId("task-chat-transcript")).getAllByText("Do not duplicate me")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("deduplicates persisted user comments by fallback text and timestamp", () => {
|
||||
render(
|
||||
<TaskChatTab
|
||||
task={makeTask({
|
||||
steeringComments: [
|
||||
makeSteeringComment({ id: "", text: "Fallback duplicate", createdAt: "2026-06-12T00:00:04.000Z" }),
|
||||
makeSteeringComment({ id: "", text: "Fallback duplicate", createdAt: "2026-06-12T00:00:04.000Z" }),
|
||||
],
|
||||
})}
|
||||
active
|
||||
addToast={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(within(screen.getByTestId("task-chat-transcript")).getAllByText("Fallback duplicate")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("interleaves user messages chronologically with agent output", () => {
|
||||
mockLogs([
|
||||
makeEntry({ agent: "executor", text: "first agent output", timestamp: "2026-06-12T00:00:00.000Z" }),
|
||||
makeEntry({ agent: "executor", text: "second agent output", timestamp: "2026-06-12T00:00:02.000Z" }),
|
||||
]);
|
||||
|
||||
render(
|
||||
<TaskChatTab
|
||||
task={makeTask({ steeringComments: [makeSteeringComment({ text: "middle user guidance", createdAt: "2026-06-12T00:00:01.000Z" })] })}
|
||||
active
|
||||
addToast={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const transcriptText = screen.getByTestId("task-chat-transcript").textContent ?? "";
|
||||
expect(transcriptText.indexOf("first agent output")).toBeLessThan(transcriptText.indexOf("middle user guidance"));
|
||||
expect(transcriptText.indexOf("middle user guidance")).toBeLessThan(transcriptText.indexOf("second agent output"));
|
||||
});
|
||||
|
||||
it.each([undefined, []])("does not render a phantom user bubble for %s steering comments", (steeringComments) => {
|
||||
render(<TaskChatTab task={makeTask({ steeringComments })} active addToast={vi.fn()} />);
|
||||
|
||||
expect(screen.queryByTestId("task-chat-entry-user")).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/No agent output yet/)).toBeVisible();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["queued", "Please continue after dispatch"],
|
||||
[undefined, "Please continue with a cleared status"],
|
||||
@@ -865,16 +993,30 @@ describe("TaskChatTab", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("surfaces send failures through addToast", async () => {
|
||||
it("rolls back optimistic messages and surfaces send failures through addToast", async () => {
|
||||
const user = userEvent.setup();
|
||||
const addToast = vi.fn();
|
||||
mockedAddSteeringComment.mockRejectedValue(new Error("network down"));
|
||||
const send = deferred<Task>();
|
||||
mockedAddSteeringComment.mockReturnValue(send.promise);
|
||||
render(<TaskChatTab task={makeTask()} active addToast={addToast} />);
|
||||
|
||||
await user.type(screen.getByLabelText("Message active agent session"), "hello");
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
const transcript = screen.getByTestId("task-chat-transcript");
|
||||
expect(within(transcript).getByTestId("task-chat-entry-user")).toBeVisible();
|
||||
expect(within(transcript).getByText("hello")).toBeVisible();
|
||||
|
||||
await act(async () => {
|
||||
send.reject(new Error("network down"));
|
||||
try {
|
||||
await send.promise;
|
||||
} catch {
|
||||
// Expected rejection drives the component rollback path.
|
||||
}
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("task-chat-entry-user")).not.toBeInTheDocument();
|
||||
expect(addToast).toHaveBeenCalledWith("Unable to send message: network down", "error");
|
||||
});
|
||||
});
|
||||
@@ -889,5 +1031,7 @@ describe("TaskChatTab", () => {
|
||||
expect(css).toContain(".task-chat-tool-group-error-count");
|
||||
expect(css).toContain(".task-chat-thinking-summary");
|
||||
expect(css).not.toContain(".task-chat-thinking-markdown + .task-chat-thinking-markdown");
|
||||
expect(css).toContain(".task-chat-user-group");
|
||||
expect(css).toContain(".task-chat-entry--user");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,9 +26,9 @@ vi.mock("../../api", () => ({
|
||||
} satisfies Partial<Settings>),
|
||||
updateGlobalSettings: vi.fn(),
|
||||
fetchAgents: vi.fn().mockResolvedValue([]),
|
||||
fetchWorkflowOptionalSteps: vi.fn().mockResolvedValue([]),
|
||||
// InlineCreateCard renders WorkflowSelector, which loads these on mount.
|
||||
fetchWorkflows: vi.fn().mockResolvedValue([]),
|
||||
fetchWorkflowOptionalSteps: vi.fn().mockResolvedValue([]),
|
||||
fetchProjectDefaultWorkflow: vi.fn().mockResolvedValue({ workflowId: null }),
|
||||
setProjectDefaultWorkflow: vi.fn().mockResolvedValue({ workflowId: null }),
|
||||
selectTaskWorkflow: vi.fn().mockResolvedValue({ workflowId: null, enabledWorkflowSteps: [] }),
|
||||
|
||||
@@ -225,9 +225,9 @@ describe("workflow-flow-mapping v2 round-trip", () => {
|
||||
};
|
||||
|
||||
const { nodes, edges } = irToFlow(v2Def(ir));
|
||||
expect(nodes.find((node) => node.id === "gate")?.type).toBe("merge");
|
||||
expect(nodes.find((node) => node.id === "gate")?.type).toBe("gate");
|
||||
expect(nodes.find((node) => node.id === "hold")?.type).toBe("hold");
|
||||
expect(nodes.find((node) => node.id === "retry")?.type).toBe("loop");
|
||||
expect(nodes.find((node) => node.id === "retry")?.type).toBe("hold");
|
||||
|
||||
const { ir: out } = flowToIr("merge aliases", nodes, edges, columnsOf(v2Def(ir)));
|
||||
if (out.version !== "v2") throw new Error("expected v2");
|
||||
|
||||
@@ -439,8 +439,8 @@ export function flowToIr(
|
||||
}
|
||||
return { id: localId, kind: "prompt", config: { ...(config ?? {}), seam: "merge" } };
|
||||
}
|
||||
if (data.kind === "foreach" || data.kind === "loop") {
|
||||
if (originalKind && originalKind !== "foreach" && originalKind !== "loop") {
|
||||
if (data.kind === "foreach" || data.kind === "loop" || originalKind === "retry-backoff") {
|
||||
if (originalKind && originalKind !== "foreach" && originalKind !== "loop" && originalKind !== "retry-backoff") {
|
||||
return { id: localId, kind: originalKind, config: config && Object.keys(config).length ? config : undefined };
|
||||
}
|
||||
// Reassemble the template from this group's children.
|
||||
|
||||
@@ -308,6 +308,92 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
|
||||
describe("POST /tasks/:id/steer", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore({
|
||||
getFusionDir: vi.fn().mockReturnValue("/fake/root/.fusion"),
|
||||
} as Partial<TaskStore>);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function buildApp(heartbeatMonitor?: NonNullable<Parameters<typeof createApiRoutes>[1]>["heartbeatMonitor"]) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store, heartbeatMonitor ? { heartbeatMonitor } : undefined));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("records user steering comments and wakes the assigned immediate-response agent", async () => {
|
||||
const updatedTask = {
|
||||
...FAKE_TASK_DETAIL,
|
||||
id: "FN-001",
|
||||
column: "in-progress" as const,
|
||||
assignedAgentId: "agent-1",
|
||||
steeringComments: [{ id: "steer-1", text: "Please continue", author: "user" as const, createdAt: "2026-06-12T00:00:00.000Z" }],
|
||||
};
|
||||
const executeHeartbeat = vi.fn().mockResolvedValue({ id: "run-1" });
|
||||
const heartbeatMonitor = {
|
||||
rootDir: "/fake/root",
|
||||
startRun: vi.fn(),
|
||||
executeHeartbeat,
|
||||
stopRun: vi.fn(),
|
||||
};
|
||||
vi.spyOn(AgentStore.prototype, "init").mockResolvedValue(undefined);
|
||||
vi.spyOn(AgentStore.prototype, "getAgent").mockResolvedValue({
|
||||
id: "agent-1",
|
||||
name: "Executor",
|
||||
role: "executor",
|
||||
runtimeConfig: { messageResponseMode: "immediate" },
|
||||
} as Awaited<ReturnType<AgentStore["getAgent"]>>);
|
||||
vi.spyOn(AgentStore.prototype, "getActiveHeartbeatRun").mockResolvedValue(null);
|
||||
(store.addSteeringComment as ReturnType<typeof vi.fn>).mockResolvedValue(updatedTask);
|
||||
|
||||
const res = await REQUEST(buildApp(heartbeatMonitor), "POST", "/api/tasks/FN-001/steer", JSON.stringify({ text: "Please continue" }), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.addSteeringComment).toHaveBeenCalledWith("FN-001", "Please continue", "user");
|
||||
expect(res.body.steeringComments).toEqual(updatedTask.steeringComments);
|
||||
await vi.waitFor(() => {
|
||||
expect(executeHeartbeat).toHaveBeenCalledWith(expect.objectContaining({
|
||||
agentId: "agent-1",
|
||||
source: "on_demand",
|
||||
taskId: "FN-001",
|
||||
triggerDetail: "steering-comment",
|
||||
triggeringCommentIds: ["steer-1"],
|
||||
triggeringCommentType: "steering",
|
||||
contextSnapshot: expect.objectContaining({
|
||||
taskId: "FN-001",
|
||||
triggerDetail: "steering-comment",
|
||||
triggeringCommentIds: ["steer-1"],
|
||||
triggeringCommentType: "steering",
|
||||
wakeReason: "on_demand",
|
||||
}),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["", "text is required and must be a string"],
|
||||
["x".repeat(2001), "text must be between 1 and 2000 characters"],
|
||||
])("rejects invalid steering text %#", async (text, expectedError) => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/FN-001/steer", JSON.stringify({ text }), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain(expectedError);
|
||||
expect(store.addSteeringComment).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("POST /tasks/:id/retry", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user