From 869974cd5a594db146be2ab0045299d9cf526809 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 1 Jul 2026 11:12:59 -0700 Subject: [PATCH] FN-7383: refresh workflows after chat authoring Chat-authored workflow changes now propagate immediately to workflow selectors and editors. - Emit workflow lifecycle SSE events when chat, planner, or room workflow tools create, update, select, configure, or delete workflows. - Force-refresh board workflow caches and the workflow editor list when workflow lifecycle events arrive. - Add cross-surface tests for chat workflow creation visibility and room/chat workflow tool event behavior. - Add a patch changeset for the published Fusion package. Files changed: .changeset/fn-7383-chat-workflow-authoring.md | 7 +++ .../workflow-selection-cross-surface.test.tsx | 23 +++++++++ packages/dashboard/app/api/legacy.ts | 9 ++-- .../app/components/WorkflowNodeEditor.tsx | 23 ++++++++- .../app/hooks/__tests__/useBoardWorkflows.test.ts | 27 +++++++--- packages/dashboard/app/hooks/useBoardWorkflows.ts | 22 +++++--- .../utils/__tests__/boardWorkflowsCache.test.ts | 12 ++++- .../dashboard/app/utils/boardWorkflowsCache.ts | 14 +++++ .../dashboard/src/__tests__/chat-manager.test.ts | 44 ++++++++++++++-- .../dashboard/src/__tests__/chat.rooms.test.ts | 44 ++++++++++++++++ packages/dashboard/src/chat.ts | 59 +++++++++++++++++++--- 11 files changed, 255 insertions(+), 29 deletions(-) Fusion-Task-Id: FN-7383 Fusion-Task-Lineage: ebe4c982-214b-402f-b829-a58ace19ffe0 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7383-chat-workflow-authoring.md | 7 +++ .../workflow-selection-cross-surface.test.tsx | 23 ++++++++ packages/dashboard/app/api/legacy.ts | 9 +-- .../app/components/WorkflowNodeEditor.tsx | 23 +++++++- .../hooks/__tests__/useBoardWorkflows.test.ts | 27 ++++++--- .../dashboard/app/hooks/useBoardWorkflows.ts | 22 +++++-- .../__tests__/boardWorkflowsCache.test.ts | 12 +++- .../app/utils/boardWorkflowsCache.ts | 14 +++++ .../src/__tests__/chat-manager.test.ts | 44 +++++++++++++- .../src/__tests__/chat.rooms.test.ts | 44 ++++++++++++++ packages/dashboard/src/chat.ts | 59 +++++++++++++++++-- 11 files changed, 255 insertions(+), 29 deletions(-) create mode 100644 .changeset/fn-7383-chat-workflow-authoring.md diff --git a/.changeset/fn-7383-chat-workflow-authoring.md b/.changeset/fn-7383-chat-workflow-authoring.md new file mode 100644 index 0000000000..168a7610b4 --- /dev/null +++ b/.changeset/fn-7383-chat-workflow-authoring.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Make chat-created workflows appear immediately across workflow selectors. +category: fix +dev: Chat workflow tools now emit workflow lifecycle SSE and workflow lists force-refresh per project. diff --git a/packages/dashboard/app/__tests__/workflow-selection-cross-surface.test.tsx b/packages/dashboard/app/__tests__/workflow-selection-cross-surface.test.tsx index 7045403e0e..9e1ab2f607 100644 --- a/packages/dashboard/app/__tests__/workflow-selection-cross-surface.test.tsx +++ b/packages/dashboard/app/__tests__/workflow-selection-cross-surface.test.tsx @@ -126,6 +126,29 @@ describe("workflow selection across dashboard surfaces", () => { expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-cross"); }); + it("shows a chat-created workflow in Header and Graph selectors after workflow lifecycle SSE", async () => { + let payload = workflowPayload({ workflows: [DEFAULT_WORKFLOW] }); + fetchBoardWorkflowsMock.mockImplementation(() => Promise.resolve(payload)); + render(); + + await waitFor(() => { + expect(screen.getByTestId("header-selection")).toHaveTextContent(DEFAULT_WORKFLOW.id); + expect(screen.getByTestId("graph-selection")).toHaveTextContent(DEFAULT_WORKFLOW.id); + }); + expect(screen.queryAllByTestId("workflow-switcher")).toHaveLength(0); + + payload = workflowPayload({ + workflows: [DEFAULT_WORKFLOW, { id: "wf-chat", name: "Chat Created", columns: [] }], + }); + const subscription = subscribeSseMock.mock.calls[0]?.[1] as { events?: Record void> }; + subscription.events?.["workflow:created"]?.(); + + await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-cross", { forceFresh: true })); + const switchers = await screen.findAllByTestId("workflow-switcher"); + fireEvent.click(switchers[0]); + expect(await screen.findByTestId("workflow-switcher-option-wf-chat")).toHaveTextContent("Chat Created"); + }); + it("keeps mounted Graph and Header workflow selections isolated while Graph filtering follows only Graph", async () => { render(); diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 36c80cbda6..ce49f781d8 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -672,8 +672,9 @@ export function updateTaskCustomFields( /** Fetch the multi-lane board metadata (U9). When the flag is OFF the server * returns `{ flagEnabled: false }` and the board renders its legacy form. */ -export function fetchBoardWorkflows(projectId?: string): Promise { - return api(withProjectId("/tasks/board-workflows", projectId)); +export function fetchBoardWorkflows(projectId?: string, options?: FetchOptions): Promise { + const path = withProjectId("/tasks/board-workflows", projectId); + return dedupe(path, () => api(path), options); } /** Manually promote a held card out of its hold column (U9). */ @@ -5332,10 +5333,10 @@ export type { } from "@fusion/core"; /** List all workflow definitions for the project. */ -export function fetchWorkflows(projectId?: string, options?: { includeDisabledBuiltins?: boolean }): Promise { +export function fetchWorkflows(projectId?: string, options?: { includeDisabledBuiltins?: boolean } & FetchOptions): Promise { const query = options?.includeDisabledBuiltins ? "?includeDisabledBuiltins=true" : ""; const path = withProjectId(`/workflows${query}`, projectId); - return dedupe(path, () => api(path)); + return dedupe(path, () => api(path), options); } /** A trait catalog entry as returned by GET /api/traits (U10). Mirrors the diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index 3970bb6db2..122dd34cc5 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -46,6 +46,7 @@ import type { DiscoveredSkill } from "../api"; import type { ToastType } from "../hooks/useToast"; import { useOverlayDismiss } from "../hooks/useOverlayDismiss"; import { useConfirm } from "../hooks/useConfirm"; +import { subscribeSse } from "../sse-bus"; /* FNXC:i18n-Localize 2026-06-20-00:00: @@ -1112,10 +1113,10 @@ function InnerEditor({ ); }, [isBuiltin, activeWorkflow, name, description, icon, nodes, edges, columns, fields, settings]); - const loadWorkflows = useCallback(async () => { + const loadWorkflows = useCallback(async (options?: { forceFresh?: boolean }) => { setLoading(true); try { - const data = await fetchWorkflows(projectId); + const data = await fetchWorkflows(projectId, options); setWorkflows(data); setActiveId((prev) => { if (prev && data.some((workflow) => workflow.id === prev)) return prev; @@ -1135,6 +1136,24 @@ function InnerEditor({ void loadWorkflows(); }, [loadWorkflows]); + useEffect(() => { + /* + FNXC:ChatWorkflowAuthoring 2026-07-01-10:55: + WorkflowNodeEditor can be open while chat/tool execution creates a workflow through ChatManager rather than this component's create/import buttons. Subscribe to workflow lifecycle SSE and force-refresh definitions so the editor list shows chat-created workflows without a hard reload or stale deduped request. + */ + const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; + const refreshFromWorkflowMutation = () => { + void loadWorkflows({ forceFresh: true }); + }; + return subscribeSse(`/api/events${query}`, { + events: { + "workflow:created": refreshFromWorkflowMutation, + "workflow:updated": refreshFromWorkflowMutation, + "workflow:deleted": refreshFromWorkflowMutation, + }, + }); + }, [loadWorkflows, projectId]); + useEffect(() => { if (!initialWorkflowId || !isMobileMode || !workflowListStageOpen) return; if (activeId !== initialWorkflowId) return; diff --git a/packages/dashboard/app/hooks/__tests__/useBoardWorkflows.test.ts b/packages/dashboard/app/hooks/__tests__/useBoardWorkflows.test.ts index a9c87c8568..41429ebd13 100644 --- a/packages/dashboard/app/hooks/__tests__/useBoardWorkflows.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useBoardWorkflows.test.ts @@ -131,24 +131,37 @@ describe("useBoardWorkflows", () => { expect(result.current.workflowOptions.map((w) => w.id)).toEqual(["wf-new"]); }); - it("an SSE workflow event re-fetches", async () => { - const deps = makeDeps(() => Promise.resolve(makePayload())); - renderHook(() => useBoardWorkflows({ projectId: "p1", ...deps })); + it("an SSE workflow event force-refreshes so chat-created workflows replace stale payloads", async () => { + let payload = makePayload(); + const deps = makeDeps(() => Promise.resolve(payload)); + const { result } = renderHook(() => useBoardWorkflows({ projectId: "p1", ...deps })); await waitFor(() => expect(deps.fetchBoardWorkflows).toHaveBeenCalledTimes(1)); + expect(typeof subscribeHandlers["workflow:created"]).toBe("function"); expect(typeof subscribeHandlers["workflow:updated"]).toBe("function"); + expect(typeof subscribeHandlers["workflow:deleted"]).toBe("function"); - await act(async () => { subscribeHandlers["workflow:updated"](); }); - expect(deps.fetchBoardWorkflows).toHaveBeenCalledTimes(2); + payload = makePayload({ + workflows: [ + { id: "wf-a", name: "Alpha", columns: [] }, + { id: "wf-chat", name: "Chat Created", columns: [] }, + ], + }); + await act(async () => { subscribeHandlers["workflow:created"](); }); + + await waitFor(() => expect(result.current.workflowOptions.map((workflow) => workflow.id)).toContain("wf-chat")); + expect(deps.fetchBoardWorkflows).toHaveBeenLastCalledWith("p1", { forceFresh: true }); + expect(deps.writeBoardWorkflowsCache).toHaveBeenLastCalledWith("p1", payload); }); - it("does not expose the Board-only aggregate sentinel as a selected workflow id", async () => { + it("preserves the Board-only aggregate sentinel while resolving a concrete fallback workflow", async () => { localStorage.setItem("kb:p1:kb-dashboard-board-workflow-selection", ALL_WORKFLOWS_BOARD_VIEW_ID); const deps = makeDeps(() => Promise.resolve(makePayload())); const { result } = renderHook(() => useBoardWorkflows({ projectId: "p1", ...deps })); await waitFor(() => expect(result.current.selectedWorkflow?.id).toBe("wf-a")); - expect(result.current.selectedWorkflowId).toBe("wf-a"); + expect(result.current.selectedWorkflowId).toBe(ALL_WORKFLOWS_BOARD_VIEW_ID); + expect(result.current.isAllWorkflowsSelected).toBe(true); expect(localStorage.getItem("kb:p1:kb-dashboard-board-workflow-selection")).toBe(ALL_WORKFLOWS_BOARD_VIEW_ID); }); diff --git a/packages/dashboard/app/hooks/useBoardWorkflows.ts b/packages/dashboard/app/hooks/useBoardWorkflows.ts index b6113ce7bb..8d2a44e469 100644 --- a/packages/dashboard/app/hooks/useBoardWorkflows.ts +++ b/packages/dashboard/app/hooks/useBoardWorkflows.ts @@ -6,6 +6,7 @@ import { } from "../api"; import { subscribeSse as defaultSubscribeSse } from "../sse-bus"; import { + clearBoardWorkflowsCache as defaultClearBoardWorkflowsCache, readBoardWorkflowsCache as defaultReadBoardWorkflowsCache, writeBoardWorkflowsCache as defaultWriteBoardWorkflowsCache, } from "../utils/boardWorkflowsCache"; @@ -37,6 +38,7 @@ export interface UseBoardWorkflowsParams { subscribeSse?: typeof defaultSubscribeSse; readBoardWorkflowsCache?: typeof defaultReadBoardWorkflowsCache; writeBoardWorkflowsCache?: typeof defaultWriteBoardWorkflowsCache; + clearBoardWorkflowsCache?: typeof defaultClearBoardWorkflowsCache; } export interface UseBoardWorkflowsResult { @@ -69,6 +71,7 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork subscribeSse = defaultSubscribeSse, readBoardWorkflowsCache = defaultReadBoardWorkflowsCache, writeBoardWorkflowsCache = defaultWriteBoardWorkflowsCache, + clearBoardWorkflowsCache = defaultClearBoardWorkflowsCache, } = params; const [boardWorkflowsState, setBoardWorkflowsState] = useState<{ projectId?: string; payload: BoardWorkflowsPayload } | null>(() => { @@ -106,9 +109,15 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork setBoardWorkflowsState(cached ? { projectId, payload: cached } : null); }, [projectId, shouldHydrateCache, readBoardWorkflowsCache]); - const refreshBoardWorkflows = useCallback(() => { + const refreshBoardWorkflows = useCallback((options?: { forceFresh?: boolean }) => { const seq = ++boardWorkflowsFetchSeqRef.current; - fetchBoardWorkflows(projectId) + if (options?.forceFresh) { + clearBoardWorkflowsCache(projectId); + } + const fetchPromise = options === undefined + ? fetchBoardWorkflows(projectId) + : fetchBoardWorkflows(projectId, options); + fetchPromise .then((payload) => { if (seq === boardWorkflowsFetchSeqRef.current) { setBoardWorkflowsState({ projectId, payload }); @@ -118,7 +127,7 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork .catch(() => { // Fetch failures are non-authoritative: keep the current/cache-hydrated payload so the cleanup effect does not erase durable selection. }); - }, [projectId, fetchBoardWorkflows, writeBoardWorkflowsCache]); + }, [projectId, fetchBoardWorkflows, writeBoardWorkflowsCache, clearBoardWorkflowsCache]); useEffect(() => { refreshBoardWorkflows(); @@ -128,11 +137,12 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisible); if (typeof window !== "undefined") window.addEventListener("focus", onVisible); const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; + const forceRefreshBoardWorkflows = () => refreshBoardWorkflows({ forceFresh: true }); const unsubscribe = subscribeSse(`/api/events${query}`, { events: { - "workflow:created": refreshBoardWorkflows, - "workflow:updated": refreshBoardWorkflows, - "workflow:deleted": refreshBoardWorkflows, + "workflow:created": forceRefreshBoardWorkflows, + "workflow:updated": forceRefreshBoardWorkflows, + "workflow:deleted": forceRefreshBoardWorkflows, }, }); return () => { diff --git a/packages/dashboard/app/utils/__tests__/boardWorkflowsCache.test.ts b/packages/dashboard/app/utils/__tests__/boardWorkflowsCache.test.ts index a2ff556c50..ab0feed23a 100644 --- a/packages/dashboard/app/utils/__tests__/boardWorkflowsCache.test.ts +++ b/packages/dashboard/app/utils/__tests__/boardWorkflowsCache.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { BoardWorkflowsPayload } from "../../api"; -import { readBoardWorkflowsCache, writeBoardWorkflowsCache } from "../boardWorkflowsCache"; +import { clearBoardWorkflowsCache, readBoardWorkflowsCache, writeBoardWorkflowsCache } from "../boardWorkflowsCache"; const payload: BoardWorkflowsPayload = { flagEnabled: true, @@ -36,6 +36,16 @@ describe("boardWorkflowsCache", () => { expect(readBoardWorkflowsCache("project-b")).toBeNull(); }); + it("clears only the requested project cache after out-of-component workflow mutations", () => { + writeBoardWorkflowsCache("project-a", payload); + writeBoardWorkflowsCache("project-b", { ...payload, defaultWorkflowId: "wf-b" }); + + clearBoardWorkflowsCache("project-a"); + + expect(readBoardWorkflowsCache("project-a")).toBeNull(); + expect(readBoardWorkflowsCache("project-b")).toEqual({ ...payload, defaultWorkflowId: "wf-b" }); + }); + it("returns null for missing, corrupt, or invalid entries", () => { expect(readBoardWorkflowsCache("missing")).toBeNull(); diff --git a/packages/dashboard/app/utils/boardWorkflowsCache.ts b/packages/dashboard/app/utils/boardWorkflowsCache.ts index 78c9f93baa..87e51d7205 100644 --- a/packages/dashboard/app/utils/boardWorkflowsCache.ts +++ b/packages/dashboard/app/utils/boardWorkflowsCache.ts @@ -61,3 +61,17 @@ export function writeBoardWorkflowsCache(projectId: string | undefined, payload: // Private-mode/quota failures should never prevent board rendering. } } + +export function clearBoardWorkflowsCache(projectId?: string): void { + if (typeof window === "undefined") return; + + try { + /* + FNXC:ChatWorkflowAuthoring 2026-07-01-10:55: + Chat workflow tools create/update/delete definitions outside mounted workflow UI components. Clear only the matching project's board-workflows snapshot before forced refetch so a chat-created workflow cannot be hidden by a pre-mutation session cache or leak into another project. + */ + window.sessionStorage.removeItem(cacheKey(projectId)); + } catch { + // Private-mode/quota failures should never prevent board rendering. + } +} diff --git a/packages/dashboard/src/__tests__/chat-manager.test.ts b/packages/dashboard/src/__tests__/chat-manager.test.ts index b0f7a8535c..039ebb2e63 100644 --- a/packages/dashboard/src/__tests__/chat-manager.test.ts +++ b/packages/dashboard/src/__tests__/chat-manager.test.ts @@ -26,8 +26,9 @@ import { // ── Mock Setup ────────────────────────────────────────────────────────────── // Mock summarizeTitle using vi.hoisted so it's available at module hoisting time -const { mockSummarizeTitle } = vi.hoisted(() => ({ +const { mockSummarizeTitle, mockEmitWorkflowSseEvent } = vi.hoisted(() => ({ mockSummarizeTitle: vi.fn(), + mockEmitWorkflowSseEvent: vi.fn(), })); vi.mock("@fusion/core", async (importOriginal) => ({ @@ -43,6 +44,10 @@ vi.mock("@fusion/core", async (importOriginal) => ({ }, })); +vi.mock("../sse.js", () => ({ + emitWorkflowSseEvent: mockEmitWorkflowSseEvent, +})); + // SessionManager is constructed per-chat for CLI session continuity. We don't // want tests touching the real ~/.pi sessions directory, so stub the static // methods. The test `cliSessionFile-threading` asserts call shapes. @@ -393,7 +398,7 @@ describe("ChatManager.sendMessage", () => { // mutation, settings, selection, and trait vocabulary to the agent when a // scoped task store is available. it("exposes the full workflow authoring surface to the chat agent when a task store is present", async () => { - let capturedTools: Array<{ name: string }> = []; + let capturedTools: Array<{ name: string; execute?: (...args: any[]) => Promise }> = []; __setCreateFnAgent(async (options: any) => { capturedTools = options.customTools ?? []; return { @@ -405,7 +410,19 @@ describe("ChatManager.sendMessage", () => { }; }); - const chatManager = createChatManagerWithTaskStore(); + const createWorkflowDefinition = vi.fn().mockResolvedValue({ id: "WF-chat", name: "Chat Created" }); + const chatManager = new ChatManager( + mockChatStore as any, + "/tmp/test", + mockAgentStore as any, + undefined, + undefined, + undefined, + { + createWorkflowDefinition, + getFusionDir: () => "/tmp/test/.fusion", + } as any, + ); await chatManager.sendMessage("chat-001", "Author me a workflow"); const names = capturedTools.map((t) => t.name); @@ -421,6 +438,27 @@ describe("ChatManager.sendMessage", () => { ]) { expect(names).toContain(required); } + + const createTool = capturedTools.find((tool) => tool.name === "fn_workflow_create"); + await createTool?.execute?.("call-workflow-create", { + name: "Chat Created", + ir: { + nodes: [{ id: "n1", kind: "execute", config: { cliSkipApproval: true, autoApprove: true } }], + edges: [], + columns: [], + }, + }); + + expect(createWorkflowDefinition).toHaveBeenCalledWith(expect.objectContaining({ + ir: expect.objectContaining({ + nodes: [expect.objectContaining({ config: {} })], + }), + })); + expect(mockEmitWorkflowSseEvent).toHaveBeenCalledWith( + "workflow:created", + expect.objectContaining({ id: "WF-chat", name: "Chat Created" }), + undefined, + ); }); it("exposes fn_task_document_* tools to the chat agent when a task store is present", async () => { diff --git a/packages/dashboard/src/__tests__/chat.rooms.test.ts b/packages/dashboard/src/__tests__/chat.rooms.test.ts index ec4ae1a945..d522092bfb 100644 --- a/packages/dashboard/src/__tests__/chat.rooms.test.ts +++ b/packages/dashboard/src/__tests__/chat.rooms.test.ts @@ -140,6 +140,50 @@ describe("Chat orchestration — rooms (FN-3805..FN-3811 contract)", () => { expect(createOptions.skillSelection.requestedSkillNames).not.toContain("disabled-debug"); }); + it("exposes the full workflow authoring surface to scoped room responder sessions", async () => { + mockChatStore.listRoomMembers.mockReturnValue([ + { roomId: "room-1", agentId: "agent-a", role: "member", addedAt: "2026-01-01" }, + ]); + mockAgentStore.listAgents.mockResolvedValue([ + { id: "agent-a", name: "Alpha", role: "executor", runtimeConfig: {} }, + ]); + mockAgentStore.getAgent.mockResolvedValue({ id: "agent-a", name: "Alpha", role: "executor", runtimeConfig: {} }); + let createOptions: any; + __setCreateResolvedAgentSession(async (options: any) => { + createOptions = options; + return { + session: { + prompt: vi.fn(), + dispose: vi.fn(), + state: { messages: [{ role: "assistant", content: "Room reply" }] }, + }, + } as any; + }); + + const manager = new ChatManager( + mockChatStore as any, + "/tmp", + mockAgentStore as any, + undefined, + undefined, + undefined, + { getFusionDir: () => "/tmp/.fusion" } as any, + ); + await manager.sendRoomMessage("room-1", "please author a workflow @Alpha"); + + const names = (createOptions.customTools ?? []).map((tool: { name: string }) => tool.name); + expect(names).toEqual(expect.arrayContaining([ + "fn_workflow_create", + "fn_workflow_update", + "fn_workflow_delete", + "fn_workflow_settings", + "fn_workflow_list", + "fn_workflow_get", + "fn_workflow_select", + "fn_trait_list", + ])); + }); + it("loads typed /skill commands for room responders and strips them from the room prompt", async () => { mockChatStore.listRoomMembers.mockReturnValue([ { roomId: "room-1", agentId: "agent-a", role: "member", addedAt: "2026-01-01" }, diff --git a/packages/dashboard/src/chat.ts b/packages/dashboard/src/chat.ts index ee6774b098..9d26a572ac 100644 --- a/packages/dashboard/src/chat.ts +++ b/packages/dashboard/src/chat.ts @@ -35,6 +35,7 @@ import { SessionManager } from "@earendil-works/pi-coding-agent"; import { SessionEventBuffer } from "./sse-buffer.js"; import { formatChatAttachmentContents, readChatAttachmentContents } from "./chat-attachment-content.js"; import { buildTaskPlannerChatContext, TASK_PLANNER_CHAT_CONTEXT_PROMPT_GUIDANCE } from "./task-planner-chat-context.js"; +import { emitWorkflowSseEvent, type WorkflowSseEventType } from "./sse.js"; import { createFnAgent as engineCreateFnAgent, @@ -232,6 +233,52 @@ const MAX_REFERENCED_FILE_SIZE = 50 * 1024; export const TASK_PLANNER_CHAT_AGENT_ID_PREFIX = "task-planner:"; const ROOM_AMBIENT_MAX_RESPONDERS = 5; +type ChatCustomTool = ReturnType[number]; +type ChatToolExecute = (...args: unknown[]) => Promise; + +function workflowEventForToolName(toolName: string): WorkflowSseEventType | null { + if (toolName === "fn_workflow_create") return "workflow:created"; + if (toolName === "fn_workflow_update" || toolName === "fn_workflow_select" || toolName === "fn_workflow_settings") return "workflow:updated"; + if (toolName === "fn_workflow_delete") return "workflow:deleted"; + return null; +} + +function wrapWorkflowMutationTool(tool: ChatCustomTool, projectId?: string | null): ChatCustomTool { + const event = workflowEventForToolName(tool.name); + if (!event || typeof tool.execute !== "function") return tool; + return { + ...tool, + execute: (async (...args: Parameters) => { + const result = await (tool.execute as unknown as ChatToolExecute)(...args); + const resultRecord = result && typeof result === "object" ? result as { isError?: boolean; details?: unknown } : null; + if (!resultRecord?.isError) { + const details = resultRecord?.details && typeof resultRecord.details === "object" ? resultRecord.details as Record : {}; + /* + FNXC:ChatWorkflowAuthoring 2026-07-01-10:55: + Chat, planner, and room responders mutate workflows outside the REST workflow routes, so successful workflow tools must emit the same lifecycle SSE events as the editor routes. Workflow selectors and editors rely on those events to bypass stale in-flight/cache state and show chat-created definitions without a hard reload. + */ + const workflowId = typeof (details as { workflowId?: unknown }).workflowId === "string" + ? (details as { workflowId: string }).workflowId + : typeof (details as { id?: unknown }).id === "string" + ? (details as { id: string }).id + : undefined; + emitWorkflowSseEvent(event, workflowId ? { ...details, id: workflowId } : details, projectId ?? undefined); + } + return result; + }) as ChatCustomTool["execute"], + }; +} + +function createChatWorkflowAuthoringTools(taskStore: TaskStore | undefined, projectId?: string | null): ChatCustomTool[] { + if (!taskStore) return []; + /* + FNXC:ChatWorkflowAuthoring 2026-07-01-10:55: + Every provider-backed chat surface with a scoped TaskStore exposes the same safe workflow-authoring tools. Passing an empty currentTaskId keeps ambient chat lanes from silently selecting a workflow for an implicit task; agents must provide task_id unless the tool is invoked from an explicitly task-scoped helper. + */ + return createWorkflowAuthoringTools(taskStore, "", { stripApprovalFlags: true }) + .map((tool) => wrapWorkflowMutationTool(tool, projectId)); +} + function createTaskPlannerSteeringTool(taskStore: TaskStore, taskId: string) { return { name: "fn_task_planner_add_steering", @@ -1370,6 +1417,7 @@ export class ChatManager { const response = await this.generateRoomResponderReply({ roomId, roomName: room.name, + roomProjectId: room.projectId ?? null, content: trimmedContent, latestUserMessageId: userMessage.id, attachments, @@ -1427,6 +1475,7 @@ export class ChatManager { private async generateRoomResponderReply(input: { roomId: string; roomName: string; + roomProjectId?: string | null; content: string; latestUserMessageId: string; attachments?: ChatAttachment[]; @@ -1508,6 +1557,8 @@ export class ChatManager { "heartbeat", ); + const workflowTools = createChatWorkflowAuthoringTools(this.taskStore, input.roomProjectId); + const resolvedSession = await createResolvedAgentSession({ sessionPurpose: "heartbeat", pluginRunner: this.pluginRunner, @@ -1523,6 +1574,7 @@ export class ChatManager { cwd: this.rootDir, systemPrompt, tools: "coding", + ...(workflowTools.length > 0 ? { customTools: workflowTools } : {}), ...(effectiveModelProvider && effectiveModelId ? { defaultProvider: effectiveModelProvider, @@ -1921,12 +1973,7 @@ export class ChatManager { ] : []; - // Expose workflow-authoring tools (fn_workflow_*) when a scoped task store - // is available. The chat lane has no ambient task, so fn_workflow_select - // has no default target — an agent must pass an explicit task_id. - const workflowTools = this.taskStore - ? createWorkflowAuthoringTools(this.taskStore, "", { stripApprovalFlags: true }) - : []; + const workflowTools = createChatWorkflowAuthoringTools(this.taskStore, session.projectId); /* FNXC:ChatAgentTools 2026-06-18-06:51: