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:
roboomp
2026-05-19 02:09:47 +00:00
parent 4024fbe7e2
commit ee442cedea
7 changed files with 95 additions and 13 deletions
@@ -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");
});
});