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:
7
.changeset/fn-7400-compound-debug-stage-json.md
Normal file
7
.changeset/fn-7400-compound-debug-stage-json.md
Normal 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.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
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 { createSessionRoutes } from "../routes/session-routes.js";
|
||||||
import { makeHarness, makeScriptedSession, scriptedFactory, type TestHarness } from "./_harness.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.
|
* 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 = {
|
const QUESTION: PlanningQuestion = {
|
||||||
id: "q1",
|
id: "q1",
|
||||||
type: "single_select",
|
type: "single_select",
|
||||||
@@ -30,6 +33,19 @@ afterEach(() => {
|
|||||||
vi.restoreAllMocks();
|
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) {
|
function route(method: string, path: string) {
|
||||||
const r = createSessionRoutes().find((x) => x.method === method && x.path === path);
|
const r = createSessionRoutes().find((x) => x.method === method && x.path === path);
|
||||||
if (!r) throw new Error(`route ${method} ${path} not found`);
|
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"]);
|
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 () => {
|
it("GET /sessions recovers stale active rows that have no live route handle", async () => {
|
||||||
const { getCeSessionStore } = await import("../session/session-store.js");
|
const { getCeSessionStore } = await import("../session/session-store.js");
|
||||||
const store = getCeSessionStore(h.ctx);
|
const store = getCeSessionStore(h.ctx);
|
||||||
@@ -169,6 +223,39 @@ describe("session routes (polling transport)", () => {
|
|||||||
expect(res.status).toBe(400);
|
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 () => {
|
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);
|
const res = await call("POST", "/sessions", { body: { stage: "brainstorm", message: "go" } }, h.ctx);
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { CreateInteractiveAiSessionFactory, InteractiveAiSessionEvent, PlanningQuestion } from "@fusion/core";
|
||||||
import { CeOrchestrator } from "../session/orchestrator.js";
|
import { CeOrchestrator } from "../session/orchestrator.js";
|
||||||
import { makeHarness, makeScriptedSession, type TestHarness } from "./_harness.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:
|
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.
|
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", () => {
|
describe("CE stage launch guard", () => {
|
||||||
let h: TestHarness;
|
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 () => {
|
it("rejects debug launch when debug is explicitly disabled", async () => {
|
||||||
h.ctx.settings = { disabledStages: ["debug"] };
|
h.ctx.settings = { disabledStages: ["debug"] };
|
||||||
const factory = vi.fn(async () => ({
|
const factory = vi.fn(async () => ({
|
||||||
|
|||||||
@@ -201,10 +201,15 @@ export function buildStageSystemPrompt(stage: CeStageDefinition): string {
|
|||||||
`You are running the Compound Engineering "${stage.stageId}" stage.`,
|
`You are running the Compound Engineering "${stage.stageId}" stage.`,
|
||||||
`Apply the bundled skill "${stage.skillId}" (it has been loaded into this session).`,
|
`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:",
|
"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":".."}]}}',
|
' - 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>", ...}}',
|
' - 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:",
|
"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),",
|
" - a direct answer to your question (an option id, array of option ids, text, or boolean),",
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ argument-hint: "[issue reference, error message, test path, or description of br
|
|||||||
|
|
||||||
# Debug and Fix
|
# 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.
|
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>
|
<bug_description> #$ARGUMENTS </bug_description>
|
||||||
|
|||||||
Reference in New Issue
Block a user