Skip to content

Commit 4e575d8

Browse files
authored
Merge pull request #676 from Yumiue/codex/web-plan-display-674
修复 Web Plan 模式计划内容展示
2 parents 5ca835c + af4fb78 commit 4e575d8

21 files changed

Lines changed: 2657 additions & 1005 deletions

internal/cli/gateway_runtime_bridge.go

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1847,18 +1847,70 @@ func convertSessionMessages(messages []providertypes.Message) []gateway.SessionM
18471847
return converted
18481848
}
18491849

1850+
// convertRuntimePlanTodoItem 将 session 计划中的 legacy todo 项映射为 gateway 展示结构。
1851+
func convertRuntimePlanTodoItem(item agentsession.TodoItem) gateway.PlanTodoItem {
1852+
required := false
1853+
if item.Required != nil {
1854+
required = *item.Required
1855+
}
1856+
return gateway.PlanTodoItem{
1857+
ID: strings.TrimSpace(item.ID),
1858+
Content: strings.TrimSpace(item.Content),
1859+
Status: strings.TrimSpace(string(item.Status)),
1860+
Required: required,
1861+
Artifacts: append([]string(nil), item.Artifacts...),
1862+
FailureReason: strings.TrimSpace(item.FailureReason),
1863+
BlockedReason: strings.TrimSpace(string(item.BlockedReason)),
1864+
Revision: item.Revision,
1865+
}
1866+
}
1867+
1868+
// convertRuntimePlanArtifact 将 runtime 当前计划快照映射为 gateway 公开契约。
1869+
func convertRuntimePlanArtifact(plan *agentsession.PlanArtifact) *gateway.PlanArtifact {
1870+
if plan == nil {
1871+
return nil
1872+
}
1873+
converted := &gateway.PlanArtifact{
1874+
ID: strings.TrimSpace(plan.ID),
1875+
Revision: plan.Revision,
1876+
Status: strings.TrimSpace(string(plan.Status)),
1877+
Spec: gateway.PlanSpec{
1878+
Goal: strings.TrimSpace(plan.Spec.Goal),
1879+
Steps: append([]string(nil), plan.Spec.Steps...),
1880+
Constraints: append([]string(nil), plan.Spec.Constraints...),
1881+
OpenQuestions: append([]string(nil), plan.Spec.OpenQuestions...),
1882+
},
1883+
Summary: gateway.PlanSummaryView{
1884+
Goal: strings.TrimSpace(plan.Summary.Goal),
1885+
KeySteps: append([]string(nil), plan.Summary.KeySteps...),
1886+
Constraints: append([]string(nil), plan.Summary.Constraints...),
1887+
ActiveTodoIDs: append([]string(nil), plan.Summary.ActiveTodoIDs...),
1888+
},
1889+
CreatedAt: plan.CreatedAt,
1890+
UpdatedAt: plan.UpdatedAt,
1891+
}
1892+
if len(plan.Spec.Todos) > 0 {
1893+
converted.Spec.Todos = make([]gateway.PlanTodoItem, 0, len(plan.Spec.Todos))
1894+
for _, item := range plan.Spec.Todos {
1895+
converted.Spec.Todos = append(converted.Spec.Todos, convertRuntimePlanTodoItem(item))
1896+
}
1897+
}
1898+
return converted
1899+
}
1900+
18501901
// convertRuntimeSessionToGatewaySession 将 runtime 会话结构映射为 gateway 契约返回值。
18511902
func convertRuntimeSessionToGatewaySession(session agentsession.Session) gateway.Session {
18521903
return gateway.Session{
1853-
ID: strings.TrimSpace(session.ID),
1854-
Title: strings.TrimSpace(session.Title),
1855-
CreatedAt: session.CreatedAt,
1856-
UpdatedAt: session.UpdatedAt,
1857-
Workdir: strings.TrimSpace(session.Workdir),
1858-
Provider: strings.TrimSpace(session.Provider),
1859-
Model: strings.TrimSpace(session.Model),
1860-
AgentMode: strings.TrimSpace(string(session.AgentMode)),
1861-
Messages: convertSessionMessages(session.Messages),
1904+
ID: strings.TrimSpace(session.ID),
1905+
Title: strings.TrimSpace(session.Title),
1906+
CreatedAt: session.CreatedAt,
1907+
UpdatedAt: session.UpdatedAt,
1908+
Workdir: strings.TrimSpace(session.Workdir),
1909+
Provider: strings.TrimSpace(session.Provider),
1910+
Model: strings.TrimSpace(session.Model),
1911+
AgentMode: strings.TrimSpace(string(session.AgentMode)),
1912+
CurrentPlan: convertRuntimePlanArtifact(session.CurrentPlan),
1913+
Messages: convertSessionMessages(session.Messages),
18621914
}
18631915
}
18641916

internal/cli/gateway_runtime_bridge_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1471,6 +1471,44 @@ func TestConvertGatewayRunInputAndSessionHelpers(t *testing.T) {
14711471
}
14721472
}
14731473

1474+
func TestConvertRuntimeSessionToGatewaySessionIncludesCurrentPlan(t *testing.T) {
1475+
required := true
1476+
session := agentsession.New("plan session")
1477+
session.AgentMode = agentsession.AgentModePlan
1478+
session.CurrentPlan = &agentsession.PlanArtifact{
1479+
ID: "plan-1",
1480+
Revision: 2,
1481+
Status: agentsession.PlanStatusDraft,
1482+
Spec: agentsession.PlanSpec{
1483+
Goal: "修复 web plan 展示",
1484+
Steps: []string{"发事件", "渲染卡片"},
1485+
Constraints: []string{"不创建执行 todo"},
1486+
OpenQuestions: []string{"是否需要审批按钮"},
1487+
Todos: []agentsession.TodoItem{{
1488+
ID: "todo-1",
1489+
Content: "legacy todo",
1490+
Status: agentsession.TodoStatusPending,
1491+
Required: &required,
1492+
}},
1493+
},
1494+
Summary: agentsession.SummaryView{
1495+
Goal: "修复 web plan 展示",
1496+
KeySteps: []string{"发事件"},
1497+
},
1498+
}
1499+
1500+
converted := convertRuntimeSessionToGatewaySession(session)
1501+
if converted.CurrentPlan == nil {
1502+
t.Fatal("expected current_plan to be present")
1503+
}
1504+
if converted.CurrentPlan.ID != "plan-1" || converted.CurrentPlan.Spec.Goal != "修复 web plan 展示" {
1505+
t.Fatalf("unexpected current_plan: %+v", converted.CurrentPlan)
1506+
}
1507+
if len(converted.CurrentPlan.Spec.Todos) != 1 || !converted.CurrentPlan.Spec.Todos[0].Required {
1508+
t.Fatalf("unexpected plan todos: %+v", converted.CurrentPlan.Spec.Todos)
1509+
}
1510+
}
1511+
14741512
func TestGatewayRuntimePortBridgeDeleteSession(t *testing.T) {
14751513
t.Run("success", func(t *testing.T) {
14761514
store := &bridgeSessionStoreStub{

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/gateway/contracts.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,46 @@ type SessionMessage struct {
680680
IsError bool `json:"is_error,omitempty"`
681681
}
682682

683+
// PlanTodoItem 表示计划正文中保留的 legacy todo 项,仅用于展示和兼容读取。
684+
type PlanTodoItem struct {
685+
ID string `json:"id"`
686+
Content string `json:"content"`
687+
Status string `json:"status,omitempty"`
688+
Required bool `json:"required,omitempty"`
689+
Artifacts []string `json:"artifacts,omitempty"`
690+
FailureReason string `json:"failure_reason,omitempty"`
691+
BlockedReason string `json:"blocked_reason,omitempty"`
692+
Revision int64 `json:"revision,omitempty"`
693+
}
694+
695+
// PlanSpec 表示当前完整计划的公开结构。
696+
type PlanSpec struct {
697+
Goal string `json:"goal"`
698+
Steps []string `json:"steps,omitempty"`
699+
Constraints []string `json:"constraints,omitempty"`
700+
Todos []PlanTodoItem `json:"todos,omitempty"`
701+
OpenQuestions []string `json:"open_questions,omitempty"`
702+
}
703+
704+
// PlanSummaryView 表示完整计划的紧凑摘要。
705+
type PlanSummaryView struct {
706+
Goal string `json:"goal"`
707+
KeySteps []string `json:"key_steps,omitempty"`
708+
Constraints []string `json:"constraints,omitempty"`
709+
ActiveTodoIDs []string `json:"active_todo_ids,omitempty"`
710+
}
711+
712+
// PlanArtifact 表示会话当前计划快照。
713+
type PlanArtifact struct {
714+
ID string `json:"id"`
715+
Revision int `json:"revision"`
716+
Status string `json:"status"`
717+
Spec PlanSpec `json:"spec"`
718+
Summary PlanSummaryView `json:"summary"`
719+
CreatedAt time.Time `json:"created_at"`
720+
UpdatedAt time.Time `json:"updated_at"`
721+
}
722+
683723
// Session 表示网关视角的会话详情。
684724
type Session struct {
685725
// ID 是会话标识。
@@ -698,6 +738,8 @@ type Session struct {
698738
Model string `json:"model,omitempty"`
699739
// AgentMode 是会话当前 Agent 工作模式。
700740
AgentMode string `json:"agent_mode,omitempty"`
741+
// CurrentPlan 是会话当前结构化计划快照。
742+
CurrentPlan *PlanArtifact `json:"current_plan,omitempty"`
701743
// Messages 是会话消息快照。
702744
Messages []SessionMessage `json:"messages,omitempty"`
703745
}

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/events.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55

66
"neo-code/internal/runtime/acceptgate"
77
"neo-code/internal/runtime/controlplane"
8+
agentsession "neo-code/internal/session"
89
)
910

1011
// EventType 标识 runtime 事件类型。
@@ -101,6 +102,12 @@ type AcceptanceDecidedPayload struct {
101102
Results []acceptgate.CheckResult `json:"results,omitempty"`
102103
}
103104

105+
// PlanUpdatedPayload 描述 plan 模式生成或改写后的结构化计划快照。
106+
type PlanUpdatedPayload struct {
107+
CurrentPlan *agentsession.PlanArtifact `json:"current_plan"`
108+
DisplayText string `json:"display_text,omitempty"`
109+
}
110+
104111
// LedgerReconciledPayload 为账本对账预留负载。
105112
type LedgerReconciledPayload struct {
106113
AttemptSeq int `json:"attempt_seq"`
@@ -320,6 +327,8 @@ const (
320327
EventThinkingDelta EventType = "thinking_delta"
321328
// EventAgentDone 表示 assistant 正常结束。
322329
EventAgentDone EventType = "agent_done"
330+
// EventPlanUpdated 表示当前结构化计划已生成或更新。
331+
EventPlanUpdated EventType = "plan_updated"
323332
// EventToolStart 表示工具开始执行。
324333
EventToolStart EventType = "tool_start"
325334
// EventToolResult 表示工具执行完成并写回会话。

internal/runtime/planning.go

Lines changed: 62 additions & 7 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, '{')
@@ -258,11 +281,43 @@ func buildPlanArtifact(current *agentsession.PlanArtifact, output planTurnOutput
258281
return plan, nil
259282
}
260283

261-
// resolvePlanDisplayText 优先保留模型对计划的额外说明文本,缺失时回退为规范计划正文。
262-
func resolvePlanDisplayText(output planTurnOutput, spec agentsession.PlanSpec) string {
263-
display := strings.TrimSpace(output.DisplayText)
264-
if display != "" {
265-
return display
284+
// renderPlanMarkdown 将结构化计划渲染为前端可直接展示的规范 Markdown。
285+
func renderPlanMarkdown(spec agentsession.PlanSpec) string {
286+
spec, err := agentsession.NormalizePlanSpec(spec)
287+
if err != nil {
288+
return ""
289+
}
290+
sections := make([]string, 0, 4)
291+
sections = append(sections, "### 目标\n\n"+spec.Goal)
292+
if len(spec.Steps) > 0 {
293+
sections = append(sections, "### 实施步骤\n\n"+renderMarkdownBulletList(spec.Steps))
294+
}
295+
if len(spec.Constraints) > 0 {
296+
sections = append(sections, "### 约束\n\n"+renderMarkdownBulletList(spec.Constraints))
297+
}
298+
if len(spec.OpenQuestions) > 0 {
299+
sections = append(sections, "### 未决问题\n\n"+renderMarkdownBulletList(spec.OpenQuestions))
300+
}
301+
return strings.TrimSpace(strings.Join(sections, "\n\n"))
302+
}
303+
304+
// renderMarkdownBulletList 将计划字段中的字符串列表渲染为 Markdown 无序列表。
305+
func renderMarkdownBulletList(items []string) string {
306+
lines := make([]string, 0, len(items))
307+
for _, item := range items {
308+
trimmed := strings.TrimSpace(item)
309+
if trimmed == "" {
310+
continue
311+
}
312+
lines = append(lines, "- "+trimmed)
313+
}
314+
return strings.Join(lines, "\n")
315+
}
316+
317+
// resolvePlanDisplayText 在解析出机器可读计划后固定返回规范化计划正文,不保留模型额外说明。
318+
func resolvePlanDisplayText(_ planTurnOutput, spec agentsession.PlanSpec) string {
319+
if markdown := renderPlanMarkdown(spec); markdown != "" {
320+
return markdown
266321
}
267322
return strings.TrimSpace(agentsession.RenderPlanContent(spec))
268323
}

0 commit comments

Comments
 (0)