fix(solve-issue): handle one-shot coder completing all phases at once

The moderator now treats completedPhase matching the last planned phase name
as full completion. Also recognizes sentinel values (all-done, all_done,
complete) for robustness.

Fixes #21

小橘 🍊(NEKO Team)
This commit is contained in:
2026-05-07 03:13:44 +00:00
parent 7926751b01
commit 96fc3e220a
3 changed files with 52 additions and 6 deletions
+2 -2
View File
@@ -10,13 +10,13 @@ export const coderMetaSchema = z.object({
export type CoderMeta = z.infer<typeof coderMetaSchema>; export type CoderMeta = z.infer<typeof coderMetaSchema>;
const CODER_SYSTEM = `You are a **coder**. Read the thread for the plan and work on the NEXT incomplete phase only. const CODER_SYSTEM = `You are a **coder**. Read the thread for the plan and work on the NEXT incomplete phase only.
Report which phase you completed. List the files you changed and summarize what you did.`; Report which phase you completed using the planner's exact phase name. If you legitimately finish every remaining phase in this single turn, set completedPhase to the last phase name in the plan (the workflow treats that as full completion). List the files you changed and summarize what you did.`;
export const coderRole: RoleDefinition<CoderMeta> = { export const coderRole: RoleDefinition<CoderMeta> = {
description: description:
"Implements the next incomplete planner phase and reports structured completion metadata.", "Implements the next incomplete planner phase and reports structured completion metadata.",
systemPrompt: CODER_SYSTEM, systemPrompt: CODER_SYSTEM,
extractPrompt: extractPrompt:
"Extract which phase was completed, which files were changed, and a summary of the work done.", "Extract completedPhase: the planner phase name finished this round (exact string from the plan). If multiple phases were finished in one round, use the last finished phase name. Extract filesChanged and a summary of the work.",
schema: coderMetaSchema, schema: coderMetaSchema,
}; };
@@ -189,6 +189,28 @@ describe("solveIssueModerator", () => {
).toBe("reviewer"); ).toBe("reviewer");
}); });
test("one-shot coder reports only last phase name → reviewer (moderator treats as all phases done)", () => {
const phases: PlannerMeta["phases"] = [
{ name: "setup-branch", description: "branch", acceptance: "branch exists" },
{ name: "write-tests", description: "tests", acceptance: "tests pass" },
{ name: "verify", description: "verify", acceptance: "ok" },
{ name: "commit-and-pr", description: "pr", acceptance: "pr open" },
];
expect(
solveIssueModerator(makeCtx(20, [plannerStep(phases), coderStep("commit-and-pr")])),
).toBe("reviewer");
});
test("completedPhase sentinel when not a planned name → reviewer", () => {
const phases: PlannerMeta["phases"] = [
{ name: "p1", description: "first", acceptance: "a1" },
{ name: "p2", description: "second", acceptance: "a2" },
];
expect(solveIssueModerator(makeCtx(20, [plannerStep(phases), coderStep("all-done")]))).toBe(
"reviewer",
);
});
test("incomplete phases → END when max rounds exhausted", () => { test("incomplete phases → END when max rounds exhausted", () => {
const phases: PlannerMeta["phases"] = [ const phases: PlannerMeta["phases"] = [
{ name: "p1", description: "first", acceptance: "a1" }, { name: "p1", description: "first", acceptance: "a1" },
@@ -3,6 +3,30 @@ import { END } from "@uncaged/workflow";
import type { SolveIssueMeta } from "./roles.js"; import type { SolveIssueMeta } from "./roles.js";
const COMPLETED_PHASE_SENTINELS = new Set(["all-done", "all_done", "complete"]);
function coderFinishedAllPlannedPhases(
phases: ReadonlyArray<{ name: string }>,
coderCompletedPhases: ReadonlyArray<string>,
): boolean {
if (phases.length === 0) {
return true;
}
const plannedNames = new Set(phases.map((p) => p.name));
const lastName = phases[phases.length - 1].name;
const explicit = new Set(coderCompletedPhases.filter((name) => plannedNames.has(name)));
if (phases.every((p) => explicit.has(p.name))) {
return true;
}
// One-shot runs often report only the final phase; treat that as the full plan done.
if (coderCompletedPhases.some((name) => name === lastName)) {
return true;
}
return coderCompletedPhases.some(
(name) => !plannedNames.has(name) && COMPLETED_PHASE_SENTINELS.has(name),
);
}
function nextAfterCoder( function nextAfterCoder(
ctx: ModeratorContext<SolveIssueMeta>, ctx: ModeratorContext<SolveIssueMeta>,
maxRounds: number, maxRounds: number,
@@ -12,10 +36,10 @@ function nextAfterCoder(
return "reviewer"; return "reviewer";
} }
const phases = plannerStep.meta.phases; const phases = plannerStep.meta.phases;
const completedPhases = new Set( const coderCompletedPhases = ctx.steps
ctx.steps.filter((s) => s.role === "coder").map((s) => s.meta.completedPhase), .filter((s) => s.role === "coder")
); .map((s) => s.meta.completedPhase);
const allDone = phases.every((p) => completedPhases.has(p.name)); const allDone = coderFinishedAllPlannedPhases(phases, coderCompletedPhases);
if (allDone) { if (allDone) {
return "reviewer"; return "reviewer";
} }