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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-01 14:07:27 -07:00
parent 9c2a264322
commit 20e42c65d1
5 changed files with 184 additions and 2 deletions

View File

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

View File

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

View File

@@ -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 () => ({

View File

@@ -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":"<unique>","type":"single_select|multi_select|text|confirm","question":"...","options":[{"id":"..","label":".."}]}}',
' - When the stage is finished: {"type":"complete","data":{"artifact":"<full markdown document>", ...}}',
"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),",

View File

@@ -6,6 +6,11 @@ argument-hint: "[issue reference, error message, test path, or description of br
# Debug and Fix
<!--
FNXC:CompoundEngineering 2026-07-01-13:43:
When this skill runs inside the Compound Engineering dashboard, the host interactive-session protocol is authoritative. Ask clarifying or handoff questions by returning the host's JSON question object, and finish by returning the host's JSON complete object; do not emit prose-only turns or blocking question tool calls at the dashboard seam.
-->
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.
<bug_description> #$ARGUMENTS </bug_description>