diff --git a/.changeset/fn-6640-planning-document-tools.md b/.changeset/fn-6640-planning-document-tools.md new file mode 100644 index 0000000000..261a7ce70a --- /dev/null +++ b/.changeset/fn-6640-planning-document-tools.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Expose task document read/write tools to planning agents with explicit task IDs, matching chat session behavior. diff --git a/docs/agents.md b/docs/agents.md index 58c9c4e871..a9ef5989d3 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -26,7 +26,7 @@ fn chat [message…] [--once] [--non-interactive] [--poll-ms ] - Dashboard-created agent chat sessions request the target agent's declared `metadata.skills` plus enabled plugin-contributed skills, so skills such as `ce-debug` are available in chat when the contributing plugin is enabled. Model-only QuickChat sessions request enabled plugin skills, and room responder sessions request the responder agent's skills. - Agent-acting session lanes share the same skill-injection contract as executor sessions: executor, merger, triage, reviewer, heartbeat, step-session, dashboard chat/room responders, CLI agent execution, planning, mission interview, milestone/slice interview, agent-onboarding interview, workflow design, memory dreams/insight extraction, and scheduled cron automation all request agent/fallback skills plus enabled plugin-contributed skills when a plugin runner is available. Utility-only lanes that only summarize/extract/generate JSON (title/PR summaries, memory compaction, subtask breakdown, text refinement, agent generation, PR metadata generation, evaluator/research synthesis, and similar one-shot helpers) intentionally stay exempt to avoid loading skills where no agent-style tool loop can use them. - In dashboard model-loop chat (main chat, QuickChat, and room responders), typing `/skill:{name}` requests that skill for the current AI session and strips the slash token from the prompt sent to the model. The requested skill is still subject to the normal enabled/disabled execution-skill filters; CLI-agent-backed PTY chat keeps raw terminal input semantics and does not interpret this command. -- Dashboard chat sessions with a scoped task store expose `fn_task_document_write` and `fn_task_document_read`; because chat has no ambient task, both tools require an explicit `task_id`. +- Dashboard chat and planning sessions with a scoped task store expose `fn_task_document_write` and `fn_task_document_read`; because neither lane has an ambient task, both tools require an explicit `task_id`. ### Flags diff --git a/packages/cli/skill/fusion/references/engine-tools.md b/packages/cli/skill/fusion/references/engine-tools.md index 201101954d..e91335396b 100644 --- a/packages/cli/skill/fusion/references/engine-tools.md +++ b/packages/cli/skill/fusion/references/engine-tools.md @@ -13,8 +13,8 @@ These tools are **not** part of the user-invokable extension surface. They are i |---|---|---|---| | `fn_task_create` | triage, executor, heartbeat | Create a follow-up task from within an agent run | `description` (string), `dependencies?` (string[]), `priority?` (`low` \| `normal` \| `high` \| `urgent`), `workflow_id?` (string) | | `fn_task_log` | executor, heartbeat | Write significant task log entries | `message` (string), `outcome?` (string) | -| `fn_task_document_write` | triage, executor, heartbeat; chat (explicit `task_id`) | Save/update a named task document revision | `key` (string), `content` (string), `author?` (string); chat also requires `task_id` (string) | -| `fn_task_document_read` | triage, executor, heartbeat; chat (explicit `task_id`) | Read one task document or list all | `key?` (string); chat also requires `task_id` (string) | +| `fn_task_document_write` | triage, executor, heartbeat; chat/planning (explicit `task_id`) | Save/update a named task document revision | `key` (string), `content` (string), `author?` (string); chat/planning also require `task_id` (string) | +| `fn_task_document_read` | triage, executor, heartbeat; chat/planning (explicit `task_id`) | Read one task document or list all | `key?` (string); chat/planning also require `task_id` (string) | | `fn_goal_list` | triage, executor, heartbeat | List goals with concise citation-ready snippets and active-goal warning details | `status?` (`active` \| `archived` \| `all`) | | `fn_goal_show` | triage, executor, heartbeat | Show one goal's full detail on demand, including the full description body | `id` (string) | | `fn_workflow_list` | executor | List the project's custom workflows (read-only built-ins plus user definitions) | none | diff --git a/packages/dashboard/src/__tests__/planning-document-tools-exposure.test.ts b/packages/dashboard/src/__tests__/planning-document-tools-exposure.test.ts new file mode 100644 index 0000000000..b3951a84f1 --- /dev/null +++ b/packages/dashboard/src/__tests__/planning-document-tools-exposure.test.ts @@ -0,0 +1,139 @@ +// @vitest-environment node + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { TaskStore } from "@fusion/core"; +import { __resetPlanningState, __setCreateFnAgent, createSession, createSessionWithAgent, planningStreamManager } from "../planning.js"; + +function createQuestionJson(): string { + return JSON.stringify({ + type: "question", + data: { id: "q-1", type: "text", question: "What should this plan cover?" }, + }); +} + +function createMockAgent(response = createQuestionJson()) { + const messages: Array<{ role: string; content: string }> = []; + return { + session: { + state: { messages }, + prompt: vi.fn(async () => { + messages.push({ role: "assistant", content: response }); + }), + dispose: vi.fn(), + }, + }; +} + +async function waitFor(condition: () => boolean): Promise { + for (let i = 0; i < 50; i += 1) { + if (condition()) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error("Timed out waiting for condition"); +} + +describe("planning task-document tools", () => { + let rootDir: string; + let globalDir: string; + let store: TaskStore; + + beforeEach(async () => { + rootDir = mkdtempSync(join(tmpdir(), "planning-doc-tools-root-")); + globalDir = mkdtempSync(join(tmpdir(), "planning-doc-tools-global-")); + store = new TaskStore(rootDir, globalDir, { inMemoryDb: true }); + await store.init(); + __resetPlanningState(); + }); + + afterEach(() => { + __resetPlanningState(); + store.close(); + rmSync(rootDir, { recursive: true, force: true }); + rmSync(globalDir, { recursive: true, force: true }); + }); + + it("exposes task-document tools from both planning customTools assembly sites", async () => { + const capturedNonStreaming: any[] = []; + __setCreateFnAgent(async (options: any) => { + capturedNonStreaming.push(options); + return createMockAgent(); + }); + + await createSession("127.0.0.210", "Plan document tool coverage", store, rootDir); + + const nonStreamingToolNames = capturedNonStreaming[0]?.customTools?.map((tool: any) => tool.name) ?? []; + expect(nonStreamingToolNames).toContain("fn_task_document_write"); + expect(nonStreamingToolNames).toContain("fn_task_document_read"); + + const capturedStreaming: any[] = []; + __resetPlanningState(); + __setCreateFnAgent(async (options: any) => { + capturedStreaming.push(options); + return createMockAgent(); + }); + + const sessionId = await createSessionWithAgent( + "127.0.0.211", + "Plan streaming document tool coverage", + rootDir, + store, + ); + const unsubscribe = planningStreamManager.subscribe(sessionId, () => undefined); + try { + planningStreamManager.consumeInitialTurn(sessionId)?.(); + await waitFor(() => capturedStreaming.length > 0); + } finally { + unsubscribe(); + } + + const streamingToolNames = capturedStreaming[0]?.customTools?.map((tool: any) => tool.name) ?? []; + expect(streamingToolNames).toContain("fn_task_document_write"); + expect(streamingToolNames).toContain("fn_task_document_read"); + }); + + it("uses explicit task_id when planning document tools write and read task documents", async () => { + const task = await store.createTask({ description: "Document target" }); + const upsertSpy = vi.spyOn(store, "upsertTaskDocument"); + const getSpy = vi.spyOn(store, "getTaskDocument"); + const listSpy = vi.spyOn(store, "getTaskDocuments"); + let capturedOptions: any; + __setCreateFnAgent(async (options: any) => { + capturedOptions = options; + return createMockAgent(); + }); + + await createSession("127.0.0.212", "Plan document behavior", store, rootDir); + + const writeTool = capturedOptions.customTools.find((tool: any) => tool.name === "fn_task_document_write"); + const readTool = capturedOptions.customTools.find((tool: any) => tool.name === "fn_task_document_read"); + expect(writeTool).toBeDefined(); + expect(readTool).toBeDefined(); + + const writeResult = await writeTool.execute("write-plan-doc", { + task_id: task.id, + key: "plan", + content: "Planning notes", + author: "planner", + }); + + expect(writeResult.content[0]?.text).toContain("Saved document \"plan\""); + expect(upsertSpy).toHaveBeenCalledWith(task.id, { + key: "plan", + content: "Planning notes", + author: "planner", + }); + + const readResult = await readTool.execute("read-plan-doc", { task_id: task.id, key: "plan" }); + expect(readResult.content[0]?.text).toContain("Document: plan"); + expect(readResult.content[0]?.text).toContain("Planning notes"); + expect(getSpy).toHaveBeenCalledWith(task.id, "plan"); + + const listResult = await readTool.execute("list-plan-docs", { task_id: task.id }); + expect(listResult.content[0]?.text).toContain("Task documents:"); + expect(listResult.content[0]?.text).toContain("- plan"); + expect(listSpy).toHaveBeenCalledWith(task.id); + }); +}); diff --git a/packages/dashboard/src/planning.ts b/packages/dashboard/src/planning.ts index 90c257e0be..3c12126229 100644 --- a/packages/dashboard/src/planning.ts +++ b/packages/dashboard/src/planning.ts @@ -34,6 +34,7 @@ import { } from "./ai-session-diagnostics.js"; import { buildSessionSkillContextSync, + createChatTaskDocumentTools, createFnAgent as engineCreateFnAgent, createWorkflowAuthoringTools, } from "@fusion/engine"; @@ -863,6 +864,11 @@ export async function createSession( customTools: [ ...createPlanningBoardTools(store), ...createWorkflowAuthoringTools(store, PLANNING_NO_AMBIENT_TASK_ID, { stripApprovalFlags: true }), + /* + FNXC:PlanningTools 2026-06-18-07:11: + FN-6640 gives planning agents parity with chat for `fn_task_document_write` and `fn_task_document_read` after FN-6635. The planning lane has no ambient task (`PLANNING_NO_AMBIENT_TASK_ID`), so these document tools must require an explicit `task_id`, mirroring no-ambient workflow authoring tools. + */ + ...createChatTaskDocumentTools(store), ], onThinking: () => { // Non-streaming path ignores thinking output @@ -1455,6 +1461,7 @@ async function createPlanningAgent( customTools: [ ...createPlanningBoardTools(store), ...createWorkflowAuthoringTools(store, PLANNING_NO_AMBIENT_TASK_ID, { stripApprovalFlags: true }), + ...createChatTaskDocumentTools(store), ], ...(modelProvider && modelId ? {