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:
gsxdsm
2026-06-13 02:23:27 -07:00
parent ae1a889d48
commit 34ada00f80
9 changed files with 414 additions and 47 deletions

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

View File

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

View File

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

View File

@@ -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" ? (

View File

@@ -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");
});
});

View File

@@ -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: [] }),

View File

@@ -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");

View File

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

View File

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