Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 61 additions & 9 deletions internal/cli/gateway_runtime_bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -1847,18 +1847,70 @@ func convertSessionMessages(messages []providertypes.Message) []gateway.SessionM
return converted
}

// convertRuntimePlanTodoItem 将 session 计划中的 legacy todo 项映射为 gateway 展示结构。
func convertRuntimePlanTodoItem(item agentsession.TodoItem) gateway.PlanTodoItem {
required := false
if item.Required != nil {
required = *item.Required
}
return gateway.PlanTodoItem{
ID: strings.TrimSpace(item.ID),
Content: strings.TrimSpace(item.Content),
Status: strings.TrimSpace(string(item.Status)),
Required: required,
Artifacts: append([]string(nil), item.Artifacts...),
FailureReason: strings.TrimSpace(item.FailureReason),
BlockedReason: strings.TrimSpace(string(item.BlockedReason)),
Revision: item.Revision,
}
}

// convertRuntimePlanArtifact 将 runtime 当前计划快照映射为 gateway 公开契约。
func convertRuntimePlanArtifact(plan *agentsession.PlanArtifact) *gateway.PlanArtifact {
if plan == nil {
return nil
}
converted := &gateway.PlanArtifact{
ID: strings.TrimSpace(plan.ID),
Revision: plan.Revision,
Status: strings.TrimSpace(string(plan.Status)),
Spec: gateway.PlanSpec{
Goal: strings.TrimSpace(plan.Spec.Goal),
Steps: append([]string(nil), plan.Spec.Steps...),
Constraints: append([]string(nil), plan.Spec.Constraints...),
OpenQuestions: append([]string(nil), plan.Spec.OpenQuestions...),
},
Summary: gateway.PlanSummaryView{
Goal: strings.TrimSpace(plan.Summary.Goal),
KeySteps: append([]string(nil), plan.Summary.KeySteps...),
Constraints: append([]string(nil), plan.Summary.Constraints...),
ActiveTodoIDs: append([]string(nil), plan.Summary.ActiveTodoIDs...),
},
CreatedAt: plan.CreatedAt,
UpdatedAt: plan.UpdatedAt,
}
if len(plan.Spec.Todos) > 0 {
converted.Spec.Todos = make([]gateway.PlanTodoItem, 0, len(plan.Spec.Todos))
for _, item := range plan.Spec.Todos {
converted.Spec.Todos = append(converted.Spec.Todos, convertRuntimePlanTodoItem(item))
}
}
return converted
}

// convertRuntimeSessionToGatewaySession 将 runtime 会话结构映射为 gateway 契约返回值。
func convertRuntimeSessionToGatewaySession(session agentsession.Session) gateway.Session {
return gateway.Session{
ID: strings.TrimSpace(session.ID),
Title: strings.TrimSpace(session.Title),
CreatedAt: session.CreatedAt,
UpdatedAt: session.UpdatedAt,
Workdir: strings.TrimSpace(session.Workdir),
Provider: strings.TrimSpace(session.Provider),
Model: strings.TrimSpace(session.Model),
AgentMode: strings.TrimSpace(string(session.AgentMode)),
Messages: convertSessionMessages(session.Messages),
ID: strings.TrimSpace(session.ID),
Title: strings.TrimSpace(session.Title),
CreatedAt: session.CreatedAt,
UpdatedAt: session.UpdatedAt,
Workdir: strings.TrimSpace(session.Workdir),
Provider: strings.TrimSpace(session.Provider),
Model: strings.TrimSpace(session.Model),
AgentMode: strings.TrimSpace(string(session.AgentMode)),
CurrentPlan: convertRuntimePlanArtifact(session.CurrentPlan),
Messages: convertSessionMessages(session.Messages),
}
}

Expand Down
38 changes: 38 additions & 0 deletions internal/cli/gateway_runtime_bridge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1471,6 +1471,44 @@ func TestConvertGatewayRunInputAndSessionHelpers(t *testing.T) {
}
}

func TestConvertRuntimeSessionToGatewaySessionIncludesCurrentPlan(t *testing.T) {
required := true
session := agentsession.New("plan session")
session.AgentMode = agentsession.AgentModePlan
session.CurrentPlan = &agentsession.PlanArtifact{
ID: "plan-1",
Revision: 2,
Status: agentsession.PlanStatusDraft,
Spec: agentsession.PlanSpec{
Goal: "修复 web plan 展示",
Steps: []string{"发事件", "渲染卡片"},
Constraints: []string{"不创建执行 todo"},
OpenQuestions: []string{"是否需要审批按钮"},
Todos: []agentsession.TodoItem{{
ID: "todo-1",
Content: "legacy todo",
Status: agentsession.TodoStatusPending,
Required: &required,
}},
},
Summary: agentsession.SummaryView{
Goal: "修复 web plan 展示",
KeySteps: []string{"发事件"},
},
}

converted := convertRuntimeSessionToGatewaySession(session)
if converted.CurrentPlan == nil {
t.Fatal("expected current_plan to be present")
}
if converted.CurrentPlan.ID != "plan-1" || converted.CurrentPlan.Spec.Goal != "修复 web plan 展示" {
t.Fatalf("unexpected current_plan: %+v", converted.CurrentPlan)
}
if len(converted.CurrentPlan.Spec.Todos) != 1 || !converted.CurrentPlan.Spec.Todos[0].Required {
t.Fatalf("unexpected plan todos: %+v", converted.CurrentPlan.Spec.Todos)
}
}

func TestGatewayRuntimePortBridgeDeleteSession(t *testing.T) {
t.Run("success", func(t *testing.T) {
store := &bridgeSessionStoreStub{
Expand Down
2 changes: 1 addition & 1 deletion internal/context/source_plan_mode.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func (planModeContextSource) Sections(ctx context.Context, input BuildInput) ([]
if stage == "plan" {
noPlanHint := promptSection{
Title: "Current Plan",
Content: "status: none\n\nNo current plan exists. You must create one by outputting a `plan_spec` + `summary_candidate` JSON before this turn ends.",
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.",
}
sections = append(sections, noPlanHint)
}
Expand Down
31 changes: 31 additions & 0 deletions internal/context/source_plan_mode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,37 @@ func TestPlanModeSectionsReturnsWithoutPlanWhenNil(t *testing.T) {
}
}

func TestPlanModeSectionsNoCurrentPlanUsesHTMLCommentContract(t *testing.T) {
t.Parallel()

source := planModeContextSource{}
sections, err := source.Sections(context.Background(), BuildInput{
AgentMode: agentsession.AgentModePlan,
PlanStage: "plan",
CurrentPlan: nil,
})
if err != nil {
t.Fatalf("Sections() error = %v", err)
}
var currentPlanContent string
for _, section := range sections {
if section.Title == "Current Plan" {
currentPlanContent = section.Content
break
}
}
if currentPlanContent == "" {
t.Fatal("expected Current Plan section when plan stage has no current plan")
}
if !strings.Contains(currentPlanContent, "visible Markdown plan") ||
!strings.Contains(currentPlanContent, "inside an HTML comment") {
t.Fatalf("Current Plan hint = %q, want Markdown plus HTML comment JSON contract", currentPlanContent)
}
if strings.Contains(currentPlanContent, "outputting a `plan_spec` + `summary_candidate` JSON") {
t.Fatalf("Current Plan hint should not use old JSON-only wording: %q", currentPlanContent)
}
}

func TestPlanModeSectionsContextError(t *testing.T) {
t.Parallel()

Expand Down
42 changes: 42 additions & 0 deletions internal/gateway/contracts.go
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,46 @@ type SessionMessage struct {
IsError bool `json:"is_error,omitempty"`
}

// PlanTodoItem 表示计划正文中保留的 legacy todo 项,仅用于展示和兼容读取。
type PlanTodoItem struct {
ID string `json:"id"`
Content string `json:"content"`
Status string `json:"status,omitempty"`
Required bool `json:"required,omitempty"`
Artifacts []string `json:"artifacts,omitempty"`
FailureReason string `json:"failure_reason,omitempty"`
BlockedReason string `json:"blocked_reason,omitempty"`
Revision int64 `json:"revision,omitempty"`
}

// PlanSpec 表示当前完整计划的公开结构。
type PlanSpec struct {
Goal string `json:"goal"`
Steps []string `json:"steps,omitempty"`
Constraints []string `json:"constraints,omitempty"`
Todos []PlanTodoItem `json:"todos,omitempty"`
OpenQuestions []string `json:"open_questions,omitempty"`
}

// PlanSummaryView 表示完整计划的紧凑摘要。
type PlanSummaryView struct {
Goal string `json:"goal"`
KeySteps []string `json:"key_steps,omitempty"`
Constraints []string `json:"constraints,omitempty"`
ActiveTodoIDs []string `json:"active_todo_ids,omitempty"`
}

// PlanArtifact 表示会话当前计划快照。
type PlanArtifact struct {
ID string `json:"id"`
Revision int `json:"revision"`
Status string `json:"status"`
Spec PlanSpec `json:"spec"`
Summary PlanSummaryView `json:"summary"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

// Session 表示网关视角的会话详情。
type Session struct {
// ID 是会话标识。
Expand All @@ -698,6 +738,8 @@ type Session struct {
Model string `json:"model,omitempty"`
// AgentMode 是会话当前 Agent 工作模式。
AgentMode string `json:"agent_mode,omitempty"`
// CurrentPlan 是会话当前结构化计划快照。
CurrentPlan *PlanArtifact `json:"current_plan,omitempty"`
// Messages 是会话消息快照。
Messages []SessionMessage `json:"messages,omitempty"`
}
Expand Down
6 changes: 6 additions & 0 deletions internal/promptasset/assets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ func TestPlanModePromptTemplates(t *testing.T) {
strings.Contains(PlanModePrompt("plan"), "must not be empty") {
t.Fatalf("expected plan prompt not to require execution todo ownership")
}
if strings.Contains(PlanModePrompt("plan"), "Only output a JSON object") {
t.Fatalf("expected plan prompt not to require JSON-only output")
}
if !strings.Contains(PlanModePrompt("plan"), "inside an HTML comment") {
t.Fatalf("expected plan prompt to require machine-readable JSON in an HTML comment")
}
if !strings.Contains(PlanModePrompt("plan"), "Do not create execution todos in plan mode") {
t.Fatalf("expected plan prompt to keep todos in build execution")
}
Expand Down
4 changes: 2 additions & 2 deletions internal/promptasset/templates/context/plan_mode_plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ You are currently in the planning stage.
- You may research, analyze, ask clarifying questions, and produce a plan.
- Do not perform any write action in this stage.
- Do not rewrite the current full plan unless the conversation clearly requires creating or replacing the plan itself.
- **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.
- **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.
- 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.
- Only output a JSON object containing `plan_spec` and `summary_candidate` when you are explicitly creating or rewriting the current full plan.
- 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.
- `plan_spec` must include `goal`, `steps`, `constraints`, and `open_questions`.
- `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.
- `summary_candidate` must include `goal`, `key_steps`, and `constraints`.
Expand Down
9 changes: 9 additions & 0 deletions internal/runtime/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (

"neo-code/internal/runtime/acceptgate"
"neo-code/internal/runtime/controlplane"
agentsession "neo-code/internal/session"
)

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

// PlanUpdatedPayload 描述 plan 模式生成或改写后的结构化计划快照。
type PlanUpdatedPayload struct {
CurrentPlan *agentsession.PlanArtifact `json:"current_plan"`
DisplayText string `json:"display_text,omitempty"`
}

// LedgerReconciledPayload 为账本对账预留负载。
type LedgerReconciledPayload struct {
AttemptSeq int `json:"attempt_seq"`
Expand Down Expand Up @@ -320,6 +327,8 @@ const (
EventThinkingDelta EventType = "thinking_delta"
// EventAgentDone 表示 assistant 正常结束。
EventAgentDone EventType = "agent_done"
// EventPlanUpdated 表示当前结构化计划已生成或更新。
EventPlanUpdated EventType = "plan_updated"
// EventToolStart 表示工具开始执行。
EventToolStart EventType = "tool_start"
// EventToolResult 表示工具执行完成并写回会话。
Expand Down
69 changes: 62 additions & 7 deletions internal/runtime/planning.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,9 @@ func decodePlanTurnOutput(jsonText string) (planTurnOutput, error) {

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

// planningJSONObjectRemovalRange 扩展结构化 JSON 的剥离范围,避免 HTML 注释外壳泄漏到可见计划正文。
func planningJSONObjectRemovalRange(text string, candidate extractedPlanningJSONObject) (int, int) {
start := candidate.Start
end := candidate.End
if start < 0 || end < start || end > len(text) {
return candidate.Start, candidate.End
}

prefix := text[:start]
open := strings.LastIndex(prefix, "<!--")
if open < 0 || strings.TrimSpace(prefix[open+len("<!--"):]) != "" {
return start, end
}

suffix := text[end:]
closeOffset := strings.Index(suffix, "-->")
if closeOffset < 0 || strings.TrimSpace(suffix[:closeOffset]) != "" {
return start, end
}
return open, end + closeOffset + len("-->")
}

// extractPlanningJSONObjectIfPresent 在文本中提取首个满足指定顶层键契约的 JSON 对象。
func extractPlanningJSONObjectIfPresent(text string, requiredKey string) (extractedPlanningJSONObject, bool) {
start := strings.IndexByte(text, '{')
Expand Down Expand Up @@ -258,11 +281,43 @@ func buildPlanArtifact(current *agentsession.PlanArtifact, output planTurnOutput
return plan, nil
}

// resolvePlanDisplayText 优先保留模型对计划的额外说明文本,缺失时回退为规范计划正文。
func resolvePlanDisplayText(output planTurnOutput, spec agentsession.PlanSpec) string {
display := strings.TrimSpace(output.DisplayText)
if display != "" {
return display
// renderPlanMarkdown 将结构化计划渲染为前端可直接展示的规范 Markdown。
func renderPlanMarkdown(spec agentsession.PlanSpec) string {
spec, err := agentsession.NormalizePlanSpec(spec)
if err != nil {
return ""
}
sections := make([]string, 0, 4)
sections = append(sections, "### 目标\n\n"+spec.Goal)
if len(spec.Steps) > 0 {
sections = append(sections, "### 实施步骤\n\n"+renderMarkdownBulletList(spec.Steps))
}
if len(spec.Constraints) > 0 {
sections = append(sections, "### 约束\n\n"+renderMarkdownBulletList(spec.Constraints))
}
if len(spec.OpenQuestions) > 0 {
sections = append(sections, "### 未决问题\n\n"+renderMarkdownBulletList(spec.OpenQuestions))
}
return strings.TrimSpace(strings.Join(sections, "\n\n"))
}

// renderMarkdownBulletList 将计划字段中的字符串列表渲染为 Markdown 无序列表。
func renderMarkdownBulletList(items []string) string {
lines := make([]string, 0, len(items))
for _, item := range items {
trimmed := strings.TrimSpace(item)
if trimmed == "" {
continue
}
lines = append(lines, "- "+trimmed)
}
return strings.Join(lines, "\n")
}

// resolvePlanDisplayText 在解析出机器可读计划后固定返回规范化计划正文,不保留模型额外说明。
func resolvePlanDisplayText(_ planTurnOutput, spec agentsession.PlanSpec) string {
if markdown := renderPlanMarkdown(spec); markdown != "" {
return markdown
}
return strings.TrimSpace(agentsession.RenderPlanContent(spec))
}
Expand Down
Loading
Loading