diff --git a/.changeset/fn-6345-task-chat-user-messages.md b/.changeset/fn-6345-task-chat-user-messages.md new file mode 100644 index 0000000000..9698efabe2 --- /dev/null +++ b/.changeset/fn-6345-task-chat-user-messages.md @@ -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. diff --git a/packages/dashboard/app/components/TaskChatTab.css b/packages/dashboard/app/components/TaskChatTab.css index d134830503..c22787397d 100644 --- a/packages/dashboard/app/components/TaskChatTab.css +++ b/packages/dashboard/app/components/TaskChatTab.css @@ -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; diff --git a/packages/dashboard/app/components/TaskChatTab.tsx b/packages/dashboard/app/components/TaskChatTab.tsx index a13a909226..b4f3d89faf 100644 --- a/packages/dashboard/app/components/TaskChatTab.tsx +++ b/packages/dashboard/app/components/TaskChatTab.tsx @@ -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 & { 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((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): string { + return message.id ? `id:${message.id}` : `fallback:${message.text}:${message.createdAt}`; +} + +function getUserMessageFallbackKey(message: Pick): string { + return `fallback:${message.text}:${message.createdAt}`; +} + +function mergeUserMessages(persistedComments: readonly SteeringComment[] | undefined, optimisticMessages: readonly UserChatMessage[]): UserChatMessage[] { + const messages: UserChatMessage[] = []; + const seen = new Set(); + const seenFallbacks = new Set(); + 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((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 ; } -export function TaskChatTab({ task, projectId, active, addToast, sessionLive }: TaskChatTabProps) { +function TaskChatUserMessage({ message }: { message: UserChatMessage }) { + return ( +
+
+
You
+
+
+
+ + {message.text} + +
+
+
+ ); +} + +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([]); const transcriptRef = useRef(null); const previousEntryCountRef = useRef(0); const previousScrollHeightRef = useRef(0); @@ -349,7 +415,12 @@ export function TaskChatTab({ task, projectId, active, addToast, sessionLive }: const anchorFrameRef = useRef(null); const textareaRef = useRef(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) => { 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 ? (
- ) : entries.length === 0 ? ( + ) : transcriptItemCount === 0 ? (
No agent output yet. Live messages from Planner, Executor, Reviewer, and Merger agents will appear here.
) : ( - groups.map((group, groupIndex) => { + transcriptItems.map((item, itemIndex) => { + if (item.kind === "user") { + return ; + } + 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 ( -
+
-
{group.label}
-
{group.entries.length === 1 ? "1 entry" : `${group.entries.length} entries`}
+
{item.label}
+
{item.entries.length === 1 ? "1 entry" : `${item.entries.length} entries`}
diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index c277d10935..5ae280431f 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -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" ? (
) : activeTab === "logs" ? ( diff --git a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx index 3412c09565..8549c7b53f 100644 --- a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx @@ -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 { } as AgentLogEntry; } +function makeSteeringComment(overrides: Partial[number]> = {}): NonNullable[number] { + return { + id: "steer-1", + text: "Persisted user guidance", + createdAt: "2026-06-12T00:00:01.000Z", + author: "user", + ...overrides, + }; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((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(); + mockedAddSteeringComment.mockReturnValue(send.promise); + render(); + + 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( + , + ); + + 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(); + + 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(); + + 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( + , + ); + + 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( + , + ); + + 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(); + + 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(); + mockedAddSteeringComment.mockReturnValue(send.promise); render(); 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"); }); }); diff --git a/packages/dashboard/app/components/__tests__/board-mobile.test.tsx b/packages/dashboard/app/components/__tests__/board-mobile.test.tsx index 33bf751dd1..614627a987 100644 --- a/packages/dashboard/app/components/__tests__/board-mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/board-mobile.test.tsx @@ -26,9 +26,9 @@ vi.mock("../../api", () => ({ } satisfies Partial), 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: [] }), diff --git a/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts b/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts index 1ffb1c4a8d..77501e8cf0 100644 --- a/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts +++ b/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts @@ -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"); diff --git a/packages/dashboard/app/components/workflow-flow-mapping.ts b/packages/dashboard/app/components/workflow-flow-mapping.ts index 2d5edddff2..06c6c929ad 100644 --- a/packages/dashboard/app/components/workflow-flow-mapping.ts +++ b/packages/dashboard/app/components/workflow-flow-mapping.ts @@ -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. diff --git a/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts b/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts index 2472ce411f..2fb489fed0 100644 --- a/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts +++ b/packages/dashboard/src/__tests__/routes-tasks-ops.test.ts @@ -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); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + function buildApp(heartbeatMonitor?: NonNullable[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>); + vi.spyOn(AgentStore.prototype, "getActiveHeartbeatRun").mockResolvedValue(null); + (store.addSteeringComment as ReturnType).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;