From 20e42c65d121fa85a72fa38c94c26d04a2d6da86 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 1 Jul 2026 14:07:27 -0700 Subject: [PATCH] FN-7400: enforce JSON protocol for CE debug launches Prevent Compound Engineering Debug stages from failing launch when skill guidance would otherwise produce prose. - Prioritize the interactive JSON question/complete protocol over loaded skill instructions in CE stage prompts. - Document CE Debug dashboard protocol requirements in the bundled debug skill. - Add regression coverage for Debug stage startup, malformed-output errors, and session list/delete handling. - Add a patch changeset for the published Fusion package. Files changed: .changeset/fn-7400-compound-debug-stage-json.md | 7 ++ .../src/__tests__/session-routes.test.ts | 89 +++++++++++++++++++++- .../src/__tests__/stage-launch-guard.test.ts | 78 +++++++++++++++++++ .../src/session/orchestrator.ts | 7 +- .../src/skills/ce-debug/SKILL.md | 5 ++ 5 files changed, 184 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-7400 Fusion-Task-Lineage: 7d37660b-1073-489f-8115-20f07c8b05a4 Co-authored-by: Fusion (runfusion.ai) --- .../fn-7400-compound-debug-stage-json.md | 7 ++ .../src/__tests__/session-routes.test.ts | 89 ++++++++++++++++++- .../src/__tests__/stage-launch-guard.test.ts | 78 ++++++++++++++++ .../src/session/orchestrator.ts | 7 +- .../src/skills/ce-debug/SKILL.md | 5 ++ 5 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 .changeset/fn-7400-compound-debug-stage-json.md diff --git a/.changeset/fn-7400-compound-debug-stage-json.md b/.changeset/fn-7400-compound-debug-stage-json.md new file mode 100644 index 0000000000..489b4684ff --- /dev/null +++ b/.changeset/fn-7400-compound-debug-stage-json.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix Compound Engineering Debug stage launches that could fail with a JSON parse error. +category: fix +dev: Strengthens CE stage prompts and debug skill guidance so dashboard sessions emit the interactive JSON protocol. diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/session-routes.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/session-routes.test.ts index f76ea7b8d8..76ec40d4d6 100644 --- a/plugins/fusion-plugin-compound-engineering/src/__tests__/session-routes.test.ts +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/session-routes.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { PlanningQuestion, PluginContext, PluginRouteResponse } from "@fusion/core"; +import type { CreateInteractiveAiSessionFactory, InteractiveAiSessionEvent, PlanningQuestion, PluginContext, PluginRouteResponse } from "@fusion/core"; import { createSessionRoutes } from "../routes/session-routes.js"; import { makeHarness, makeScriptedSession, scriptedFactory, type TestHarness } from "./_harness.js"; @@ -11,6 +11,9 @@ import { makeHarness, makeScriptedSession, scriptedFactory, type TestHarness } f * which is the correct, non-hanging behavior. */ +const DEBUG_OPENING_MESSAGE = "Start the Debug stage."; +const DEBUG_PROTOCOL_SENTINEL = "translate any loaded-skill instruction"; + const QUESTION: PlanningQuestion = { id: "q1", type: "single_select", @@ -30,6 +33,19 @@ afterEach(() => { vi.restoreAllMocks(); }); +function debugProtocolSensitiveFactory(question: PlanningQuestion): CreateInteractiveAiSessionFactory { + return vi.fn(async (options) => { + const hasConflictOverride = options.systemPrompt.includes(DEBUG_PROTOCOL_SENTINEL); + const event: InteractiveAiSessionEvent = hasConflictOverride + ? { type: "question", data: question } + : { + type: "error", + data: { message: "Failed to parse agent response: AI returned no valid JSON." }, + }; + return { session: makeScriptedSession([event]) }; + }); +} + function route(method: string, path: string) { const r = createSessionRoutes().find((x) => x.method === method && x.path === path); if (!r) throw new Error(`route ${method} ${path} not found`); @@ -117,6 +133,44 @@ describe("session routes (polling transport)", () => { expect(sessions.map((s) => s.stage).sort()).toEqual(["brainstorm", "plan"]); }); + it("GET /sessions keeps error, interrupted, awaiting_input, active, and completed rows independently manageable", async () => { + const { getCeSessionStore } = await import("../session/session-store.js"); + const store = getCeSessionStore(h.ctx); + const error = store.update(store.create({ stage: "debug" }).id, { + status: "error", + error: "Failed to parse agent response: AI returned no valid JSON.", + })!; + const interrupted = store.update(store.create({ stage: "plan" }).id, { + status: "interrupted", + error: "Cancelled by user", + })!; + const awaiting = store.update(store.create({ stage: "brainstorm" }).id, { + status: "awaiting_input", + currentQuestion: QUESTION, + })!; + const active = store.update(store.create({ stage: "strategy", turnIntervalMs: 60_000 }).id, { status: "active" })!; + const completed = store.update(store.create({ stage: "work" }).id, { status: "completed" })!; + + const res = await call("GET", "/sessions", { params: {}, query: {} }, h.ctx); + + expect(res.status).toBe(200); + const sessions = (res.body as { sessions: Array<{ id: string; status: string; error: string | null }> }).sessions; + expect(sessions.map((s) => [s.id, s.status, s.error])).toEqual( + expect.arrayContaining([ + [error.id, "error", "Failed to parse agent response: AI returned no valid JSON."], + [interrupted.id, "interrupted", "Cancelled by user"], + [awaiting.id, "awaiting_input", null], + [active.id, "active", null], + [completed.id, "completed", null], + ]), + ); + + const deleted = await call("DELETE", "/sessions/:id", { params: { id: error.id } }, h.ctx); + expect(deleted.status).toBe(200); + expect(store.get(error.id)).toBeUndefined(); + expect(store.get(completed.id)).toBeDefined(); + }); + it("GET /sessions recovers stale active rows that have no live route handle", async () => { const { getCeSessionStore } = await import("../session/session-store.js"); const store = getCeSessionStore(h.ctx); @@ -169,6 +223,39 @@ describe("session routes (polling transport)", () => { expect(res.status).toBe(400); }); + it("POST /sessions starts debug detached and polling observes a protocol question instead of parse error", async () => { + const question: PlanningQuestion = { + id: "debug-scope", + type: "text", + question: "What bug or failing behavior should I investigate?", + }; + h.ctx.createInteractiveAiSession = debugProtocolSensitiveFactory(question); + + const started = await call( + "POST", + "/sessions", + { body: { stage: "debug", message: DEBUG_OPENING_MESSAGE } }, + h.ctx, + ); + + expect(started.status).toBe(201); + const sessionId = (started.body as { session: { id: string; status: string; error: string | null } }).session.id; + expect((started.body as { session: { status: string } }).session.status).toBe("launching"); + + await new Promise((resolve) => setImmediate(resolve)); + + const polled = await call("GET", "/sessions/:id", { params: { id: sessionId } }, h.ctx); + expect(polled.status).toBe(200); + expect((polled.body as { session: { status: string; error: string | null; currentQuestion: PlanningQuestion } }).session).toMatchObject({ + status: "awaiting_input", + error: null, + currentQuestion: question, + }); + expect( + (polled.body as { session: { error: string | null } }).session.error ?? "", + ).not.toContain("AI returned no valid JSON"); + }); + it("POST /sessions without engine interactive factory returns a clean 400 (no hang)", async () => { const res = await call("POST", "/sessions", { body: { stage: "brainstorm", message: "go" } }, h.ctx); expect(res.status).toBe(400); diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/stage-launch-guard.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/stage-launch-guard.test.ts index 131cbb7147..2c9c1755a5 100644 --- a/plugins/fusion-plugin-compound-engineering/src/__tests__/stage-launch-guard.test.ts +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/stage-launch-guard.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CreateInteractiveAiSessionFactory, InteractiveAiSessionEvent, PlanningQuestion } from "@fusion/core"; import { CeOrchestrator } from "../session/orchestrator.js"; import { makeHarness, makeScriptedSession, type TestHarness } from "./_harness.js"; @@ -6,6 +7,25 @@ import { makeHarness, makeScriptedSession, type TestHarness } from "./_harness.j FNXC:CompoundEngineering 2026-06-17-13:22: A stale persisted enabledStages snapshot must not block any registered CE stage, including newly added stages such as debug. Keep this runnable source regression outside the quarantined skill-wiring suite so opt-out launch gating remains covered by normal test runs. */ +const DEBUG_OPENING_MESSAGE = "Start the Debug stage."; + +const DEBUG_PROTOCOL_SENTINEL = "translate any loaded-skill instruction"; + +function debugProtocolSensitiveFactory(question: PlanningQuestion): CreateInteractiveAiSessionFactory { + return vi.fn(async (options) => { + const hasConflictOverride = options.systemPrompt.includes(DEBUG_PROTOCOL_SENTINEL); + const event: InteractiveAiSessionEvent = hasConflictOverride + ? { type: "question", data: question } + : { + type: "error", + data: { + message: "Failed to parse agent response: AI returned no valid JSON.", + }, + }; + return { session: makeScriptedSession([event]) }; + }); +} + describe("CE stage launch guard", () => { let h: TestHarness; @@ -37,6 +57,64 @@ describe("CE stage launch guard", () => { }, ); + it("starts the built-in debug stage with the default launcher message as a protocol question", async () => { + const question: PlanningQuestion = { + id: "debug-scope", + type: "text", + question: "What bug or failing behavior should I investigate?", + }; + const factory = debugProtocolSensitiveFactory(question); + const orch = new CeOrchestrator({ + ctx: h.ctx, + createInteractiveAiSession: factory, + projectRoot: h.projectRoot, + turnTimeoutMs: 5000, + }); + + const result = await orch.start("debug", { openingMessage: DEBUG_OPENING_MESSAGE }); + + expect(factory).toHaveBeenCalledTimes(1); + expect(result.session).toMatchObject({ + stage: "debug", + status: "awaiting_input", + error: null, + currentQuestion: question, + }); + expect(result.session.conversationHistory).toEqual( + expect.arrayContaining([ + expect.objectContaining({ role: "user", text: DEBUG_OPENING_MESSAGE }), + expect.objectContaining({ role: "agent", text: JSON.stringify({ question }) }), + ]), + ); + expect(result.session.error ?? "").not.toContain("AI returned no valid JSON"); + }); + + it("keeps genuinely malformed debug output as an error instead of fabricating a question", async () => { + const factory = vi.fn(async () => ({ + session: makeScriptedSession([ + { + type: "error", + data: { message: "Failed to parse agent response: AI returned no valid JSON." }, + }, + ]), + })); + const orch = new CeOrchestrator({ + ctx: h.ctx, + createInteractiveAiSession: factory, + projectRoot: h.projectRoot, + turnTimeoutMs: 5000, + }); + + const result = await orch.start("debug", { openingMessage: DEBUG_OPENING_MESSAGE }); + + expect(result.session).toMatchObject({ + stage: "debug", + status: "error", + currentQuestion: null, + error: "Failed to parse agent response: AI returned no valid JSON.", + }); + }); + it("rejects debug launch when debug is explicitly disabled", async () => { h.ctx.settings = { disabledStages: ["debug"] }; const factory = vi.fn(async () => ({ diff --git a/plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts b/plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts index 0e8b1f46e2..ac5161ed1d 100644 --- a/plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts +++ b/plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts @@ -201,10 +201,15 @@ export function buildStageSystemPrompt(stage: CeStageDefinition): string { `You are running the Compound Engineering "${stage.stageId}" stage.`, `Apply the bundled skill "${stage.skillId}" (it has been loaded into this session).`, "", + /* + * FNXC:CompoundEngineering 2026-07-01-13:41: + * CE dashboard stages may load rich skills that were authored for chat/terminal flows, including instructions to ask blocking questions or write prose summaries. The interactive session seam is stricter: every visible turn must translate any loaded-skill instruction into the structured question/complete JSON protocol so a newly launched Debug stage cannot become an Error session from non-JSON output. + */ + "The JSON protocol below has priority over any loaded-skill instruction about asking questions, using blocking question tools, writing prose summaries, previewing commits/PRs, or ending with chat text: translate any loaded-skill instruction into a JSON question or JSON complete event.", "Drive the stage as an interactive question/answer flow. On every turn respond with ONLY a JSON object:", ' - To ask the user something: {"type":"question","data":{"id":"","type":"single_select|multi_select|text|confirm","question":"...","options":[{"id":"..","label":".."}]}}', ' - When the stage is finished: {"type":"complete","data":{"artifact":"", ...}}', - "No markdown fences, no prose outside the JSON object.", + "No markdown fences, no prose outside the JSON object. Do not call user-question tools from this interactive stage; emit a JSON question instead.", "", "The user's reply arrives as {\"type\":\"answer\",\"questionId\":\"...\",\"response\":...}. The response takes one of three shapes:", " - a direct answer to your question (an option id, array of option ids, text, or boolean),", diff --git a/plugins/fusion-plugin-compound-engineering/src/skills/ce-debug/SKILL.md b/plugins/fusion-plugin-compound-engineering/src/skills/ce-debug/SKILL.md index 87b0cb602f..4ef4a0e587 100644 --- a/plugins/fusion-plugin-compound-engineering/src/skills/ce-debug/SKILL.md +++ b/plugins/fusion-plugin-compound-engineering/src/skills/ce-debug/SKILL.md @@ -6,6 +6,11 @@ argument-hint: "[issue reference, error message, test path, or description of br # Debug and Fix + + Find root causes, then fix them. This skill investigates bugs systematically — tracing the full causal chain before proposing a fix — and optionally implements the fix with test-first discipline. #$ARGUMENTS