fix(ask,plan): fix renderer crash and plan-mode exit loop for Qwen3 and similar models
- Guard renderInlineMarkdown against non-string input: partial JSON during
streaming can leave option label fields as undefined, causing marked.lexer
to throw 'undefined is not an object (evaluating e.replace)'. The ask tool
renderer now silently falls back to an empty string or baseColor output.
- Sanitize normalizePlanTitle instead of hard-rejecting: models that produce
natural-language plan titles like 'My Improvement Plan' were getting a
ToolError on every resolve call, causing an infinite retry loop. Spaces are
now converted to hyphens, remaining invalid chars are dropped, and only
truly unresolvable titles (empty after sanitization, path separators) throw.
- Fix ask.md prompt example: the example showed the legacy single-question
format (question/options/recommended at the top level) while the schema
requires questions: [{id, question, options}]. Models that follow examples
closely (Qwen3) generated calls that always failed schema validation.
Fixes #1176
This commit is contained in:
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { humanizePlanTitle, renameApprovedPlanFile } from "@oh-my-pi/pi-coding-agent/plan-mode/approved-plan";
|
||||
import { humanizePlanTitle, normalizePlanTitle, renameApprovedPlanFile } from "@oh-my-pi/pi-coding-agent/plan-mode/approved-plan";
|
||||
|
||||
describe("renameApprovedPlanFile", () => {
|
||||
let tmpDir: string;
|
||||
@@ -62,3 +62,47 @@ describe("humanizePlanTitle", () => {
|
||||
expect(humanizePlanTitle("---")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizePlanTitle", () => {
|
||||
it("accepts a clean identifier as-is", () => {
|
||||
expect(normalizePlanTitle("my-plan")).toEqual({ title: "my-plan", fileName: "my-plan.md" });
|
||||
expect(normalizePlanTitle("feature_branch")).toEqual({ title: "feature_branch", fileName: "feature_branch.md" });
|
||||
});
|
||||
|
||||
it("strips a trailing .md suffix provided by the model", () => {
|
||||
expect(normalizePlanTitle("my-plan.md")).toEqual({ title: "my-plan", fileName: "my-plan.md" });
|
||||
});
|
||||
|
||||
it("converts spaces to hyphens (natural-language titles)", () => {
|
||||
expect(normalizePlanTitle("My Improvement Plan")).toEqual({
|
||||
title: "My-Improvement-Plan",
|
||||
fileName: "My-Improvement-Plan.md",
|
||||
});
|
||||
});
|
||||
|
||||
it("collapses consecutive spaces / resulting hyphens", () => {
|
||||
expect(normalizePlanTitle("foo bar")).toEqual({ title: "foo-bar", fileName: "foo-bar.md" });
|
||||
});
|
||||
|
||||
it("drops characters outside the allowed set after space replacement", () => {
|
||||
expect(normalizePlanTitle("plan: v1.0 (draft)")).toEqual({ title: "plan-v10-draft", fileName: "plan-v10-draft.md" });
|
||||
});
|
||||
|
||||
it("trims leading/trailing hyphens that result from sanitization", () => {
|
||||
expect(normalizePlanTitle("!!! plan !!!")).toEqual({ title: "plan", fileName: "plan.md" });
|
||||
});
|
||||
|
||||
it("throws for empty title", () => {
|
||||
expect(() => normalizePlanTitle("")).toThrow("Plan title is required");
|
||||
expect(() => normalizePlanTitle(" ")).toThrow("Plan title is required");
|
||||
});
|
||||
|
||||
it("throws for path separators", () => {
|
||||
expect(() => normalizePlanTitle("../etc/passwd")).toThrow("path separators");
|
||||
expect(() => normalizePlanTitle("a/b")).toThrow("path separators");
|
||||
});
|
||||
|
||||
it("throws when sanitization produces empty result", () => {
|
||||
expect(() => normalizePlanTitle("!!!")).toThrow("at least one letter");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user