Skip to content

Commit af4fb78

Browse files
committed
fix(runtime): align plan prompt output contract
1 parent c6cfd3e commit af4fb78

6 files changed

Lines changed: 88 additions & 5 deletions

File tree

internal/context/source_plan_mode.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ func (planModeContextSource) Sections(ctx context.Context, input BuildInput) ([]
3434
if stage == "plan" {
3535
noPlanHint := promptSection{
3636
Title: "Current Plan",
37-
Content: "status: none\n\nNo current plan exists. You must create one by outputting a `plan_spec` + `summary_candidate` JSON before this turn ends.",
37+
Content: "status: none\n\nNo current plan exists. You must create one before this turn ends by outputting a visible Markdown plan, followed by one compact `plan_spec` + `summary_candidate` JSON object inside an HTML comment.",
3838
}
3939
sections = append(sections, noPlanHint)
4040
}

internal/context/source_plan_mode_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,37 @@ func TestPlanModeSectionsReturnsWithoutPlanWhenNil(t *testing.T) {
5050
}
5151
}
5252

53+
func TestPlanModeSectionsNoCurrentPlanUsesHTMLCommentContract(t *testing.T) {
54+
t.Parallel()
55+
56+
source := planModeContextSource{}
57+
sections, err := source.Sections(context.Background(), BuildInput{
58+
AgentMode: agentsession.AgentModePlan,
59+
PlanStage: "plan",
60+
CurrentPlan: nil,
61+
})
62+
if err != nil {
63+
t.Fatalf("Sections() error = %v", err)
64+
}
65+
var currentPlanContent string
66+
for _, section := range sections {
67+
if section.Title == "Current Plan" {
68+
currentPlanContent = section.Content
69+
break
70+
}
71+
}
72+
if currentPlanContent == "" {
73+
t.Fatal("expected Current Plan section when plan stage has no current plan")
74+
}
75+
if !strings.Contains(currentPlanContent, "visible Markdown plan") ||
76+
!strings.Contains(currentPlanContent, "inside an HTML comment") {
77+
t.Fatalf("Current Plan hint = %q, want Markdown plus HTML comment JSON contract", currentPlanContent)
78+
}
79+
if strings.Contains(currentPlanContent, "outputting a `plan_spec` + `summary_candidate` JSON") {
80+
t.Fatalf("Current Plan hint should not use old JSON-only wording: %q", currentPlanContent)
81+
}
82+
}
83+
5384
func TestPlanModeSectionsContextError(t *testing.T) {
5485
t.Parallel()
5586

internal/promptasset/assets_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,12 @@ func TestPlanModePromptTemplates(t *testing.T) {
9494
strings.Contains(PlanModePrompt("plan"), "must not be empty") {
9595
t.Fatalf("expected plan prompt not to require execution todo ownership")
9696
}
97+
if strings.Contains(PlanModePrompt("plan"), "Only output a JSON object") {
98+
t.Fatalf("expected plan prompt not to require JSON-only output")
99+
}
100+
if !strings.Contains(PlanModePrompt("plan"), "inside an HTML comment") {
101+
t.Fatalf("expected plan prompt to require machine-readable JSON in an HTML comment")
102+
}
97103
if !strings.Contains(PlanModePrompt("plan"), "Do not create execution todos in plan mode") {
98104
t.Fatalf("expected plan prompt to keep todos in build execution")
99105
}

internal/promptasset/templates/context/plan_mode_plan.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ You are currently in the planning stage.
33
- You may research, analyze, ask clarifying questions, and produce a plan.
44
- Do not perform any write action in this stage.
55
- Do not rewrite the current full plan unless the conversation clearly requires creating or replacing the plan itself.
6-
- **If no Current Plan section is attached, your first priority is to produce a plan.** The user has entered planning mode expecting a structured plan. Research the codebase as needed, then output a complete `plan_spec` + `summary_candidate` JSON. Do not end the turn with only a conversational answer when there is no existing plan.
6+
- **If no Current Plan section is attached, your first priority is to produce a plan.** The user has entered planning mode expecting a structured plan. Research the codebase as needed, then output a visible Markdown plan followed by one compact machine-readable JSON object containing `plan_spec` and `summary_candidate` inside an HTML comment. Do not end the turn with only a conversational answer when there is no existing plan.
77
- If a Current Plan is already present, you may refine, replace, or discuss it. When the user asks a clarifying question or wants to explore options without committing to a new plan revision, you may answer conversationally without outputting planning JSON.
8-
- Only output a JSON object containing `plan_spec` and `summary_candidate` when you are explicitly creating or rewriting the current full plan.
8+
- When explicitly creating or rewriting the current full plan, output the visible plan as Markdown first, then append the machine-readable JSON inside an HTML comment, not in a fenced code block.
99
- `plan_spec` must include `goal`, `steps`, `constraints`, and `open_questions`.
1010
- `plan_spec.todos` is optional legacy data. Do not create execution todos in plan mode; build mode will create and maintain runtime todos when implementation starts.
1111
- `summary_candidate` must include `goal`, `key_steps`, and `constraints`.

internal/runtime/planning.go

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,8 +174,9 @@ func decodePlanTurnOutput(jsonText string) (planTurnOutput, error) {
174174

175175
// stripPlanningJSONObjectText 从原始回复中移除结构化 JSON,并尽量保留自然段落间距。
176176
func stripPlanningJSONObjectText(text string, candidate extractedPlanningJSONObject) string {
177-
before := strings.TrimSpace(text[:candidate.Start])
178-
after := strings.TrimSpace(text[candidate.End:])
177+
start, end := planningJSONObjectRemovalRange(text, candidate)
178+
before := strings.TrimSpace(text[:start])
179+
after := strings.TrimSpace(text[end:])
179180
switch {
180181
case before == "":
181182
return after
@@ -186,6 +187,28 @@ func stripPlanningJSONObjectText(text string, candidate extractedPlanningJSONObj
186187
}
187188
}
188189

190+
// planningJSONObjectRemovalRange 扩展结构化 JSON 的剥离范围,避免 HTML 注释外壳泄漏到可见计划正文。
191+
func planningJSONObjectRemovalRange(text string, candidate extractedPlanningJSONObject) (int, int) {
192+
start := candidate.Start
193+
end := candidate.End
194+
if start < 0 || end < start || end > len(text) {
195+
return candidate.Start, candidate.End
196+
}
197+
198+
prefix := text[:start]
199+
open := strings.LastIndex(prefix, "<!--")
200+
if open < 0 || strings.TrimSpace(prefix[open+len("<!--"):]) != "" {
201+
return start, end
202+
}
203+
204+
suffix := text[end:]
205+
closeOffset := strings.Index(suffix, "-->")
206+
if closeOffset < 0 || strings.TrimSpace(suffix[:closeOffset]) != "" {
207+
return start, end
208+
}
209+
return open, end + closeOffset + len("-->")
210+
}
211+
189212
// extractPlanningJSONObjectIfPresent 在文本中提取首个满足指定顶层键契约的 JSON 对象。
190213
func extractPlanningJSONObjectIfPresent(text string, requiredKey string) (extractedPlanningJSONObject, bool) {
191214
start := strings.IndexByte(text, '{')

internal/runtime/planning_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,29 @@ func TestMaybeParsePlanTurnOutputIgnoresBraceTextAndKeepsExplanation(t *testing.
133133
}
134134
}
135135

136+
func TestMaybeParsePlanTurnOutputStripsHTMLCommentJSON(t *testing.T) {
137+
t.Parallel()
138+
139+
markdown := "### Goal\n\nShip plan display\n\n### Steps\n\n- Align prompts"
140+
text := markdown + "\n\n<!-- {\"plan_spec\":{\"goal\":\"Ship plan display\",\"steps\":[\"Align prompts\"],\"constraints\":[\"Keep parser stable\"],\"open_questions\":[]},\"summary_candidate\":{\"goal\":\"Ship plan display\",\"key_steps\":[\"Align prompts\"],\"constraints\":[\"Keep parser stable\"]}} -->"
141+
output, ok, err := maybeParsePlanTurnOutput(providertypes.Message{
142+
Role: providertypes.RoleAssistant,
143+
Parts: []providertypes.ContentPart{providertypes.NewTextPart(text)},
144+
})
145+
if err != nil {
146+
t.Fatalf("maybeParsePlanTurnOutput() error = %v", err)
147+
}
148+
if !ok {
149+
t.Fatal("expected HTML comment plan JSON to be detected")
150+
}
151+
if output.PlanSpec.Goal != "Ship plan display" {
152+
t.Fatalf("PlanSpec.Goal = %q", output.PlanSpec.Goal)
153+
}
154+
if output.DisplayText != markdown {
155+
t.Fatalf("DisplayText = %q, want %q", output.DisplayText, markdown)
156+
}
157+
}
158+
136159
func TestMaybeParsePlanTurnOutputFallsBackWhenSummaryIsInvalid(t *testing.T) {
137160
t.Parallel()
138161

0 commit comments

Comments
 (0)