From 7fec57655e9d5389cd827a5191d61d13a3c455f6 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Thu, 30 Apr 2026 10:38:52 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(runtime):=E5=A2=9E=E5=8A=A0build/plan?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/context/builder.go | 1 + internal/context/builder_test.go | 45 + internal/context/source_plan_mode.go | 102 ++ internal/context/types.go | 4 + internal/promptasset/assets.go | 16 + internal/promptasset/assets_test.go | 31 + .../context/plan_mode_build_execute.md | 11 + .../templates/context/plan_mode_plan.md | 9 + internal/runtime/budget_models.go | 3 + internal/runtime/compact.go | 6 + internal/runtime/input_prepare.go | 1 + internal/runtime/permission.go | 1 + internal/runtime/plan_approval.go | 31 + internal/runtime/planning.go | 339 +++++++ internal/runtime/planning_test.go | 368 ++++++++ internal/runtime/run.go | 81 +- internal/runtime/runtime.go | 14 + internal/runtime/runtime_test.go | 890 ++++++++++++++++++ internal/runtime/session_mutation.go | 1 + internal/runtime/session_scheduler.go | 23 +- internal/runtime/state.go | 1 + internal/session/plan.go | 314 ++++++ internal/session/plan_test.go | 190 ++++ internal/session/sqlite_store.go | 343 ++++++- internal/session/store.go | 122 ++- internal/session/store_test.go | 165 ++++ internal/tools/manager.go | 23 +- internal/tools/manager_test.go | 54 ++ internal/tools/mode_filter.go | 27 + internal/tools/types.go | 1 + 30 files changed, 3126 insertions(+), 91 deletions(-) create mode 100644 internal/context/source_plan_mode.go create mode 100644 internal/promptasset/templates/context/plan_mode_build_execute.md create mode 100644 internal/promptasset/templates/context/plan_mode_plan.md create mode 100644 internal/runtime/plan_approval.go create mode 100644 internal/runtime/planning.go create mode 100644 internal/runtime/planning_test.go create mode 100644 internal/session/plan.go create mode 100644 internal/session/plan_test.go create mode 100644 internal/tools/mode_filter.go diff --git a/internal/context/builder.go b/internal/context/builder.go index 873c04a8..47fde6a2 100644 --- a/internal/context/builder.go +++ b/internal/context/builder.go @@ -21,6 +21,7 @@ func newPromptSources(extra ...SectionSource) []promptSectionSource { corePromptSource{}, &projectRulesSource{}, taskStateSource{}, + planModeContextSource{}, todosSource{}, skillPromptSource{}, } diff --git a/internal/context/builder_test.go b/internal/context/builder_test.go index fdd67994..41f53806 100644 --- a/internal/context/builder_test.go +++ b/internal/context/builder_test.go @@ -143,6 +143,51 @@ func TestDefaultBuilderBuildIncludesTaskStateBeforeSystemState(t *testing.T) { } } +func TestDefaultBuilderBuildIncludesPlanSections(t *testing.T) { + t.Parallel() + + builder := NewBuilder() + got, err := builder.Build(stdcontext.Background(), BuildInput{ + AgentMode: agentsession.AgentModePlan, + PlanStage: "plan", + CurrentPlan: &agentsession.PlanArtifact{ + ID: "plan-1", + Revision: 3, + Status: agentsession.PlanStatusDraft, + Spec: agentsession.PlanSpec{ + Goal: "引入 plan/build 模式", + Steps: []string{"扩展 session", "扩展 runtime"}, + Constraints: []string{"保持 tools 边界"}, + Verify: []string{"go test ./internal/..."}, + }, + Summary: agentsession.SummaryView{ + Goal: "引入 plan/build 模式", + KeySteps: []string{"扩展 session", "扩展 runtime"}, + Constraints: []string{"保持 tools 边界"}, + Verify: []string{"go test ./internal/..."}, + ActiveTodoIDs: []string{"todo-1"}, + }, + }, + Metadata: testMetadata(t.TempDir()), + InjectFullPlan: true, + }) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + if !strings.Contains(got.SystemPrompt, "## Plan Mode") { + t.Fatalf("expected plan mode section, got %q", got.SystemPrompt) + } + if !strings.Contains(got.SystemPrompt, "You are currently in the planning stage.") { + t.Fatalf("expected plan mode prompt asset content, got %q", got.SystemPrompt) + } + if !strings.Contains(got.SystemPrompt, "## Current Plan") { + t.Fatalf("expected current plan section, got %q", got.SystemPrompt) + } + if !strings.Contains(got.SystemPrompt, "full_plan_view:") { + t.Fatalf("expected full plan view in prompt, got %q", got.SystemPrompt) + } +} + func TestDefaultBuilderBuildIncludesTodosBeforeSystemState(t *testing.T) { t.Parallel() diff --git a/internal/context/source_plan_mode.go b/internal/context/source_plan_mode.go new file mode 100644 index 00000000..5ca40fe9 --- /dev/null +++ b/internal/context/source_plan_mode.go @@ -0,0 +1,102 @@ +package context + +import ( + "context" + "fmt" + "strings" + + "neo-code/internal/promptasset" + agentsession "neo-code/internal/session" +) + +// planModeContextSource injects plan/build mode guidance plus the current plan projection. +type planModeContextSource struct{} + +// Sections renders the relevant mode guidance and optional current plan section. +func (planModeContextSource) Sections(ctx context.Context, input BuildInput) ([]promptSection, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + mode := agentsession.NormalizeAgentMode(input.AgentMode) + stage := strings.TrimSpace(input.PlanStage) + if stage == "" { + return nil, nil + } + + sections := make([]promptSection, 0, 2) + modeSection := renderPlanModeSection(mode, stage) + if modeSection.Content != "" { + sections = append(sections, modeSection) + } + + if input.CurrentPlan == nil { + return sections, nil + } + planSection := renderCurrentPlanSection(input.CurrentPlan, input.InjectFullPlan) + if planSection.Content != "" { + sections = append(sections, planSection) + } + return sections, nil +} + +func renderPlanModeSection(mode agentsession.AgentMode, stage string) promptSection { + lines := make([]string, 0, 4) + lines = append(lines, fmt.Sprintf("current_mode: %q", mode)) + lines = append(lines, fmt.Sprintf("current_stage: %q", stage)) + if content := strings.TrimSpace(promptasset.PlanModePrompt(stage)); content != "" { + lines = append(lines, content) + } + return promptSection{ + Title: "Plan Mode", + Content: strings.Join(lines, "\n"), + } +} + +func renderCurrentPlanSection(plan *agentsession.PlanArtifact, injectFull bool) promptSection { + if plan == nil { + return promptSection{} + } + lines := make([]string, 0, 16) + lines = append(lines, + fmt.Sprintf("plan_id: %q", strings.TrimSpace(plan.ID)), + fmt.Sprintf("revision: %d", plan.Revision), + fmt.Sprintf("status: %q", agentsession.NormalizePlanStatus(plan.Status)), + ) + if goal := strings.TrimSpace(plan.Summary.Goal); goal != "" { + lines = append(lines, fmt.Sprintf("goal: %q", goal)) + } + if len(plan.Summary.KeySteps) > 0 { + lines = append(lines, "key_steps:") + for _, step := range plan.Summary.KeySteps { + lines = append(lines, "- "+step) + } + } + if len(plan.Summary.Constraints) > 0 { + lines = append(lines, "constraints:") + for _, constraint := range plan.Summary.Constraints { + lines = append(lines, "- "+constraint) + } + } + if len(plan.Summary.Verify) > 0 { + lines = append(lines, "verify:") + for _, check := range plan.Summary.Verify { + lines = append(lines, "- "+check) + } + } + if len(plan.Summary.ActiveTodoIDs) > 0 { + lines = append(lines, "active_todo_ids:") + for _, todoID := range plan.Summary.ActiveTodoIDs { + lines = append(lines, "- "+todoID) + } + } + if injectFull { + if full := strings.TrimSpace(agentsession.RenderPlanContent(plan.Spec)); full != "" { + lines = append(lines, "", "full_plan_view:", full) + } + } + return promptSection{ + Title: "Current Plan", + Content: strings.Join(lines, "\n"), + } +} diff --git a/internal/context/types.go b/internal/context/types.go index 356abfee..244060a3 100644 --- a/internal/context/types.go +++ b/internal/context/types.go @@ -20,6 +20,10 @@ type BuildInput struct { Messages []providertypes.Message TaskState agentsession.TaskState Todos []agentsession.TodoItem + AgentMode agentsession.AgentMode + PlanStage string + CurrentPlan *agentsession.PlanArtifact + InjectFullPlan bool ActiveSkills []skills.Skill RepositorySummary *RepositorySummarySection Repository RepositoryContext diff --git a/internal/promptasset/assets.go b/internal/promptasset/assets.go index 95b569e9..f8e8b0ed 100644 --- a/internal/promptasset/assets.go +++ b/internal/promptasset/assets.go @@ -28,6 +28,10 @@ var repeatCycleReminder = mustReadTemplate("templates/runtime/self_healing_repea var compactSystemPromptTemplate = mustReadTemplate("templates/context/compact_system_prompt.md") +var planModePlanPrompt = mustReadTemplate("templates/context/plan_mode_plan.md") + +var planModeBuildExecutePrompt = mustReadTemplate("templates/context/plan_mode_build_execute.md") + var researcherRolePrompt = mustReadTemplate("templates/subagent/researcher.md") var coderRolePrompt = mustReadTemplate("templates/subagent/coder.md") @@ -58,6 +62,18 @@ func CompactSystemPrompt(taskStateContract string, summaryFormat string) string return strings.TrimSpace(replacer.Replace(compactSystemPromptTemplate)) } +// PlanModePrompt 返回 plan/build 不同阶段对应的静态提示模板,供 context source 注入动态字段前复用。 +func PlanModePrompt(stage string) string { + switch strings.TrimSpace(stage) { + case "plan": + return planModePlanPrompt + case "build_execute": + return planModeBuildExecutePrompt + default: + return "" + } +} + // ResearcherRolePrompt 返回 researcher 子代理基础 prompt。 func ResearcherRolePrompt() string { return researcherRolePrompt diff --git a/internal/promptasset/assets_test.go b/internal/promptasset/assets_test.go index daa9895b..43c21456 100644 --- a/internal/promptasset/assets_test.go +++ b/internal/promptasset/assets_test.go @@ -66,6 +66,37 @@ func TestRuntimeReminderTemplates(t *testing.T) { } } +func TestPlanModePromptTemplates(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + stage string + want string + }{ + {name: "plan", stage: "plan", want: "planning stage"}, + {name: "build execute", stage: "build_execute", want: "build execution"}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + prompt := PlanModePrompt(tt.stage) + if strings.TrimSpace(prompt) == "" { + t.Fatalf("PlanModePrompt(%q) should not be empty", tt.stage) + } + if !strings.Contains(prompt, tt.want) { + t.Fatalf("PlanModePrompt(%q) = %q, want substring %q", tt.stage, prompt, tt.want) + } + }) + } + + if got := PlanModePrompt("unknown"); got != "" { + t.Fatalf("PlanModePrompt(unknown) = %q, want empty", got) + } +} + func joinCoreSectionContent() string { sections := CoreSections() parts := make([]string, 0, len(sections)) diff --git a/internal/promptasset/templates/context/plan_mode_build_execute.md b/internal/promptasset/templates/context/plan_mode_build_execute.md new file mode 100644 index 00000000..25a024bc --- /dev/null +++ b/internal/promptasset/templates/context/plan_mode_build_execute.md @@ -0,0 +1,11 @@ +You are currently in build execution. + +- Execute the task directly. +- If a current plan summary is attached, use it as guidance by default. +- If the summary is insufficient for the current task, consult the attached full plan view when available. +- If no current plan is attached, continue using task state, todos, and the conversation context. +- Small necessary deviations are allowed, but explain why they are needed. +- Do not create or rewrite the current full plan in this stage. +- If the current plan appears outdated, explain the mismatch and continue, or recommend switching back to planning. +- Do not output `plan_spec` or `summary_candidate` in build execution. +- When you believe the task tied to the current plan is complete, start your reply with a JSON object of the form `{"task_completion":{"completed":true}}`, then continue with the normal user-facing completion message. diff --git a/internal/promptasset/templates/context/plan_mode_plan.md b/internal/promptasset/templates/context/plan_mode_plan.md new file mode 100644 index 00000000..7531cbce --- /dev/null +++ b/internal/promptasset/templates/context/plan_mode_plan.md @@ -0,0 +1,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 you are only answering questions, comparing options, clarifying constraints, or refining details, do not output planning JSON. +- Only output a JSON object containing `plan_spec` and `summary_candidate` when you are explicitly creating or rewriting the current full plan. +- `plan_spec` must include `goal`, `steps`, `constraints`, `verify`, `todos`, and `open_questions`. +- `summary_candidate` must include `goal`, `key_steps`, `constraints`, `verify`, and `active_todo_ids`. diff --git a/internal/runtime/budget_models.go b/internal/runtime/budget_models.go index b573eeb2..f4ad6289 100644 --- a/internal/runtime/budget_models.go +++ b/internal/runtime/budget_models.go @@ -22,6 +22,7 @@ type TurnBudgetSnapshot struct { CompactCount int NoProgressStreakLimit int RepeatCycleStreakLimit int + InjectFullPlan bool Request providertypes.GenerateRequest } @@ -62,6 +63,7 @@ func newTurnBudgetSnapshot( compactCount int, noProgressStreakLimit int, repeatCycleStreakLimit int, + injectFullPlan bool, request providertypes.GenerateRequest, ) TurnBudgetSnapshot { if attemptSeq <= 0 { @@ -82,6 +84,7 @@ func newTurnBudgetSnapshot( CompactCount: compactCount, NoProgressStreakLimit: noProgressStreakLimit, RepeatCycleStreakLimit: repeatCycleStreakLimit, + InjectFullPlan: injectFullPlan, Request: request, } } diff --git a/internal/runtime/compact.go b/internal/runtime/compact.go index bea7b2a5..e39cfb59 100644 --- a/internal/runtime/compact.go +++ b/internal/runtime/compact.go @@ -89,6 +89,12 @@ func (s *Service) Compact(ctx context.Context, input CompactInput) (CompactResul if err != nil { return CompactResult{}, err } + if result.Applied && markCurrentPlanContextDirty(&session) { + session.UpdatedAt = time.Now() + if err := s.sessionStore.UpdateSessionState(ctx, sessionStateInputFromSession(session)); err != nil { + return CompactResult{}, err + } + } return fromCompactResult(result), nil } diff --git a/internal/runtime/input_prepare.go b/internal/runtime/input_prepare.go index 5177c2ad..24dbe345 100644 --- a/internal/runtime/input_prepare.go +++ b/internal/runtime/input_prepare.go @@ -169,6 +169,7 @@ func (p sessionInputPreparer) Prepare( RunID: strings.TrimSpace(input.RunID), Parts: prepared.Parts, Workdir: strings.TrimSpace(prepared.Workdir), + Mode: strings.TrimSpace(input.Mode), }, SavedAssets: append([]agentsession.AssetMeta(nil), prepared.SavedAssets...), }, nil diff --git a/internal/runtime/permission.go b/internal/runtime/permission.go index 071ea51c..c0870368 100644 --- a/internal/runtime/permission.go +++ b/internal/runtime/permission.go @@ -87,6 +87,7 @@ func (s *Service) executeToolCallWithPermission(ctx context.Context, input permi Name: input.Call.Name, Arguments: []byte(input.Call.Arguments), Workdir: input.Workdir, + ReadOnly: input.State != nil && isReadOnlyPlanningStage(resolvePlanningStageForState(input.State)), SessionID: input.SessionID, TaskID: input.TaskID, AgentID: input.AgentID, diff --git a/internal/runtime/plan_approval.go b/internal/runtime/plan_approval.go new file mode 100644 index 00000000..b1da53f2 --- /dev/null +++ b/internal/runtime/plan_approval.go @@ -0,0 +1,31 @@ +package runtime + +import ( + "context" + "errors" + "strings" + "time" +) + +// ApproveCurrentPlan 显式批准当前完整计划 revision,并安排下一轮做一次完整计划对齐。 +func (s *Service) ApproveCurrentPlan(ctx context.Context, input ApproveCurrentPlanInput) error { + if err := ctx.Err(); err != nil { + return err + } + if s == nil { + return errors.New("runtime: service is nil") + } + sessionID := strings.TrimSpace(input.SessionID) + releaseLock := s.bindSessionLock(sessionID) + defer releaseLock() + + session, err := s.sessionStore.LoadSession(ctx, sessionID) + if err != nil { + return err + } + if err := approveCurrentPlan(&session, input.PlanID, input.Revision); err != nil { + return err + } + session.UpdatedAt = time.Now() + return s.sessionStore.UpdateSessionState(ctx, sessionStateInputFromSession(session)) +} diff --git a/internal/runtime/planning.go b/internal/runtime/planning.go new file mode 100644 index 00000000..a8008755 --- /dev/null +++ b/internal/runtime/planning.go @@ -0,0 +1,339 @@ +package runtime + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "neo-code/internal/partsrender" + providertypes "neo-code/internal/provider/types" + "neo-code/internal/runtime/controlplane" + agentsession "neo-code/internal/session" +) + +const ( + planStagePlan = "plan" + planStageBuildExecute = "build_execute" +) + +type summaryCandidate struct { + Goal string `json:"goal"` + KeySteps []string `json:"key_steps"` + Constraints []string `json:"constraints"` + Verify []string `json:"verify"` + ActiveTodoIDs []string `json:"active_todo_ids"` +} + +type planTurnOutput struct { + PlanSpec agentsession.PlanSpec `json:"plan_spec"` + SummaryCandidate summaryCandidate `json:"summary_candidate"` +} + +type taskCompletionSignal struct { + Completed bool `json:"completed"` +} + +type completionTurnOutput struct { + TaskCompletion taskCompletionSignal `json:"task_completion"` +} + +// resolvePlanningStage 根据当前会话模式映射出活动的 planning stage。 +func resolvePlanningStage(session agentsession.Session) string { + if agentsession.NormalizeAgentMode(session.AgentMode) == agentsession.AgentModePlan { + return planStagePlan + } + return planStageBuildExecute +} + +// resolvePlanningStageForState 在需要时启用 plan/build 上下文链路。 +func resolvePlanningStageForState(state *runState) string { + if state == nil || !state.planningEnabled { + return "" + } + return resolvePlanningStage(state.session) +} + +// applyRequestedAgentMode 将显式请求的 mode 回写到会话状态中。 +func applyRequestedAgentMode(session *agentsession.Session, requested string) bool { + if session == nil { + return false + } + trimmed := strings.TrimSpace(requested) + if trimmed == "" { + if session.AgentMode == "" { + session.AgentMode = agentsession.AgentModeBuild + return true + } + session.AgentMode = agentsession.NormalizeAgentMode(session.AgentMode) + return false + } + next := agentsession.NormalizeAgentMode(agentsession.AgentMode(trimmed)) + if session.AgentMode == next { + return false + } + session.AgentMode = next + return true +} + +// isReadOnlyPlanningStage 标记只允许只读工具的 planning stage,目前仅 plan 模式受限。 +func isReadOnlyPlanningStage(stage string) bool { + return stage == planStagePlan +} + +// baseRunStateForPlanningStage 为 planning stage 选择初始运行态,确保规划阶段仍落在 RunStatePlan。 +func baseRunStateForPlanningStage(stage string) controlplane.RunState { + return controlplane.RunStatePlan +} + +// planningNeedsFullPlan 判断当前回合是否需要注入完整计划正文。 +func planningNeedsFullPlan(state *runState) bool { + if state == nil || state.session.CurrentPlan == nil { + return false + } + if state.session.CurrentPlan.Status == agentsession.PlanStatusCompleted && + !state.session.PlanCompletionPendingFullReview { + return false + } + if !summaryViewUsable(state.session.CurrentPlan.Summary) { + return true + } + if state.session.CurrentPlan.Revision > state.session.LastFullPlanRevision { + return true + } + return state.session.PlanApprovalPendingFullAlign || + state.session.PlanCompletionPendingFullReview || + state.session.PlanContextDirty || + state.session.PlanRestorePendingAlign +} + +func summaryViewUsable(summary agentsession.SummaryView) bool { + return strings.TrimSpace(summary.Goal) != "" && + len(summary.KeySteps) > 0 && + len(summary.Verify) > 0 +} + +func normalizeSummaryCandidate(candidate summaryCandidate) agentsession.SummaryView { + return agentsession.SummaryView{ + Goal: strings.TrimSpace(candidate.Goal), + KeySteps: append([]string(nil), candidate.KeySteps...), + Constraints: append([]string(nil), candidate.Constraints...), + Verify: append([]string(nil), candidate.Verify...), + ActiveTodoIDs: append([]string(nil), candidate.ActiveTodoIDs...), + } +} + +// maybeParsePlanTurnOutput 仅在 assistant 实际输出 planning JSON 时解析计划载荷。 +func maybeParsePlanTurnOutput(message providertypes.Message) (planTurnOutput, bool, error) { + text := strings.TrimSpace(partsrender.RenderDisplayParts(message.Parts)) + if text == "" { + return planTurnOutput{}, false, nil + } + jsonText, ok, err := extractPlanningJSONObjectIfPresent(text) + if err != nil { + return planTurnOutput{}, false, err + } + if !ok { + return planTurnOutput{}, false, nil + } + var output planTurnOutput + if err := json.Unmarshal([]byte(jsonText), &output); err != nil { + return planTurnOutput{}, false, fmt.Errorf("runtime: decode planning json: %w", err) + } + spec, err := agentsession.NormalizePlanSpec(output.PlanSpec) + if err != nil { + return planTurnOutput{}, false, err + } + output.PlanSpec = spec + return output, true, nil +} + +// maybeParseCompletionTurnOutput 仅在 assistant 明确输出结构化完成信号时返回完成标记。 +func maybeParseCompletionTurnOutput(message providertypes.Message) (bool, error) { + text := strings.TrimSpace(partsrender.RenderDisplayParts(message.Parts)) + if text == "" || !strings.Contains(text, `"task_completion"`) { + return false, nil + } + jsonText, ok, err := extractPlanningJSONObjectIfPresent(text) + if err != nil { + return false, err + } + if !ok { + return false, nil + } + var output completionTurnOutput + if err := json.Unmarshal([]byte(jsonText), &output); err != nil { + return false, fmt.Errorf("runtime: decode completion json: %w", err) + } + return output.TaskCompletion.Completed, nil +} + +// extractPlanningJSONObjectIfPresent 在文本中提取首个配平的 JSON 对象。 +func extractPlanningJSONObjectIfPresent(text string) (string, bool, error) { + start := strings.IndexByte(text, '{') + if start < 0 { + return "", false, nil + } + for { + candidate, err := extractJSONObjectCandidate(text, start) + if err == nil { + return candidate, true, nil + } + next := strings.IndexByte(text[start+1:], '{') + if next < 0 { + break + } + start += next + 1 + } + return "", false, fmt.Errorf("runtime: planning response does not contain a valid JSON object") +} + +func buildPlanArtifact(current *agentsession.PlanArtifact, output planTurnOutput) (*agentsession.PlanArtifact, error) { + now := time.Now().UTC() + revision := 1 + planID := agentsession.NewID("plan") + createdAt := now + if current != nil { + planID = strings.TrimSpace(current.ID) + if planID == "" { + planID = agentsession.NewID("plan") + } + revision = current.Revision + 1 + if revision <= 0 { + revision = 1 + } + if !current.CreatedAt.IsZero() { + createdAt = current.CreatedAt.UTC() + } + } + + summary := agentsession.NormalizeSummaryView(normalizeSummaryCandidate(output.SummaryCandidate), output.PlanSpec) + plan, err := agentsession.NormalizePlanArtifact(&agentsession.PlanArtifact{ + ID: planID, + Revision: revision, + Status: agentsession.PlanStatusDraft, + Spec: output.PlanSpec, + Summary: summary, + CreatedAt: createdAt, + UpdatedAt: now, + }) + if err != nil { + return nil, err + } + return plan, nil +} + +// applyCurrentPlanRevision 用新 revision 替换当前计划,并清理旧 revision 遗留的对齐状态。 +func applyCurrentPlanRevision(session *agentsession.Session, plan *agentsession.PlanArtifact) bool { + if session == nil || plan == nil { + return false + } + session.CurrentPlan = plan + session.PlanApprovalPendingFullAlign = false + session.PlanCompletionPendingFullReview = false + session.PlanContextDirty = false + session.PlanRestorePendingAlign = false + return true +} + +// markCurrentPlanRestorePending 为已加载的活动计划设置一次恢复后全文对齐标记。 +func markCurrentPlanRestorePending(session *agentsession.Session) bool { + if session == nil || session.CurrentPlan == nil { + return false + } + if session.CurrentPlan.Status == agentsession.PlanStatusCompleted && + !session.PlanCompletionPendingFullReview { + return false + } + if session.PlanRestorePendingAlign { + return false + } + session.PlanRestorePendingAlign = true + return true +} + +// markCurrentPlanContextDirty 在 compact 成功后标记当前计划需要重新做一次全文对齐。 +func markCurrentPlanContextDirty(session *agentsession.Session) bool { + if session == nil || session.CurrentPlan == nil { + return false + } + if session.CurrentPlan.Status == agentsession.PlanStatusCompleted && + !session.PlanCompletionPendingFullReview { + return false + } + if session.PlanContextDirty { + return false + } + session.PlanContextDirty = true + return true +} + +// rememberFullPlanRevision 记录最近一次已完整注入的计划 revision,并清理一次性对齐标记。 +func rememberFullPlanRevision(session *agentsession.Session) bool { + if session == nil || session.CurrentPlan == nil { + return false + } + changed := false + if session.CurrentPlan.Revision > session.LastFullPlanRevision { + session.LastFullPlanRevision = session.CurrentPlan.Revision + changed = true + } + if session.PlanApprovalPendingFullAlign { + session.PlanApprovalPendingFullAlign = false + changed = true + } + if session.PlanCompletionPendingFullReview { + session.PlanCompletionPendingFullReview = false + changed = true + } + if session.PlanContextDirty { + session.PlanContextDirty = false + changed = true + } + if session.PlanRestorePendingAlign { + session.PlanRestorePendingAlign = false + changed = true + } + return changed +} + +// approveCurrentPlan 显式批准当前 draft revision,并安排下一轮做一次完整计划对齐。 +func approveCurrentPlan(session *agentsession.Session, planID string, revision int) error { + if session == nil || session.CurrentPlan == nil { + return fmt.Errorf("runtime: current plan does not exist") + } + if strings.TrimSpace(planID) == "" || strings.TrimSpace(session.CurrentPlan.ID) != strings.TrimSpace(planID) { + return fmt.Errorf("runtime: current plan id does not match") + } + if revision <= 0 || session.CurrentPlan.Revision != revision { + return fmt.Errorf("runtime: current plan revision does not match") + } + if session.CurrentPlan.Status != agentsession.PlanStatusDraft { + return fmt.Errorf("runtime: current plan status %q cannot be approved", session.CurrentPlan.Status) + } + session.CurrentPlan = session.CurrentPlan.Clone() + session.CurrentPlan.Status = agentsession.PlanStatusApproved + session.CurrentPlan.UpdatedAt = time.Now().UTC() + session.PlanApprovalPendingFullAlign = true + session.PlanCompletionPendingFullReview = false + return nil +} + +// markCurrentPlanCompleted 在结构化完成信号和验收同时通过后推进计划完成态。 +func markCurrentPlanCompleted(session *agentsession.Session, completionSignaled bool) bool { + if session == nil || session.CurrentPlan == nil { + return false + } + if !completionSignaled { + return false + } + if session.CurrentPlan.Status == agentsession.PlanStatusCompleted { + return false + } + session.CurrentPlan = session.CurrentPlan.Clone() + session.CurrentPlan.Status = agentsession.PlanStatusCompleted + session.CurrentPlan.UpdatedAt = time.Now().UTC() + session.PlanApprovalPendingFullAlign = false + session.PlanCompletionPendingFullReview = true + return true +} diff --git a/internal/runtime/planning_test.go b/internal/runtime/planning_test.go new file mode 100644 index 00000000..1596bf6d --- /dev/null +++ b/internal/runtime/planning_test.go @@ -0,0 +1,368 @@ +package runtime + +import ( + "strings" + "testing" + "time" + + providertypes "neo-code/internal/provider/types" + agentsession "neo-code/internal/session" +) + +func TestResolvePlanningStageForStateRespectsPlanningEnabled(t *testing.T) { + t.Parallel() + + session := newRuntimeSession("session-plan-stage") + state := newRunState("run-plan-stage", session) + if got := resolvePlanningStageForState(&state); got != "" { + t.Fatalf("resolvePlanningStageForState() = %q, want empty when planning disabled", got) + } + + state.planningEnabled = true + if got := resolvePlanningStageForState(&state); got != planStageBuildExecute { + t.Fatalf("resolvePlanningStageForState() = %q, want %q", got, planStageBuildExecute) + } + + state.session.AgentMode = agentsession.AgentModePlan + if got := resolvePlanningStageForState(&state); got != planStagePlan { + t.Fatalf("resolvePlanningStageForState() = %q, want %q", got, planStagePlan) + } +} + +func TestApplyRequestedAgentMode(t *testing.T) { + t.Parallel() + + session := agentsession.New("mode switch") + session.AgentMode = "" + + if changed := applyRequestedAgentMode(&session, ""); !changed { + t.Fatalf("expected empty request to initialize default mode") + } + if session.AgentMode != agentsession.AgentModeBuild { + t.Fatalf("AgentMode = %q, want build", session.AgentMode) + } + if changed := applyRequestedAgentMode(&session, "plan"); !changed { + t.Fatalf("expected explicit mode switch to report changed") + } + if session.AgentMode != agentsession.AgentModePlan { + t.Fatalf("AgentMode = %q, want plan", session.AgentMode) + } + if changed := applyRequestedAgentMode(&session, "PLAN"); changed { + t.Fatalf("expected normalized duplicate mode switch to report unchanged") + } +} + +func TestMaybeParsePlanTurnOutput(t *testing.T) { + t.Parallel() + + message := providertypes.Message{ + Role: providertypes.RoleAssistant, + Parts: []providertypes.ContentPart{ + providertypes.NewTextPart(`{ + "plan_spec": { + "goal": "实现 plan/build 模式", + "steps": ["扩展 session", "过滤工具"], + "verify": ["build 保留 verify"], + "todos": [{"id":"todo-1","content":"扩展 session","status":"pending"}] + }, + "summary_candidate": { + "goal": "实现 plan/build 模式", + "key_steps": ["扩展 session"], + "constraints": [], + "verify": ["build 保留 verify"], + "active_todo_ids": ["todo-1"] + } +}`), + }, + } + + output, ok, err := maybeParsePlanTurnOutput(message) + if err != nil { + t.Fatalf("maybeParsePlanTurnOutput() error = %v", err) + } + if !ok { + t.Fatalf("expected plan JSON to be detected") + } + if output.PlanSpec.Goal != "实现 plan/build 模式" { + t.Fatalf("PlanSpec.Goal = %q", output.PlanSpec.Goal) + } + if len(output.PlanSpec.Todos) != 1 || output.PlanSpec.Todos[0].ID != "todo-1" { + t.Fatalf("PlanSpec.Todos = %+v", output.PlanSpec.Todos) + } +} + +func TestMaybeParsePlanTurnOutputAllowsNaturalLanguage(t *testing.T) { + t.Parallel() + + output, ok, err := maybeParsePlanTurnOutput(providertypes.Message{ + Role: providertypes.RoleAssistant, + Parts: []providertypes.ContentPart{providertypes.NewTextPart("Here is an analysis without a structured plan.")}, + }) + if err != nil { + t.Fatalf("maybeParsePlanTurnOutput() error = %v", err) + } + if ok { + t.Fatalf("expected natural-language response not to be treated as a plan: %+v", output) + } +} + +func TestMaybeParseCompletionTurnOutput(t *testing.T) { + t.Parallel() + + completed, err := maybeParseCompletionTurnOutput(providertypes.Message{ + Role: providertypes.RoleAssistant, + Parts: []providertypes.ContentPart{ + providertypes.NewTextPart("{\"task_completion\":{\"completed\":true}}\n任务已经完成。"), + }, + }) + if err != nil { + t.Fatalf("maybeParseCompletionTurnOutput() error = %v", err) + } + if !completed { + t.Fatal("expected structured completion signal to be detected") + } + + completed, err = maybeParseCompletionTurnOutput(providertypes.Message{ + Role: providertypes.RoleAssistant, + Parts: []providertypes.ContentPart{providertypes.NewTextPart("plain answer only")}, + }) + if err != nil { + t.Fatalf("maybeParseCompletionTurnOutput() natural language error = %v", err) + } + if completed { + t.Fatal("expected natural language without completion JSON not to signal completion") + } +} + +func TestExtractPlanningJSONObjectIfPresent(t *testing.T) { + t.Parallel() + + text := "preface\n{\"plan_spec\":{\"goal\":\"x\",\"steps\":[\"s\"],\"verify\":[\"v\"]},\"summary_candidate\":{\"goal\":\"x\",\"key_steps\":[\"s\"],\"constraints\":[],\"verify\":[\"v\"],\"active_todo_ids\":[]}}\ntrailing" + got, ok, err := extractPlanningJSONObjectIfPresent(text) + if err != nil { + t.Fatalf("extractPlanningJSONObjectIfPresent() error = %v", err) + } + if !ok { + t.Fatalf("expected JSON object to be detected") + } + if !strings.HasPrefix(got, "{") || !strings.Contains(got, "\"plan_spec\"") { + t.Fatalf("extractPlanningJSONObjectIfPresent() = %q", got) + } +} + +func TestExtractPlanningJSONObjectIfPresentWithoutJSON(t *testing.T) { + t.Parallel() + + got, ok, err := extractPlanningJSONObjectIfPresent("plain text only") + if err != nil { + t.Fatalf("extractPlanningJSONObjectIfPresent() error = %v", err) + } + if ok || got != "" { + t.Fatalf("expected no JSON result, got ok=%v text=%q", ok, got) + } +} + +func TestBuildPlanArtifact(t *testing.T) { + t.Parallel() + + current := &agentsession.PlanArtifact{ + ID: "plan-1", + Revision: 2, + Status: agentsession.PlanStatusDraft, + CreatedAt: time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC), + Spec: agentsession.PlanSpec{ + Goal: "旧计划", + Steps: []string{"旧步骤"}, + Verify: []string{"旧验证"}, + }, + } + output := planTurnOutput{ + PlanSpec: agentsession.PlanSpec{ + Goal: "新计划", + Steps: []string{"步骤一"}, + Verify: []string{"验证一"}, + Todos: []agentsession.TodoItem{ + {ID: "todo-1", Content: "待办", Status: agentsession.TodoStatusPending}, + }, + }, + SummaryCandidate: summaryCandidate{ + Goal: "新计划", + KeySteps: []string{"步骤一"}, + Verify: []string{"验证一"}, + ActiveTodoIDs: []string{"todo-1"}, + }, + } + + plan, err := buildPlanArtifact(current, output) + if err != nil { + t.Fatalf("buildPlanArtifact() error = %v", err) + } + if plan.ID != "plan-1" { + t.Fatalf("ID = %q, want %q", plan.ID, "plan-1") + } + if plan.Revision != 3 { + t.Fatalf("Revision = %d, want 3", plan.Revision) + } + if plan.Status != agentsession.PlanStatusDraft { + t.Fatalf("Status = %q, want %q", plan.Status, agentsession.PlanStatusDraft) + } + if !plan.CreatedAt.Equal(current.CreatedAt) { + t.Fatalf("CreatedAt = %v, want %v", plan.CreatedAt, current.CreatedAt) + } + if plan.Summary.Goal != "新计划" { + t.Fatalf("Summary.Goal = %q", plan.Summary.Goal) + } +} + +func TestMarkCurrentPlanCompleted(t *testing.T) { + t.Parallel() + + session := agentsession.New("plan state") + session.CurrentPlan = &agentsession.PlanArtifact{ + ID: "plan-1", + Revision: 1, + Status: agentsession.PlanStatusDraft, + Spec: agentsession.PlanSpec{ + Goal: "执行当前计划", + Steps: []string{"步骤一"}, + Verify: []string{"验证一"}, + }, + } + if !markCurrentPlanCompleted(&session, true) { + t.Fatalf("expected draft plan with completion signal to transition to completed") + } + if session.CurrentPlan.Status != agentsession.PlanStatusCompleted { + t.Fatalf("Status = %q, want completed", session.CurrentPlan.Status) + } + if !session.PlanCompletionPendingFullReview { + t.Fatal("expected completed plan to request one final full-plan review turn") + } + if markCurrentPlanCompleted(&session, true) { + t.Fatalf("expected completed plan not to transition again") + } + + session.CurrentPlan = &agentsession.PlanArtifact{ + ID: "plan-2", + Revision: 1, + Status: agentsession.PlanStatusDraft, + Spec: agentsession.PlanSpec{ + Goal: "草案计划", + Steps: []string{"步骤一"}, + Verify: []string{"验证一"}, + }, + } + if markCurrentPlanCompleted(&session, false) { + t.Fatalf("expected missing completion signal to keep plan unfinished") + } +} + +func TestPlanningNeedsFullPlan(t *testing.T) { + t.Parallel() + + state := newRunState("run-full-plan-check", agentsession.New("plan")) + state.session.CurrentPlan = &agentsession.PlanArtifact{ + ID: "plan-1", + Revision: 2, + Status: agentsession.PlanStatusApproved, + Spec: agentsession.PlanSpec{ + Goal: "Use full plan when alignment is pending", + Steps: []string{"align plan"}, + Verify: []string{"go test ./internal/runtime"}, + Todos: []agentsession.TodoItem{ + {ID: "todo-1", Content: "align plan", Status: agentsession.TodoStatusPending}, + }, + }, + Summary: agentsession.SummaryView{ + Goal: "Use full plan when alignment is pending", + KeySteps: []string{"align plan"}, + Verify: []string{"go test ./internal/runtime"}, + ActiveTodoIDs: []string{"todo-1"}, + }, + } + if !planningNeedsFullPlan(&state) { + t.Fatalf("expected newer revision to require full plan") + } + + state.session.LastFullPlanRevision = 2 + if planningNeedsFullPlan(&state) { + t.Fatalf("expected aligned revision to use summary view only") + } + + state.session.PlanApprovalPendingFullAlign = true + if !planningNeedsFullPlan(&state) { + t.Fatalf("expected approval alignment flag to require full plan") + } + state.session.PlanApprovalPendingFullAlign = false + + state.session.PlanCompletionPendingFullReview = true + state.session.CurrentPlan.Status = agentsession.PlanStatusCompleted + if !planningNeedsFullPlan(&state) { + t.Fatalf("expected completion review flag to require full plan even for completed plan") + } + state.session.PlanCompletionPendingFullReview = false + if planningNeedsFullPlan(&state) { + t.Fatalf("expected completed plan without review flag to stay summary-only") + } + + state.session.CurrentPlan.Status = agentsession.PlanStatusApproved + state.session.CurrentPlan.Summary = agentsession.SummaryView{} + if !planningNeedsFullPlan(&state) { + t.Fatalf("expected unusable summary view to require full plan") + } +} + +func TestApproveCurrentPlan(t *testing.T) { + t.Parallel() + + session := agentsession.New("approve plan") + session.CurrentPlan = &agentsession.PlanArtifact{ + ID: "plan-approve", + Revision: 3, + Status: agentsession.PlanStatusDraft, + Spec: agentsession.PlanSpec{ + Goal: "批准当前计划", + Steps: []string{"步骤一"}, + Verify: []string{"验证一"}, + }, + } + if err := approveCurrentPlan(&session, "plan-approve", 3); err != nil { + t.Fatalf("approveCurrentPlan() error = %v", err) + } + if session.CurrentPlan.Status != agentsession.PlanStatusApproved { + t.Fatalf("Status = %q, want approved", session.CurrentPlan.Status) + } + if !session.PlanApprovalPendingFullAlign { + t.Fatal("expected approval to schedule a full-plan alignment") + } +} + +func TestRememberFullPlanRevisionClearsAlignmentFlags(t *testing.T) { + t.Parallel() + + session := agentsession.New("remember full plan") + session.CurrentPlan = &agentsession.PlanArtifact{ + ID: "plan-align", + Revision: 2, + Status: agentsession.PlanStatusApproved, + Spec: agentsession.PlanSpec{ + Goal: "完成全文对齐", + Steps: []string{"步骤一"}, + Verify: []string{"验证一"}, + }, + } + session.PlanApprovalPendingFullAlign = true + session.PlanCompletionPendingFullReview = true + session.PlanContextDirty = true + session.PlanRestorePendingAlign = true + + if !rememberFullPlanRevision(&session) { + t.Fatal("expected full-plan alignment to update revision state") + } + if session.LastFullPlanRevision != 2 { + t.Fatalf("LastFullPlanRevision = %d, want 2", session.LastFullPlanRevision) + } + if session.PlanApprovalPendingFullAlign || session.PlanCompletionPendingFullReview || + session.PlanContextDirty || session.PlanRestorePendingAlign { + t.Fatalf("expected one-shot alignment flags to be cleared, got %+v", session) + } +} diff --git a/internal/runtime/run.go b/internal/runtime/run.go index edd4aae1..9de226a3 100644 --- a/internal/runtime/run.go +++ b/internal/runtime/run.go @@ -113,12 +113,21 @@ func (s *Service) Run(ctx context.Context, input UserInput) (err error) { if err != nil { return s.handleRunError(err) } + if applyRequestedAgentMode(&session, input.Mode) { + session.UpdatedAt = time.Now() + if err := s.sessionStore.UpdateSessionState(ctx, sessionStateInputFromSession(session)); err != nil { + return s.handleRunError(err) + } + } if sessionID == "" { releaseSessionLock = s.bindSessionLock(session.ID) } state := newRunState(input.RunID, session) + state.planningEnabled = strings.TrimSpace(input.Mode) != "" || + session.CurrentPlan != nil || + agentsession.NormalizeAgentMode(session.AgentMode) == agentsession.AgentModePlan state.taskID = strings.TrimSpace(input.TaskID) state.agentID = strings.TrimSpace(input.AgentID) if input.CapabilityToken != nil { @@ -167,7 +176,8 @@ func (s *Service) Run(ctx context.Context, input UserInput) (err error) { state.turn = turn state.compactCount = 0 state.nextAttemptSeq = 1 - if err := s.setBaseRunState(ctx, &state, controlplane.RunStatePlan); err != nil { + stage := resolvePlanningStageForState(&state) + if err := s.setBaseRunState(ctx, &state, baseRunStateForPlanningStage(stage)); err != nil { return s.handleRunError(err) } @@ -263,6 +273,12 @@ func (s *Service) Run(ctx context.Context, input UserInput) (err error) { } s.emitLedgerReconciled(ctx, &state, turnOutput.usageObservation, reconciled) s.emitTokenUsage(ctx, &state, reconciled) + if snapshot.InjectFullPlan && rememberFullPlanRevision(&state.session) { + state.touchSession() + if err := s.sessionStore.UpdateSessionState(ctx, sessionStateInputFromSession(state.session)); err != nil { + return s.handleRunError(err) + } + } state.mu.Lock() state.completion = collectCompletionState( @@ -278,6 +294,39 @@ func (s *Service) Run(ctx context.Context, input UserInput) (err error) { state.mu.Unlock() if !hasToolCalls { + stage = resolvePlanningStageForState(&state) + if stage == planStagePlan { + planOutput, hasPlanOutput, err := maybeParsePlanTurnOutput(turnOutput.assistant) + if err != nil { + return s.handleRunError(err) + } + if hasPlanOutput { + nextPlan, err := buildPlanArtifact(state.session.CurrentPlan, planOutput) + if err != nil { + return s.handleRunError(err) + } + applyCurrentPlanRevision(&state.session, nextPlan) + state.touchSession() + if err := s.sessionStore.UpdateSessionState(ctx, sessionStateInputFromSession(state.session)); err != nil { + return s.handleRunError(err) + } + planMessage := providertypes.Message{ + Role: providertypes.RoleAssistant, + Parts: []providertypes.ContentPart{ + providertypes.NewTextPart(strings.TrimSpace(agentsession.RenderPlanContent(nextPlan.Spec))), + }, + } + if err := s.appendAssistantMessageOnlyAndSave(ctx, &state, planMessage); err != nil { + return s.handleRunError(err) + } + s.emitRunScoped(ctx, EventAgentDone, &state, planMessage) + return nil + } + } + completionSignaled, err := maybeParseCompletionTurnOutput(turnOutput.assistant) + if err != nil { + return s.handleRunError(err) + } if err := s.setBaseRunState(ctx, &state, controlplane.RunStateVerify); err != nil { return s.handleRunError(err) } @@ -337,6 +386,12 @@ func (s *Service) Run(ctx context.Context, input UserInput) (err error) { switch acceptanceDecision.Status { case acceptance.AcceptanceAccepted: + if markCurrentPlanCompleted(&state.session, completionSignaled) { + state.touchSession() + if err := s.sessionStore.UpdateSessionState(ctx, sessionStateInputFromSession(state.session)); err != nil { + return s.handleRunError(err) + } + } if err := s.appendAssistantMessageOnlyAndSave(ctx, &state, turnOutput.assistant); err != nil { return s.handleRunError(err) } @@ -398,8 +453,12 @@ func (s *Service) Run(ctx context.Context, input UserInput) (err error) { state.completion = applyToolExecutionCompletion(state.completion, summary) afterTask := state.session.TaskState.Clone() afterTodos := cloneTodosForPersistence(state.session.Todos) + progressRunState := controlplane.RunStateExecute + if resolvePlanningStageForState(&state) == planStagePlan { + progressRunState = controlplane.RunStatePlan + } progressInput := collectProgressInput( - controlplane.RunStateExecute, + progressRunState, beforeTask, afterTask, beforeTodos, @@ -439,11 +498,18 @@ func (s *Service) prepareTurnBudgetSnapshot(ctx context.Context, state *runState if err != nil { return TurnBudgetSnapshot{}, false, err } + stage := resolvePlanningStageForState(state) + readOnly := isReadOnlyPlanningStage(stage) + injectFullPlan := planningNeedsFullPlan(state) builtContext, err := s.contextBuilder.Build(ctx, agentcontext.BuildInput{ Messages: state.session.Messages, TaskState: state.session.TaskState, Todos: cloneTodosForPersistence(state.session.Todos), + AgentMode: state.session.AgentMode, + PlanStage: stage, + CurrentPlan: state.session.CurrentPlan.Clone(), + InjectFullPlan: injectFullPlan, ActiveSkills: activeSkills, RepositorySummary: repositorySummary, Repository: repositoryContext, @@ -470,6 +536,8 @@ func (s *Service) prepareTurnBudgetSnapshot(ctx context.Context, state *runState toolSpecs, err := s.toolManager.ListAvailableSpecs(ctx, tools.SpecListInput{ SessionID: state.session.ID, + Mode: string(agentsession.NormalizeAgentMode(state.session.AgentMode)), + ReadOnly: readOnly, }) if err != nil { return TurnBudgetSnapshot{}, false, err @@ -517,6 +585,7 @@ func (s *Service) prepareTurnBudgetSnapshot(ctx context.Context, state *runState state.compactCount, limit, repeatLimit, + injectFullPlan, request, ), false, nil } @@ -615,6 +684,12 @@ func (s *Service) applyCompactForState( } state.session = session if result.Applied { + if markCurrentPlanContextDirty(&state.session) { + state.session.UpdatedAt = time.Now() + if err := s.sessionStore.UpdateSessionState(ctx, sessionStateInputFromSession(state.session)); err != nil { + return err + } + } if mode == contextcompact.ModeProactive || mode == contextcompact.ModeReactive { state.compactCount++ } @@ -778,7 +853,7 @@ func hasUserInputParts(parts []providertypes.ContentPart) bool { return false } -// sessionTitleFromParts extracts a sensible title from the input parts. +// sessionTitleFromParts 从输入 parts 中提取一个合适的会话标题。 func sessionTitleFromParts(parts []providertypes.ContentPart) string { for _, part := range parts { if part.Kind == providertypes.ContentPartText && strings.TrimSpace(part.Text) != "" { diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 8b8ba8d1..8199eb6f 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -48,12 +48,18 @@ type Runtime interface { ListAvailableSkills(ctx context.Context, sessionID string) ([]AvailableSkillState, error) } +// PlanApprover 定义显式批准当前完整计划 revision 的可选 runtime 能力。 +type PlanApprover interface { + ApproveCurrentPlan(ctx context.Context, input ApproveCurrentPlanInput) error +} + // UserInput 描述一次用户输入请求的最小运行参数。 type UserInput struct { SessionID string RunID string Parts []providertypes.ContentPart Workdir string + Mode string TaskID string AgentID string CapabilityToken *security.CapabilityToken @@ -70,6 +76,7 @@ type PrepareInput struct { SessionID string RunID string Workdir string + Mode string Text string Images []UserImageInput } @@ -89,6 +96,13 @@ type PreparedInputResult struct { SavedAssets []agentsession.AssetMeta } +// ApproveCurrentPlanInput 描述一次显式批准当前完整计划 revision 的最小输入。 +type ApproveCurrentPlanInput struct { + SessionID string + PlanID string + Revision int +} + // UserInputPreparer 定义 runtime 输入归一化能力:会话绑定、附件持久化与 parts 组装。 type UserInputPreparer interface { Prepare(ctx context.Context, input PrepareInput, defaultWorkdir string) (PreparedInputResult, error) diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 28f4c377..65e90ef1 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -93,6 +93,13 @@ func (s *memoryStore) CreateSession(ctx context.Context, input agentsession.Crea session.TokenInputTotal = head.TokenInputTotal session.TokenOutputTotal = head.TokenOutputTotal session.HasUnknownUsage = head.HasUnknownUsage + session.AgentMode = agentsession.NormalizeAgentMode(head.AgentMode) + session.CurrentPlan = head.CurrentPlan.Clone() + session.LastFullPlanRevision = head.LastFullPlanRevision + session.PlanApprovalPendingFullAlign = head.PlanApprovalPendingFullAlign + session.PlanCompletionPendingFullReview = head.PlanCompletionPendingFullReview + session.PlanContextDirty = head.PlanContextDirty + session.PlanRestorePendingAlign = head.PlanRestorePendingAlign session.Messages = []providertypes.Message{} s.mu.Lock() @@ -214,6 +221,13 @@ func (s *memoryStore) UpdateSessionState(ctx context.Context, input agentsession session.TokenInputTotal = head.TokenInputTotal session.TokenOutputTotal = head.TokenOutputTotal session.HasUnknownUsage = head.HasUnknownUsage + session.AgentMode = agentsession.NormalizeAgentMode(head.AgentMode) + session.CurrentPlan = head.CurrentPlan.Clone() + session.LastFullPlanRevision = head.LastFullPlanRevision + session.PlanApprovalPendingFullAlign = head.PlanApprovalPendingFullAlign + session.PlanCompletionPendingFullReview = head.PlanCompletionPendingFullReview + session.PlanContextDirty = head.PlanContextDirty + session.PlanRestorePendingAlign = head.PlanRestorePendingAlign s.saves++ s.sessions[input.SessionID] = cloneSession(session) return nil @@ -245,6 +259,13 @@ func (s *memoryStore) ReplaceTranscript(ctx context.Context, input agentsession. session.TokenInputTotal = head.TokenInputTotal session.TokenOutputTotal = head.TokenOutputTotal session.HasUnknownUsage = head.HasUnknownUsage + session.AgentMode = agentsession.NormalizeAgentMode(head.AgentMode) + session.CurrentPlan = head.CurrentPlan.Clone() + session.LastFullPlanRevision = head.LastFullPlanRevision + session.PlanApprovalPendingFullAlign = head.PlanApprovalPendingFullAlign + session.PlanCompletionPendingFullReview = head.PlanCompletionPendingFullReview + session.PlanContextDirty = head.PlanContextDirty + session.PlanRestorePendingAlign = head.PlanRestorePendingAlign s.saves++ s.sessions[input.SessionID] = cloneSession(session) return nil @@ -367,6 +388,9 @@ func (s *blockingLoadStore) CreateSession(ctx context.Context, input agentsessio session.TokenInputTotal = head.TokenInputTotal session.TokenOutputTotal = head.TokenOutputTotal session.HasUnknownUsage = head.HasUnknownUsage + session.AgentMode = agentsession.NormalizeAgentMode(head.AgentMode) + session.CurrentPlan = head.CurrentPlan.Clone() + session.LastFullPlanRevision = head.LastFullPlanRevision s.mu.Lock() s.sessions[session.ID] = cloneSession(session) s.mu.Unlock() @@ -469,6 +493,9 @@ func (s *blockingLoadStore) UpdateSessionState(ctx context.Context, input agents session.TokenInputTotal = head.TokenInputTotal session.TokenOutputTotal = head.TokenOutputTotal session.HasUnknownUsage = head.HasUnknownUsage + session.AgentMode = agentsession.NormalizeAgentMode(head.AgentMode) + session.CurrentPlan = head.CurrentPlan.Clone() + session.LastFullPlanRevision = head.LastFullPlanRevision s.sessions[input.SessionID] = cloneSession(session) return nil } @@ -498,6 +525,9 @@ func (s *blockingLoadStore) ReplaceTranscript(ctx context.Context, input agentse session.TokenInputTotal = head.TokenInputTotal session.TokenOutputTotal = head.TokenOutputTotal session.HasUnknownUsage = head.HasUnknownUsage + session.AgentMode = agentsession.NormalizeAgentMode(head.AgentMode) + session.CurrentPlan = head.CurrentPlan.Clone() + session.LastFullPlanRevision = head.LastFullPlanRevision s.sessions[input.SessionID] = cloneSession(session) return nil } @@ -3449,6 +3479,862 @@ func TestServiceRunUsesInputWorkdirForNewSession(t *testing.T) { } } +func TestServiceRunPlanModePersistsDraftPlan(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + builder := &stubContextBuilder{} + scripted := &scriptedProvider{ + responses: []scriptedResponse{ + { + Message: providertypes.Message{ + Role: providertypes.RoleAssistant, + Parts: []providertypes.ContentPart{providertypes.NewTextPart(`{ + "plan_spec": { + "goal": "为 runtime 引入 plan/build 模式", + "steps": ["扩展 session", "扩展 runtime"], + "constraints": ["保持 tools 边界"], + "verify": ["go test ./internal/runtime"], + "todos": [{"id":"todo-plan-1","content":"扩展 session","status":"pending"}] + }, + "summary_candidate": { + "goal": "为 runtime 引入 plan/build 模式", + "key_steps": ["扩展 session", "扩展 runtime"], + "constraints": ["保持 tools 边界"], + "verify": ["go test ./internal/runtime"], + "active_todo_ids": ["todo-plan-1"] + } +}`)}, + }, + FinishReason: "stop", + }, + }, + } + + service := NewWithFactory(manager, tools.NewRegistry(), store, &scriptedProviderFactory{provider: scripted}, builder) + if err := service.Run(context.Background(), UserInput{ + RunID: "run-plan-persists-draft", + Mode: string(agentsession.AgentModePlan), + Parts: []providertypes.ContentPart{providertypes.NewTextPart("请先给出计划")}, + }); err != nil { + t.Fatalf("Run() error = %v", err) + } + + saved := onlySession(t, store) + if saved.AgentMode != agentsession.AgentModePlan { + t.Fatalf("AgentMode = %q, want %q", saved.AgentMode, agentsession.AgentModePlan) + } + if saved.CurrentPlan == nil { + t.Fatalf("expected CurrentPlan to be persisted") + } + if saved.CurrentPlan.Status != agentsession.PlanStatusDraft { + t.Fatalf("Status = %q, want %q", saved.CurrentPlan.Status, agentsession.PlanStatusDraft) + } + if saved.CurrentPlan.Spec.Goal != "为 runtime 引入 plan/build 模式" { + t.Fatalf("Goal = %q", saved.CurrentPlan.Spec.Goal) + } + if saved.LastFullPlanRevision != 0 { + t.Fatalf("LastFullPlanRevision = %d, want 0 before first full-plan alignment", saved.LastFullPlanRevision) + } + if builder.callCount != 1 { + t.Fatalf("builder call count = %d, want 1", builder.callCount) + } + if builder.lastInput.PlanStage != planStagePlan { + t.Fatalf("PlanStage = %q, want %q", builder.lastInput.PlanStage, planStagePlan) + } + if builder.lastInput.CurrentPlan != nil { + t.Fatalf("expected initial plan-mode build input to have nil CurrentPlan") + } + if len(saved.Messages) != 2 { + t.Fatalf("message count = %d, want 2", len(saved.Messages)) + } + if got := renderPartsForTest(saved.Messages[1].Parts); !strings.Contains(got, "目标") { + t.Fatalf("expected rendered plan content, got %q", got) + } +} + +func TestServiceRunBuildModeDoesNotRequireCurrentPlan(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + builder := &stubContextBuilder{} + scripted := &scriptedProvider{ + responses: []scriptedResponse{ + { + Message: providertypes.Message{ + Role: providertypes.RoleAssistant, + Parts: []providertypes.ContentPart{providertypes.NewTextPart(`{ + "plan_spec": { + "goal": "落地 build bootstrap", + "steps": ["补齐最小计划", "进入执行"], + "constraints": ["bootstrap 阶段只读"], + "verify": ["执行完成后进入 verify"], + "todos": [{"id":"todo-build-1","content":"补齐最小计划","status":"pending"}] + }, + "summary_candidate": { + "goal": "落地 build bootstrap", + "key_steps": ["补齐最小计划", "进入执行"], + "constraints": ["bootstrap 阶段只读"], + "verify": ["执行完成后进入 verify"], + "active_todo_ids": ["todo-build-1"] + } +}`)}, + }, + FinishReason: "stop", + }, + { + Message: providertypes.Message{ + Role: providertypes.RoleAssistant, + Parts: []providertypes.ContentPart{providertypes.NewTextPart("implementation complete")}, + }, + FinishReason: "stop", + }, + }, + } + + service := NewWithFactory(manager, tools.NewRegistry(), store, &scriptedProviderFactory{provider: scripted}, builder) + if err := service.Run(context.Background(), UserInput{ + RunID: "run-build-bootstrap-complete", + Mode: string(agentsession.AgentModeBuild), + Parts: []providertypes.ContentPart{providertypes.NewTextPart("直接进入 build")}, + }); err != nil { + t.Fatalf("Run() error = %v", err) + } + + saved := onlySession(t, store) + if saved.AgentMode != agentsession.AgentModeBuild { + t.Fatalf("AgentMode = %q, want %q", saved.AgentMode, agentsession.AgentModeBuild) + } + if saved.CurrentPlan != nil { + t.Fatalf("expected build mode to complete without CurrentPlan, got %+v", saved.CurrentPlan) + } + if builder.callCount != 1 { + t.Fatalf("builder call count = %d, want 1", builder.callCount) + } + if builder.builds[0].PlanStage != planStageBuildExecute { + t.Fatalf("PlanStage = %q, want %q", builder.builds[0].PlanStage, planStageBuildExecute) + } + if builder.builds[0].CurrentPlan != nil { + t.Fatalf("expected build mode without plan to keep CurrentPlan nil") + } + if builder.builds[0].InjectFullPlan { + t.Fatalf("expected build mode without plan not to inject full plan") + } +} + +func TestServiceRunPlanModeInjectsFullPlanOnNextTurnAfterDraftCreation(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + builder := &stubContextBuilder{} + scripted := &scriptedProvider{ + responses: []scriptedResponse{ + { + Message: providertypes.Message{ + Role: providertypes.RoleAssistant, + Parts: []providertypes.ContentPart{providertypes.NewTextPart(`{ + "plan_spec": { + "goal": "Introduce plan mode", + "steps": ["persist plan state"], + "verify": ["go test ./internal/runtime"], + "todos": [{"id":"todo-plan-1","content":"persist plan state","status":"pending"}] + }, + "summary_candidate": { + "goal": "Introduce plan mode", + "key_steps": ["persist plan state"], + "constraints": [], + "verify": ["go test ./internal/runtime"], + "active_todo_ids": ["todo-plan-1"] + } +}`)}, + }, + FinishReason: "stop", + }, + { + Message: providertypes.Message{ + Role: providertypes.RoleAssistant, + Parts: []providertypes.ContentPart{providertypes.NewTextPart(`{ + "plan_spec": { + "goal": "Introduce plan mode v2", + "steps": ["persist plan state", "align next run"], + "verify": ["go test ./internal/runtime"], + "todos": [{"id":"todo-plan-2","content":"align next run","status":"pending"}] + }, + "summary_candidate": { + "goal": "Introduce plan mode v2", + "key_steps": ["persist plan state", "align next run"], + "constraints": [], + "verify": ["go test ./internal/runtime"], + "active_todo_ids": ["todo-plan-2"] + } +}`)}, + }, + FinishReason: "stop", + }, + }, + } + + service := NewWithFactory(manager, tools.NewRegistry(), store, &scriptedProviderFactory{provider: scripted}, builder) + if err := service.Run(context.Background(), UserInput{ + RunID: "run-plan-first-draft", + Mode: string(agentsession.AgentModePlan), + Parts: []providertypes.ContentPart{providertypes.NewTextPart("draft plan")}, + }); err != nil { + t.Fatalf("first Run() error = %v", err) + } + firstSession := onlySession(t, store) + _ = collectRuntimeEvents(service.Events()) + + if err := service.Run(context.Background(), UserInput{ + SessionID: firstSession.ID, + RunID: "run-plan-second-align", + Mode: string(agentsession.AgentModePlan), + Parts: []providertypes.ContentPart{providertypes.NewTextPart("continue planning")}, + }); err != nil { + t.Fatalf("second Run() error = %v", err) + } + + if len(builder.builds) != 2 { + t.Fatalf("expected 2 builder calls, got %d", len(builder.builds)) + } + if builder.builds[0].InjectFullPlan { + t.Fatalf("expected initial draft turn not to inject full plan") + } + if !builder.builds[1].InjectFullPlan { + t.Fatalf("expected next turn after draft creation to inject full plan") + } + if builder.builds[1].CurrentPlan == nil || builder.builds[1].CurrentPlan.Revision != 1 { + t.Fatalf("expected second turn to see revision 1 current plan, got %+v", builder.builds[1].CurrentPlan) + } +} + +func TestServiceRunPlanModeUsesSummaryViewForAlignedPlanTurn(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + builder := &stubContextBuilder{} + seed := agentsession.New("aligned plan") + seed.AgentMode = agentsession.AgentModePlan + seed.CurrentPlan = &agentsession.PlanArtifact{ + ID: "plan-aligned", + Revision: 2, + Status: agentsession.PlanStatusDraft, + Spec: agentsession.PlanSpec{ + Goal: "Keep planning aligned", + Steps: []string{"summarize current plan"}, + Verify: []string{"go test ./internal/runtime"}, + Todos: []agentsession.TodoItem{ + {ID: "todo-aligned", Content: "summarize current plan", Status: agentsession.TodoStatusPending}, + }, + }, + Summary: agentsession.SummaryView{ + Goal: "Keep planning aligned", + KeySteps: []string{"summarize current plan"}, + Verify: []string{"go test ./internal/runtime"}, + ActiveTodoIDs: []string{"todo-aligned"}, + }, + } + seed.LastFullPlanRevision = 2 + if _, err := store.CreateSession(context.Background(), createSessionInputFromSession(seed)); err != nil { + t.Fatalf("CreateSession() error = %v", err) + } + + scripted := &scriptedProvider{ + responses: []scriptedResponse{ + { + Message: providertypes.Message{ + Role: providertypes.RoleAssistant, + Parts: []providertypes.ContentPart{providertypes.NewTextPart(`{ + "plan_spec": { + "goal": "Keep planning aligned v2", + "steps": ["summarize current plan", "collect open questions"], + "verify": ["go test ./internal/runtime"], + "todos": [{"id":"todo-aligned-2","content":"collect open questions","status":"pending"}] + }, + "summary_candidate": { + "goal": "Keep planning aligned v2", + "key_steps": ["summarize current plan", "collect open questions"], + "constraints": [], + "verify": ["go test ./internal/runtime"], + "active_todo_ids": ["todo-aligned-2"] + } +}`)}, + }, + FinishReason: "stop", + }, + }, + } + + service := NewWithFactory(manager, tools.NewRegistry(), store, &scriptedProviderFactory{provider: scripted}, builder) + if err := service.Run(context.Background(), UserInput{ + SessionID: seed.ID, + RunID: "run-plan-aligned-summary", + Mode: string(agentsession.AgentModePlan), + Parts: []providertypes.ContentPart{providertypes.NewTextPart("continue planning")}, + }); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if len(builder.builds) != 1 { + t.Fatalf("expected 1 builder call, got %d", len(builder.builds)) + } + if builder.builds[0].InjectFullPlan { + t.Fatalf("expected aligned plan turn to use SummaryView only") + } +} + +func TestServiceRunBuildModeInjectsFullPlanForUnalignedExistingPlan(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + builder := &stubContextBuilder{} + seed := agentsession.New("restored build") + seed.AgentMode = agentsession.AgentModeBuild + seed.CurrentPlan = &agentsession.PlanArtifact{ + ID: "plan-restored", + Revision: 2, + Status: agentsession.PlanStatusApproved, + Spec: agentsession.PlanSpec{ + Goal: "Resume build execution", + Steps: []string{"resume implementation"}, + Verify: []string{"go test ./internal/runtime"}, + Todos: []agentsession.TodoItem{ + {ID: "todo-restored", Content: "resume implementation", Status: agentsession.TodoStatusPending}, + }, + }, + Summary: agentsession.SummaryView{ + Goal: "Resume build execution", + KeySteps: []string{"resume implementation"}, + Verify: []string{"go test ./internal/runtime"}, + ActiveTodoIDs: []string{"todo-restored"}, + }, + } + seed.LastFullPlanRevision = 0 + if _, err := store.CreateSession(context.Background(), createSessionInputFromSession(seed)); err != nil { + t.Fatalf("CreateSession() error = %v", err) + } + + scripted := &scriptedProvider{ + responses: []scriptedResponse{ + { + Message: providertypes.Message{ + Role: providertypes.RoleAssistant, + Parts: []providertypes.ContentPart{providertypes.NewTextPart("build done")}, + }, + FinishReason: "stop", + }, + }, + } + + service := NewWithFactory(manager, tools.NewRegistry(), store, &scriptedProviderFactory{provider: scripted}, builder) + if err := service.Run(context.Background(), UserInput{ + SessionID: seed.ID, + RunID: "run-build-restored-align", + Mode: string(agentsession.AgentModeBuild), + Parts: []providertypes.ContentPart{providertypes.NewTextPart("resume build")}, + }); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if len(builder.builds) != 1 { + t.Fatalf("expected 1 builder call, got %d", len(builder.builds)) + } + if !builder.builds[0].InjectFullPlan { + t.Fatalf("expected unaligned existing plan to inject full plan") + } + saved := onlySession(t, store) + if saved.LastFullPlanRevision != 2 { + t.Fatalf("expected last full plan revision 2, got %d", saved.LastFullPlanRevision) + } +} + +func TestServiceRunBuildModeUsesSummaryViewForAlignedExecuteTurn(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + builder := &stubContextBuilder{} + seed := agentsession.New("aligned build") + seed.AgentMode = agentsession.AgentModeBuild + seed.CurrentPlan = &agentsession.PlanArtifact{ + ID: "plan-build-aligned", + Revision: 3, + Status: agentsession.PlanStatusApproved, + Spec: agentsession.PlanSpec{ + Goal: "Execute aligned build", + Steps: []string{"continue implementation"}, + Verify: []string{"go test ./internal/runtime"}, + Todos: []agentsession.TodoItem{ + {ID: "todo-build-aligned", Content: "continue implementation", Status: agentsession.TodoStatusPending}, + }, + }, + Summary: agentsession.SummaryView{ + Goal: "Execute aligned build", + KeySteps: []string{"continue implementation"}, + Verify: []string{"go test ./internal/runtime"}, + ActiveTodoIDs: []string{"todo-build-aligned"}, + }, + } + seed.LastFullPlanRevision = 3 + if _, err := store.CreateSession(context.Background(), createSessionInputFromSession(seed)); err != nil { + t.Fatalf("CreateSession() error = %v", err) + } + + scripted := &scriptedProvider{ + responses: []scriptedResponse{ + { + Message: providertypes.Message{ + Role: providertypes.RoleAssistant, + Parts: []providertypes.ContentPart{providertypes.NewTextPart("build done")}, + }, + FinishReason: "stop", + }, + }, + } + + service := NewWithFactory(manager, tools.NewRegistry(), store, &scriptedProviderFactory{provider: scripted}, builder) + if err := service.Run(context.Background(), UserInput{ + SessionID: seed.ID, + RunID: "run-build-aligned-summary", + Mode: string(agentsession.AgentModeBuild), + Parts: []providertypes.ContentPart{providertypes.NewTextPart("continue build")}, + }); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if len(builder.builds) != 1 { + t.Fatalf("expected 1 builder call, got %d", len(builder.builds)) + } + if builder.builds[0].InjectFullPlan { + t.Fatalf("expected aligned build execute turn to use SummaryView only") + } +} + +func TestServiceRunBuildModeInjectsFullPlanWhenSummaryIsUnusable(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + builder := &stubContextBuilder{} + seed := agentsession.New("full-plan fallback") + seed.AgentMode = agentsession.AgentModeBuild + seed.CurrentPlan = &agentsession.PlanArtifact{ + ID: "plan-full-fallback", + Revision: 1, + Status: agentsession.PlanStatusApproved, + Spec: agentsession.PlanSpec{ + Goal: "Follow full plan when summary is missing", + Steps: []string{"review whole plan"}, + Verify: []string{"go test ./internal/runtime"}, + Todos: []agentsession.TodoItem{ + {ID: "todo-full-fallback", Content: "review whole plan", Status: agentsession.TodoStatusPending}, + }, + }, + Summary: agentsession.SummaryView{}, + } + seed.LastFullPlanRevision = 1 + if _, err := store.CreateSession(context.Background(), createSessionInputFromSession(seed)); err != nil { + t.Fatalf("CreateSession() error = %v", err) + } + + scripted := &scriptedProvider{ + responses: []scriptedResponse{ + { + Message: providertypes.Message{ + Role: providertypes.RoleAssistant, + Parts: []providertypes.ContentPart{providertypes.NewTextPart("build done")}, + }, + FinishReason: "stop", + }, + }, + } + + service := NewWithFactory(manager, tools.NewRegistry(), store, &scriptedProviderFactory{provider: scripted}, builder) + if err := service.Run(context.Background(), UserInput{ + SessionID: seed.ID, + RunID: "run-full-plan-fallback", + Mode: string(agentsession.AgentModeBuild), + Parts: []providertypes.ContentPart{providertypes.NewTextPart("continue build")}, + }); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if len(builder.builds) != 1 { + t.Fatalf("expected 1 builder call, got %d", len(builder.builds)) + } + if !builder.builds[0].InjectFullPlan { + t.Fatalf("expected unusable summary to force full plan injection") + } +} + +func TestServiceApproveCurrentPlanTriggersOneFullPlanAlignment(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + builder := &stubContextBuilder{} + seed := agentsession.New("approve current plan") + seed.AgentMode = agentsession.AgentModeBuild + seed.CurrentPlan = &agentsession.PlanArtifact{ + ID: "plan-approve-runtime", + Revision: 4, + Status: agentsession.PlanStatusDraft, + Spec: agentsession.PlanSpec{ + Goal: "批准并执行当前计划", + Steps: []string{"继续实现"}, + Verify: []string{"go test ./internal/runtime"}, + }, + Summary: agentsession.SummaryView{ + Goal: "批准并执行当前计划", + KeySteps: []string{"继续实现"}, + Verify: []string{"go test ./internal/runtime"}, + }, + } + seed.LastFullPlanRevision = 4 + if _, err := store.CreateSession(context.Background(), createSessionInputFromSession(seed)); err != nil { + t.Fatalf("CreateSession() error = %v", err) + } + + service := NewWithFactory(manager, tools.NewRegistry(), store, &scriptedProviderFactory{provider: &scriptedProvider{ + responses: []scriptedResponse{ + { + Message: providertypes.Message{ + Role: providertypes.RoleAssistant, + Parts: []providertypes.ContentPart{providertypes.NewTextPart("build done")}, + }, + FinishReason: "stop", + }, + }, + }}, builder) + + if err := service.ApproveCurrentPlan(context.Background(), ApproveCurrentPlanInput{ + SessionID: seed.ID, + PlanID: "plan-approve-runtime", + Revision: 4, + }); err != nil { + t.Fatalf("ApproveCurrentPlan() error = %v", err) + } + + saved := onlySession(t, store) + if saved.CurrentPlan == nil || saved.CurrentPlan.Status != agentsession.PlanStatusApproved { + t.Fatalf("expected approved current plan, got %+v", saved.CurrentPlan) + } + if !saved.PlanApprovalPendingFullAlign { + t.Fatal("expected approval to schedule one full-plan alignment") + } + + if err := service.Run(context.Background(), UserInput{ + SessionID: seed.ID, + RunID: "run-approved-align", + Mode: string(agentsession.AgentModeBuild), + Parts: []providertypes.ContentPart{providertypes.NewTextPart("continue build")}, + }); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if len(builder.builds) != 1 { + t.Fatalf("expected 1 builder call, got %d", len(builder.builds)) + } + if !builder.builds[0].InjectFullPlan { + t.Fatalf("expected approved plan to inject full plan once") + } + + saved = onlySession(t, store) + if saved.PlanApprovalPendingFullAlign { + t.Fatal("expected approval alignment flag to clear after full-plan injection") + } +} + +func TestServiceApproveCurrentPlanNilService(t *testing.T) { + t.Parallel() + + var service *Service + err := service.ApproveCurrentPlan(context.Background(), ApproveCurrentPlanInput{ + SessionID: "session-1", + PlanID: "plan-1", + Revision: 1, + }) + if err == nil || !strings.Contains(err.Error(), "service is nil") { + t.Fatalf("expected service is nil error, got %v", err) + } +} + +func TestServiceRunBuildModeIgnoresPlanningJSON(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + builder := &stubContextBuilder{} + seed := agentsession.New("build ignores plan json") + seed.AgentMode = agentsession.AgentModeBuild + seed.CurrentPlan = &agentsession.PlanArtifact{ + ID: "plan-stable", + Revision: 1, + Status: agentsession.PlanStatusDraft, + Spec: agentsession.PlanSpec{ + Goal: "保持旧计划不被覆盖", + Steps: []string{"旧步骤"}, + Verify: []string{"旧验证"}, + }, + Summary: agentsession.SummaryView{ + Goal: "保持旧计划不被覆盖", + KeySteps: []string{"旧步骤"}, + Verify: []string{"旧验证"}, + }, + } + seed.LastFullPlanRevision = 1 + if _, err := store.CreateSession(context.Background(), createSessionInputFromSession(seed)); err != nil { + t.Fatalf("CreateSession() error = %v", err) + } + + scripted := &scriptedProvider{ + responses: []scriptedResponse{ + { + Message: providertypes.Message{ + Role: providertypes.RoleAssistant, + Parts: []providertypes.ContentPart{providertypes.NewTextPart(`{ + "plan_spec": { + "goal": "不应在 build 中落库", + "steps": ["错误改写计划"], + "verify": ["不应落库"], + "todos": [{"id":"todo-build-plan-json","content":"bad","status":"pending"}] + }, + "summary_candidate": { + "goal": "不应在 build 中落库", + "key_steps": ["错误改写计划"], + "constraints": [], + "verify": ["不应落库"], + "active_todo_ids": ["todo-build-plan-json"] + } +}`)}, + }, + FinishReason: "stop", + }, + }, + } + + service := NewWithFactory(manager, tools.NewRegistry(), store, &scriptedProviderFactory{provider: scripted}, builder) + if err := service.Run(context.Background(), UserInput{ + SessionID: seed.ID, + RunID: "run-build-ignore-plan-json", + Mode: string(agentsession.AgentModeBuild), + Parts: []providertypes.ContentPart{providertypes.NewTextPart("continue build")}, + }); err != nil { + t.Fatalf("Run() error = %v", err) + } + + saved := onlySession(t, store) + if saved.CurrentPlan == nil || saved.CurrentPlan.Spec.Goal != "保持旧计划不被覆盖" { + t.Fatalf("expected build mode to keep existing plan unchanged, got %+v", saved.CurrentPlan) + } +} + +func TestServiceRunCompletedPlanRequestsOneFinalFullReview(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + builder := &stubContextBuilder{} + seed := agentsession.New("completed plan review") + seed.AgentMode = agentsession.AgentModeBuild + seed.CurrentPlan = &agentsession.PlanArtifact{ + ID: "plan-complete-review", + Revision: 2, + Status: agentsession.PlanStatusDraft, + Spec: agentsession.PlanSpec{ + Goal: "完成计划后仍需一次全文确认", + Steps: []string{"收尾"}, + Verify: []string{"go test ./internal/runtime"}, + }, + Summary: agentsession.SummaryView{ + Goal: "完成计划后仍需一次全文确认", + KeySteps: []string{"收尾"}, + Verify: []string{"go test ./internal/runtime"}, + }, + } + seed.LastFullPlanRevision = 2 + if _, err := store.CreateSession(context.Background(), createSessionInputFromSession(seed)); err != nil { + t.Fatalf("CreateSession() error = %v", err) + } + + scripted := &scriptedProvider{ + responses: []scriptedResponse{ + { + Message: providertypes.Message{ + Role: providertypes.RoleAssistant, + Parts: []providertypes.ContentPart{ + providertypes.NewTextPart("{\"task_completion\":{\"completed\":true}}\n执行已完成。"), + }, + }, + FinishReason: "stop", + }, + { + Message: providertypes.Message{ + Role: providertypes.RoleAssistant, + Parts: []providertypes.ContentPart{providertypes.NewTextPart("确认完成情况。")}, + }, + FinishReason: "stop", + }, + }, + } + + service := NewWithFactory(manager, tools.NewRegistry(), store, &scriptedProviderFactory{provider: scripted}, builder) + if err := service.Run(context.Background(), UserInput{ + SessionID: seed.ID, + RunID: "run-complete-first", + Mode: string(agentsession.AgentModeBuild), + Parts: []providertypes.ContentPart{providertypes.NewTextPart("finish task")}, + }); err != nil { + t.Fatalf("first Run() error = %v", err) + } + saved := onlySession(t, store) + if saved.CurrentPlan == nil || saved.CurrentPlan.Status != agentsession.PlanStatusCompleted { + t.Fatalf("expected current plan to become completed, got %+v", saved.CurrentPlan) + } + if !saved.PlanCompletionPendingFullReview { + t.Fatal("expected completed plan to request one final full-plan review") + } + _ = collectRuntimeEvents(service.Events()) + + if err := service.Run(context.Background(), UserInput{ + SessionID: seed.ID, + RunID: "run-complete-review", + Mode: string(agentsession.AgentModeBuild), + Parts: []providertypes.ContentPart{providertypes.NewTextPart("confirm completion")}, + }); err != nil { + t.Fatalf("second Run() error = %v", err) + } + + if len(builder.builds) != 2 { + t.Fatalf("expected 2 builder calls, got %d", len(builder.builds)) + } + if !builder.builds[1].InjectFullPlan { + t.Fatalf("expected the post-completion review turn to inject full plan") + } + + saved = onlySession(t, store) + if saved.PlanCompletionPendingFullReview { + t.Fatal("expected completion review flag to clear after final full-plan review") + } +} + +func TestServiceCompactMarksPlanContextDirty(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + session := agentsession.New("compact marks plan dirty") + session.ID = "session-compact-plan-dirty" + session.CurrentPlan = &agentsession.PlanArtifact{ + ID: "plan-compact", + Revision: 1, + Status: agentsession.PlanStatusApproved, + Spec: agentsession.PlanSpec{ + Goal: "compact 后重对齐计划", + Steps: []string{"压缩历史"}, + Verify: []string{"go test ./internal/runtime"}, + }, + Summary: agentsession.SummaryView{ + Goal: "compact 后重对齐计划", + KeySteps: []string{"压缩历史"}, + Verify: []string{"go test ./internal/runtime"}, + }, + } + if _, err := store.CreateSession(context.Background(), createSessionInputFromSession(session)); err != nil { + t.Fatalf("CreateSession() error = %v", err) + } + + service := NewWithFactory(manager, tools.NewRegistry(), store, &scriptedProviderFactory{provider: &scriptedProvider{}}, nil) + service.compactRunner = &stubCompactRunner{ + result: contextcompact.Result{ + Messages: []providertypes.Message{ + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("[compact_summary]\ndone:\n- ok")}}, + }, + Applied: true, + Metrics: contextcompact.Metrics{TriggerMode: string(contextcompact.ModeManual)}, + }, + } + + if _, err := service.Compact(context.Background(), CompactInput{ + SessionID: session.ID, + RunID: "run-manual-plan-dirty", + }); err != nil { + t.Fatalf("Compact() error = %v", err) + } + + saved := onlySession(t, store) + if !saved.PlanContextDirty { + t.Fatal("expected compact to mark current plan context dirty") + } +} + +func TestServiceRunCompactedSessionRequestsRestoreAlignment(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + builder := &stubContextBuilder{} + seed := agentsession.New("restored after compact") + seed.AgentMode = agentsession.AgentModeBuild + seed.CurrentPlan = &agentsession.PlanArtifact{ + ID: "plan-restore-align", + Revision: 1, + Status: agentsession.PlanStatusApproved, + Spec: agentsession.PlanSpec{ + Goal: "compact 恢复后重新对齐计划", + Steps: []string{"继续执行"}, + Verify: []string{"go test ./internal/runtime"}, + }, + Summary: agentsession.SummaryView{ + Goal: "compact 恢复后重新对齐计划", + KeySteps: []string{"继续执行"}, + Verify: []string{"go test ./internal/runtime"}, + }, + } + seed.LastFullPlanRevision = 1 + seed.Messages = []providertypes.Message{ + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("[compact_summary]\ndone:\n- archived\n\nin_progress:\n- continue")}}, + } + if _, err := store.CreateSession(context.Background(), createSessionInputFromSession(seed)); err != nil { + t.Fatalf("CreateSession() error = %v", err) + } + store.sessions[seed.ID] = cloneSession(seed) + + scripted := &scriptedProvider{ + responses: []scriptedResponse{ + { + Message: providertypes.Message{ + Role: providertypes.RoleAssistant, + Parts: []providertypes.ContentPart{providertypes.NewTextPart("resume after compact")}, + }, + FinishReason: "stop", + }, + }, + } + + service := NewWithFactory(manager, tools.NewRegistry(), store, &scriptedProviderFactory{provider: scripted}, builder) + if err := service.Run(context.Background(), UserInput{ + SessionID: seed.ID, + RunID: "run-restored-align", + Mode: string(agentsession.AgentModeBuild), + Parts: []providertypes.ContentPart{providertypes.NewTextPart("continue")}, + }); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if len(builder.builds) != 1 { + t.Fatalf("expected 1 builder call, got %d", len(builder.builds)) + } + if !builder.builds[0].InjectFullPlan { + t.Fatalf("expected compact-restored session to inject full plan once") + } +} + func newRuntimeConfigManager(t *testing.T) *config.Manager { return newRuntimeConfigManagerWithProviderEnvs(t, nil) } @@ -3681,6 +4567,8 @@ func cloneSession(session agentsession.Session) agentsession.Session { cloned.Messages = append([]providertypes.Message(nil), session.Messages...) cloned.TaskState = session.TaskState.Clone() cloned.ActivatedSkills = append([]agentsession.SkillActivation(nil), session.ActivatedSkills...) + cloned.Todos = cloneTodosForPersistence(session.Todos) + cloned.CurrentPlan = session.CurrentPlan.Clone() return cloned } @@ -3695,6 +4583,8 @@ func cloneBuildInput(input agentcontext.BuildInput) agentcontext.BuildInput { cloned := input cloned.Messages = append([]providertypes.Message(nil), input.Messages...) cloned.TaskState = input.TaskState.Clone() + cloned.Todos = cloneTodosForPersistence(input.Todos) + cloned.CurrentPlan = input.CurrentPlan.Clone() cloned.ActiveSkills = append([]skills.Skill(nil), input.ActiveSkills...) if input.RepositorySummary != nil { summary := *input.RepositorySummary diff --git a/internal/runtime/session_mutation.go b/internal/runtime/session_mutation.go index 7b2dd791..26d3d9bb 100644 --- a/internal/runtime/session_mutation.go +++ b/internal/runtime/session_mutation.go @@ -225,6 +225,7 @@ func cloneSessionForPersistence(session agentsession.Session) agentsession.Sessi cloned.TaskState = session.TaskState.Clone() cloned.ActivatedSkills = agentsessionCloneSkillActivations(session.ActivatedSkills) cloned.Todos = cloneTodosForPersistence(session.Todos) + cloned.CurrentPlan = session.CurrentPlan.Clone() return cloned } diff --git a/internal/runtime/session_scheduler.go b/internal/runtime/session_scheduler.go index 9becf528..b70240d5 100644 --- a/internal/runtime/session_scheduler.go +++ b/internal/runtime/session_scheduler.go @@ -7,6 +7,7 @@ import ( "sync" "time" + "neo-code/internal/partsrender" agentsession "neo-code/internal/session" ) @@ -37,8 +38,12 @@ func (s *Service) loadOrCreateSession( return agentsession.Session{}, err } profileChanged := establishSessionVerificationProfile(&session) + restoreAlignChanged := false + if sessionHasCompactedTranscript(session) { + restoreAlignChanged = markCurrentPlanRestorePending(&session) + } if strings.TrimSpace(requestedWorkdir) == "" && strings.TrimSpace(session.Workdir) != "" { - if profileChanged { + if profileChanged || restoreAlignChanged { session.UpdatedAt = time.Now() if err := s.sessionStore.UpdateSessionState(ctx, sessionStateInputFromSession(session)); err != nil { return agentsession.Session{}, err @@ -52,7 +57,7 @@ func (s *Service) loadOrCreateSession( return agentsession.Session{}, err } workdirChanged := session.Workdir != resolved - if !workdirChanged && !profileChanged { + if !workdirChanged && !profileChanged && !restoreAlignChanged { return session, nil } if workdirChanged { @@ -65,6 +70,20 @@ func (s *Service) loadOrCreateSession( return session, nil } +// sessionHasCompactedTranscript 判断会话是否已进入 compact 后的恢复上下文。 +func sessionHasCompactedTranscript(session agentsession.Session) bool { + if len(session.Messages) == 0 { + return false + } + for _, message := range session.Messages { + if !strings.Contains(partsrender.RenderDisplayParts(message.Parts), "[compact_summary]") { + continue + } + return true + } + return false +} + // establishSessionVerificationProfile 在创建新会话的边界显式写入验收 profile,避免运行时依赖隐式零值。 func establishSessionVerificationProfile(session *agentsession.Session) bool { if session == nil { diff --git a/internal/runtime/state.go b/internal/runtime/state.go index fc420632..1d41df98 100644 --- a/internal/runtime/state.go +++ b/internal/runtime/state.go @@ -17,6 +17,7 @@ type runState struct { compactCount int reactiveCompactAttempts int rememberedThisRun bool + planningEnabled bool taskID string agentID string capabilityToken *security.CapabilityToken diff --git a/internal/session/plan.go b/internal/session/plan.go new file mode 100644 index 00000000..4bfead1b --- /dev/null +++ b/internal/session/plan.go @@ -0,0 +1,314 @@ +package session + +import ( + "fmt" + "strings" + "time" +) + +// AgentMode identifies the session's working mode. +type AgentMode string + +const ( + AgentModePlan AgentMode = "plan" + AgentModeBuild AgentMode = "build" +) + +// PlanStatus tracks the lifecycle of the current plan artifact. +type PlanStatus string + +const ( + PlanStatusDraft PlanStatus = "draft" + PlanStatusApproved PlanStatus = "approved" + PlanStatusCompleted PlanStatus = "completed" +) + +const ( + maxSummaryKeySteps = 5 + maxSummaryConstraints = 5 + maxSummaryVerify = 5 + maxSummaryTodoIDs = 20 +) + +// PlanArtifact stores the current plan persisted in the session. +type PlanArtifact struct { + ID string `json:"id"` + Revision int `json:"revision"` + Status PlanStatus `json:"status"` + Spec PlanSpec `json:"spec"` + Summary SummaryView `json:"summary"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// PlanSpec is the source of truth for the current plan. +type PlanSpec struct { + Goal string `json:"goal"` + Steps []string `json:"steps,omitempty"` + Constraints []string `json:"constraints,omitempty"` + Verify []string `json:"verify,omitempty"` + Todos []TodoItem `json:"todos,omitempty"` + OpenQuestions []string `json:"open_questions,omitempty"` +} + +// SummaryView is the compact projection derived from PlanSpec. +type SummaryView struct { + Goal string `json:"goal"` + KeySteps []string `json:"key_steps,omitempty"` + Constraints []string `json:"constraints,omitempty"` + Verify []string `json:"verify,omitempty"` + ActiveTodoIDs []string `json:"active_todo_ids,omitempty"` +} + +// Clone returns a deep copy of the plan artifact. +func (p *PlanArtifact) Clone() *PlanArtifact { + if p == nil { + return nil + } + cloned := *p + cloned.Spec = p.Spec.Clone() + cloned.Summary = p.Summary.Clone() + return &cloned +} + +// Clone returns a deep copy of the plan spec. +func (p PlanSpec) Clone() PlanSpec { + p.Goal = strings.TrimSpace(p.Goal) + p.Steps = append([]string(nil), p.Steps...) + p.Constraints = append([]string(nil), p.Constraints...) + p.Verify = append([]string(nil), p.Verify...) + p.OpenQuestions = append([]string(nil), p.OpenQuestions...) + p.Todos = cloneTodoItems(p.Todos) + return p +} + +// Clone returns a deep copy of the summary view. +func (s SummaryView) Clone() SummaryView { + s.Goal = strings.TrimSpace(s.Goal) + s.KeySteps = append([]string(nil), s.KeySteps...) + s.Constraints = append([]string(nil), s.Constraints...) + s.Verify = append([]string(nil), s.Verify...) + s.ActiveTodoIDs = append([]string(nil), s.ActiveTodoIDs...) + return s +} + +// NormalizeAgentMode normalizes empty and invalid values to build. +func NormalizeAgentMode(mode AgentMode) AgentMode { + switch AgentMode(strings.ToLower(strings.TrimSpace(string(mode)))) { + case AgentModePlan: + return AgentModePlan + case AgentModeBuild: + return AgentModeBuild + default: + return AgentModeBuild + } +} + +// NormalizePlanStatus normalizes empty and invalid values to draft. +func NormalizePlanStatus(status PlanStatus) PlanStatus { + switch PlanStatus(strings.ToLower(strings.TrimSpace(string(status)))) { + case PlanStatusDraft: + return PlanStatusDraft + case PlanStatusApproved: + return PlanStatusApproved + case PlanStatusCompleted: + return PlanStatusCompleted + default: + return PlanStatusDraft + } +} + +// NormalizePlanArtifact normalizes and validates the persisted plan artifact. +func NormalizePlanArtifact(plan *PlanArtifact) (*PlanArtifact, error) { + if plan == nil { + return nil, nil + } + cloned := plan.Clone() + if cloned == nil { + return nil, nil + } + cloned.ID = strings.TrimSpace(cloned.ID) + if cloned.Revision <= 0 { + cloned.Revision = 1 + } + cloned.Status = NormalizePlanStatus(cloned.Status) + if cloned.CreatedAt.IsZero() { + cloned.CreatedAt = time.Now().UTC() + } + if cloned.UpdatedAt.IsZero() { + cloned.UpdatedAt = cloned.CreatedAt + } else { + cloned.UpdatedAt = cloned.UpdatedAt.UTC() + } + + spec, err := NormalizePlanSpec(cloned.Spec) + if err != nil { + return nil, err + } + cloned.Spec = spec + if cloned.ID == "" { + return nil, fmt.Errorf("session: plan id is empty") + } + cloned.Summary = NormalizeSummaryView(cloned.Summary, cloned.Spec) + return cloned, nil +} + +// NormalizePlanSpec normalizes a plan spec for persistence and later reuse. +func NormalizePlanSpec(spec PlanSpec) (PlanSpec, error) { + spec = spec.Clone() + spec.Goal = strings.TrimSpace(spec.Goal) + spec.Steps = normalizeTodoTextList(spec.Steps) + spec.Constraints = normalizeTodoTextList(spec.Constraints) + spec.Verify = normalizeTodoTextList(spec.Verify) + spec.OpenQuestions = normalizeTodoTextList(spec.OpenQuestions) + + todos, err := normalizeAndValidateTodos(spec.Todos) + if err != nil { + return PlanSpec{}, err + } + spec.Todos = todos + + if spec.Goal == "" { + return PlanSpec{}, fmt.Errorf("session: plan goal is empty") + } + return spec, nil +} + +// NormalizeSummaryView falls back to a built summary when needed. +func NormalizeSummaryView(summary SummaryView, spec PlanSpec) SummaryView { + normalized := summary.Clone() + normalized.Goal = strings.TrimSpace(normalized.Goal) + normalized.KeySteps = normalizeTodoTextList(normalized.KeySteps) + normalized.Constraints = normalizeTodoTextList(normalized.Constraints) + normalized.Verify = normalizeTodoTextList(normalized.Verify) + normalized.ActiveTodoIDs = normalizeTodoTextList(normalized.ActiveTodoIDs) + if !summaryViewStructurallyValid(normalized, spec) { + return BuildSummaryView(spec) + } + return normalized +} + +// BuildSummaryView 从完整的方案规格文档,生成一份稳定、精炼的摘要 +func BuildSummaryView(spec PlanSpec) SummaryView { + spec, err := NormalizePlanSpec(spec) + if err != nil { + return SummaryView{} + } + return SummaryView{ + Goal: spec.Goal, + KeySteps: clampStringList(spec.Steps, maxSummaryKeySteps), + Constraints: clampStringList(spec.Constraints, maxSummaryConstraints), + Verify: clampStringList(spec.Verify, maxSummaryVerify), + ActiveTodoIDs: collectActiveTodoIDs(spec.Todos, maxSummaryTodoIDs), + } +} + +// RenderPlanContent renders the full plan text view for model context and logs. +func RenderPlanContent(spec PlanSpec) string { + spec, err := NormalizePlanSpec(spec) + if err != nil { + return "" + } + + sections := make([]string, 0, 6) + sections = append(sections, "目标\n"+spec.Goal) + if len(spec.Steps) > 0 { + sections = append(sections, "实施步骤\n"+renderBulletList(spec.Steps)) + } + if len(spec.Constraints) > 0 { + sections = append(sections, "约束\n"+renderBulletList(spec.Constraints)) + } + if len(spec.Verify) > 0 { + sections = append(sections, "验证\n"+renderBulletList(spec.Verify)) + } + activeTodos := collectActiveTodoLines(spec.Todos) + if len(activeTodos) > 0 { + sections = append(sections, "当前待办\n"+renderBulletList(activeTodos)) + } + if len(spec.OpenQuestions) > 0 { + sections = append(sections, "未决问题\n"+renderBulletList(spec.OpenQuestions)) + } + return strings.Join(sections, "\n\n") +} + +func summaryViewStructurallyValid(summary SummaryView, spec PlanSpec) bool { + if strings.TrimSpace(summary.Goal) == "" { + return false + } + if len(summary.KeySteps) == 0 || len(summary.Verify) == 0 { + return false + } + if len(summary.ActiveTodoIDs) == 0 { + return len(spec.Todos) == 0 + } + knownTodoIDs := make(map[string]struct{}, len(spec.Todos)) + for _, item := range spec.Todos { + knownTodoIDs[item.ID] = struct{}{} + } + for _, id := range summary.ActiveTodoIDs { + if _, ok := knownTodoIDs[id]; !ok { + return false + } + } + return true +} + +func clampStringList(items []string, maxItems int) []string { + normalized := normalizeTodoTextList(items) + if len(normalized) <= maxItems || maxItems <= 0 { + return normalized + } + return append([]string(nil), normalized[:maxItems]...) +} + +func collectActiveTodoIDs(items []TodoItem, limit int) []string { + if len(items) == 0 || limit <= 0 { + return nil + } + result := make([]string, 0, len(items)) + for _, item := range items { + if item.Status.IsTerminal() { + continue + } + result = append(result, item.ID) + if len(result) >= limit { + break + } + } + if len(result) == 0 { + return nil + } + return result +} + +func collectActiveTodoLines(items []TodoItem) []string { + if len(items) == 0 { + return nil + } + lines := make([]string, 0, len(items)) + for _, item := range items { + if item.Status.IsTerminal() { + continue + } + lines = append(lines, fmt.Sprintf("[%s] %s (id=%s)", item.Status, item.Content, item.ID)) + } + if len(lines) == 0 { + return nil + } + return lines +} + +func renderBulletList(items []string) string { + if len(items) == 0 { + return "" + } + 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") +} diff --git a/internal/session/plan_test.go b/internal/session/plan_test.go new file mode 100644 index 00000000..fb19b8c7 --- /dev/null +++ b/internal/session/plan_test.go @@ -0,0 +1,190 @@ +package session + +import ( + "strings" + "testing" + "time" +) + +func TestNormalizeSummaryViewFallsBackToBuiltSummaryWhenStructurallyInvalid(t *testing.T) { + t.Parallel() + + spec, err := NormalizePlanSpec(PlanSpec{ + Goal: "为 runtime 引入 plan/build 模式", + Steps: []string{"扩展 session", "过滤工具", "调整 runtime"}, + Constraints: []string{"plan 模式禁止写工具"}, + Verify: []string{"build 结束后进入 verify"}, + Todos: []TodoItem{ + {ID: "todo-1", Content: "扩展 session", Status: TodoStatusPending}, + {ID: "todo-2", Content: "过滤工具", Status: TodoStatusCompleted}, + }, + }) + if err != nil { + t.Fatalf("NormalizePlanSpec() error = %v", err) + } + + got := NormalizeSummaryView(SummaryView{ + Goal: " ", + KeySteps: []string{"仅一步"}, + Verify: []string{"验收"}, + ActiveTodoIDs: []string{"missing"}, + }, spec) + want := BuildSummaryView(spec) + + if got.Goal != want.Goal { + t.Fatalf("Goal = %q, want %q", got.Goal, want.Goal) + } + if len(got.KeySteps) != len(want.KeySteps) || got.KeySteps[0] != want.KeySteps[0] { + t.Fatalf("KeySteps = %+v, want %+v", got.KeySteps, want.KeySteps) + } + if len(got.ActiveTodoIDs) != 1 || got.ActiveTodoIDs[0] != "todo-1" { + t.Fatalf("ActiveTodoIDs = %+v, want [todo-1]", got.ActiveTodoIDs) + } +} + +func TestBuildSummaryViewUsesActiveNonTerminalTodosOnly(t *testing.T) { + t.Parallel() + + spec, err := NormalizePlanSpec(PlanSpec{ + Goal: "整理当前执行摘要", + Steps: []string{"步骤一", "步骤二"}, + Verify: []string{"验证一"}, + Todos: []TodoItem{ + {ID: "todo-1", Content: "待执行", Status: TodoStatusPending}, + {ID: "todo-2", Content: "执行中", Status: TodoStatusInProgress}, + {ID: "todo-3", Content: "已完成", Status: TodoStatusCompleted}, + }, + }) + if err != nil { + t.Fatalf("NormalizePlanSpec() error = %v", err) + } + + summary := BuildSummaryView(spec) + if len(summary.ActiveTodoIDs) != 2 { + t.Fatalf("ActiveTodoIDs length = %d, want 2", len(summary.ActiveTodoIDs)) + } + if summary.ActiveTodoIDs[0] != "todo-1" || summary.ActiveTodoIDs[1] != "todo-2" { + t.Fatalf("ActiveTodoIDs = %+v, want [todo-1 todo-2]", summary.ActiveTodoIDs) + } + if len(summary.KeySteps) != 2 || summary.KeySteps[0] != "步骤一" { + t.Fatalf("KeySteps = %+v", summary.KeySteps) + } +} + +func TestNormalizePlanArtifactDefaultsAndStatusNormalization(t *testing.T) { + t.Parallel() + + plan, err := NormalizePlanArtifact(&PlanArtifact{ + ID: "plan-1", + Revision: 0, + Status: PlanStatus("unknown"), + Spec: PlanSpec{ + Goal: "规范化计划对象", + Steps: []string{"步骤一"}, + Verify: []string{"验证一"}, + }, + }) + if err != nil { + t.Fatalf("NormalizePlanArtifact() error = %v", err) + } + if plan.Revision != 1 { + t.Fatalf("Revision = %d, want 1", plan.Revision) + } + if plan.Status != PlanStatusDraft { + t.Fatalf("Status = %q, want %q", plan.Status, PlanStatusDraft) + } + if plan.CreatedAt.IsZero() || plan.UpdatedAt.IsZero() { + t.Fatalf("expected timestamps to be populated: %+v", plan) + } + if plan.Summary.Goal != "规范化计划对象" { + t.Fatalf("Summary.Goal = %q", plan.Summary.Goal) + } +} + +func TestNormalizePlanArtifactPreservesCreatedAtAndNormalizesUpdatedAt(t *testing.T) { + t.Parallel() + + created := time.Date(2026, 4, 29, 12, 0, 0, 0, time.FixedZone("UTC+8", 8*3600)) + updated := created.Add(2 * time.Hour) + plan, err := NormalizePlanArtifact(&PlanArtifact{ + ID: "plan-2", + Revision: 2, + Status: PlanStatusApproved, + CreatedAt: created, + UpdatedAt: updated, + Spec: PlanSpec{ + Goal: "保留时间字段", + Steps: []string{"步骤一"}, + Verify: []string{"验证一"}, + }, + }) + if err != nil { + t.Fatalf("NormalizePlanArtifact() error = %v", err) + } + if !plan.CreatedAt.Equal(created.UTC()) { + t.Fatalf("CreatedAt = %v, want %v", plan.CreatedAt, created.UTC()) + } + if !plan.UpdatedAt.Equal(updated.UTC()) { + t.Fatalf("UpdatedAt = %v, want %v", plan.UpdatedAt, updated.UTC()) + } +} + +func TestNormalizeSummaryViewAllowsEmptyTodoRefsWhenPlanHasNoTodos(t *testing.T) { + t.Parallel() + + spec, err := NormalizePlanSpec(PlanSpec{ + Goal: "无 todo 计划", + Steps: []string{"步骤一"}, + Verify: []string{"验证一"}, + }) + if err != nil { + t.Fatalf("NormalizePlanSpec() error = %v", err) + } + + summary := NormalizeSummaryView(SummaryView{ + Goal: "无 todo 计划", + KeySteps: []string{"步骤一"}, + Verify: []string{"验证一"}, + }, spec) + if summary.Goal != "无 todo 计划" { + t.Fatalf("Goal = %q", summary.Goal) + } + if len(summary.ActiveTodoIDs) != 0 { + t.Fatalf("ActiveTodoIDs = %+v, want empty", summary.ActiveTodoIDs) + } +} + +func TestRenderPlanContentIncludesAllSections(t *testing.T) { + t.Parallel() + + rendered := RenderPlanContent(PlanSpec{ + Goal: "输出完整计划正文", + Steps: []string{"步骤一", "步骤二"}, + Constraints: []string{"约束一"}, + Verify: []string{"验证一"}, + OpenQuestions: []string{"问题一"}, + Todos: []TodoItem{ + {ID: "todo-1", Content: "待执行", Status: TodoStatusPending}, + {ID: "todo-2", Content: "已完成", Status: TodoStatusCompleted}, + }, + }) + + wantSubstrings := []string{ + "目标", + "输出完整计划正文", + "实施步骤", + "约束", + "验证", + "当前待办", + "id=todo-1", + "未决问题", + } + for _, want := range wantSubstrings { + if !strings.Contains(rendered, want) { + t.Fatalf("RenderPlanContent() = %q, want substring %q", rendered, want) + } + } + if strings.Contains(rendered, "todo-2") { + t.Fatalf("RenderPlanContent() should skip terminal todos, got %q", rendered) + } +} diff --git a/internal/session/sqlite_store.go b/internal/session/sqlite_store.go index 0e34dbdb..9f339342 100644 --- a/internal/session/sqlite_store.go +++ b/internal/session/sqlite_store.go @@ -20,19 +20,26 @@ import ( ) type sqliteSessionRow struct { - ID string - Title string - Provider string - Model string - CreatedAtMS int64 - UpdatedAtMS int64 - Workdir string - TaskStateJSON string - ActivatedJSON string - TodosJSON string - TokenInputTotal int - TokenOutputTotal int - HasUnknownUsage bool + ID string + Title string + Provider string + Model string + CreatedAtMS int64 + UpdatedAtMS int64 + Workdir string + TaskStateJSON string + ActivatedJSON string + TodosJSON string + TokenInputTotal int + TokenOutputTotal int + HasUnknownUsage bool + AgentMode string + CurrentPlanJSON string + LastFullPlanRevision int + PlanApprovalPendingFullAlign bool + PlanCompletionPendingFullReview bool + PlanContextDirty bool + PlanRestorePendingAlign bool } type sqliteMessageRow struct { @@ -161,8 +168,10 @@ func (s *SQLiteStore) CreateSession(ctx context.Context, input CreateSessionInpu INSERT INTO sessions ( id, title, created_at_ms, updated_at_ms, provider, model, workdir, task_state_json, todos_json, activated_skills_json, - token_input_total, token_output_total, has_unknown_usage, last_seq, message_count -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0) + token_input_total, token_output_total, has_unknown_usage, agent_mode, current_plan_json, last_full_plan_revision, + plan_approval_pending_full_align, plan_completion_pending_full_review, plan_context_dirty, plan_restore_pending_align, + last_seq, message_count +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0) `, session.ID, session.Title, @@ -177,6 +186,13 @@ INSERT INTO sessions ( session.TokenInputTotal, session.TokenOutputTotal, session.HasUnknownUsage, + string(NormalizeAgentMode(session.AgentMode)), + mustJSONPlanArtifact(session.CurrentPlan), + session.LastFullPlanRevision, + session.PlanApprovalPendingFullAlign, + session.PlanCompletionPendingFullReview, + session.PlanContextDirty, + session.PlanRestorePendingAlign, ) if err != nil { if isSQLiteSessionUniqueConstraintError(err) { @@ -403,7 +419,14 @@ SET title = ?, activated_skills_json = ?, token_input_total = ?, token_output_total = ?, - has_unknown_usage = ? + has_unknown_usage = ?, + agent_mode = ?, + current_plan_json = ?, + last_full_plan_revision = ?, + plan_approval_pending_full_align = ?, + plan_completion_pending_full_review = ?, + plan_context_dirty = ?, + plan_restore_pending_align = ? WHERE id = ? `, row.Title, @@ -417,6 +440,13 @@ WHERE id = ? row.TokenInputTotal, row.TokenOutputTotal, row.HasUnknownUsage, + row.AgentMode, + row.CurrentPlanJSON, + row.LastFullPlanRevision, + row.PlanApprovalPendingFullAlign, + row.PlanCompletionPendingFullReview, + row.PlanContextDirty, + row.PlanRestorePendingAlign, row.ID, ) if err != nil { @@ -472,6 +502,13 @@ SET updated_at_ms = ?, token_input_total = ?, token_output_total = ?, has_unknown_usage = ?, + agent_mode = ?, + current_plan_json = ?, + last_full_plan_revision = ?, + plan_approval_pending_full_align = ?, + plan_completion_pending_full_review = ?, + plan_context_dirty = ?, + plan_restore_pending_align = ?, last_seq = ?, message_count = ? WHERE id = ? @@ -486,6 +523,13 @@ WHERE id = ? row.TokenInputTotal, row.TokenOutputTotal, row.HasUnknownUsage, + row.AgentMode, + row.CurrentPlanJSON, + row.LastFullPlanRevision, + row.PlanApprovalPendingFullAlign, + row.PlanCompletionPendingFullReview, + row.PlanContextDirty, + row.PlanRestorePendingAlign, lastSeq, len(messages), row.ID, @@ -809,6 +853,36 @@ func initializeSQLiteSchema(ctx context.Context, db *sql.DB) error { if err := migrateSQLiteSchemaV1ToV2(ctx, db); err != nil { return err } + if err := migrateSQLiteSchemaV2ToV3(ctx, db); err != nil { + return err + } + if err := migrateSQLiteSchemaV3ToV4(ctx, db); err != nil { + return err + } + if err := migrateSQLiteSchemaV4ToV5(ctx, db); err != nil { + return err + } + case 2: + if err := migrateSQLiteSchemaV2ToV3(ctx, db); err != nil { + return err + } + if err := migrateSQLiteSchemaV3ToV4(ctx, db); err != nil { + return err + } + if err := migrateSQLiteSchemaV4ToV5(ctx, db); err != nil { + return err + } + case 3: + if err := migrateSQLiteSchemaV3ToV4(ctx, db); err != nil { + return err + } + if err := migrateSQLiteSchemaV4ToV5(ctx, db); err != nil { + return err + } + case 4: + if err := migrateSQLiteSchemaV4ToV5(ctx, db); err != nil { + return err + } default: return fmt.Errorf("session: unsupported sqlite schema version %d", userVersion) } @@ -834,6 +908,13 @@ func initializeSQLiteSchema(ctx context.Context, db *sql.DB) error { token_input_total INTEGER NOT NULL DEFAULT 0, token_output_total INTEGER NOT NULL DEFAULT 0, has_unknown_usage INTEGER NOT NULL DEFAULT 0, + agent_mode TEXT NOT NULL DEFAULT 'build', + current_plan_json TEXT NOT NULL DEFAULT '', + last_full_plan_revision INTEGER NOT NULL DEFAULT 0, + plan_approval_pending_full_align INTEGER NOT NULL DEFAULT 0, + plan_completion_pending_full_review INTEGER NOT NULL DEFAULT 0, + plan_context_dirty INTEGER NOT NULL DEFAULT 0, + plan_restore_pending_align INTEGER NOT NULL DEFAULT 0, last_seq INTEGER NOT NULL DEFAULT 0, message_count INTEGER NOT NULL DEFAULT 0 )`, @@ -905,6 +986,121 @@ func migrateSQLiteSchemaV1ToV2(ctx context.Context, db *sql.DB) error { } // sqliteTableHasColumn 检查指定表是否包含字段,供明确版本迁移保持幂等。 +// migrateSQLiteSchemaV2ToV3 将 v2 会话库升级到 v3 schema,补齐 plan/build 所需字段。 +func migrateSQLiteSchemaV2ToV3(ctx context.Context, db *sql.DB) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("session: begin schema migration tx: %w", err) + } + defer rollbackTx(tx) + + hasModeColumn, err := sqliteTableHasColumn(ctx, tx, "sessions", "agent_mode") + if err != nil { + return err + } + if !hasModeColumn { + if _, err := tx.ExecContext(ctx, `ALTER TABLE sessions ADD COLUMN agent_mode TEXT NOT NULL DEFAULT 'build'`); err != nil { + return fmt.Errorf("session: migrate sqlite schema v2 to v3 add agent_mode: %w", err) + } + } + + hasPlanColumn, err := sqliteTableHasColumn(ctx, tx, "sessions", "current_plan_json") + if err != nil { + return err + } + if !hasPlanColumn { + if _, err := tx.ExecContext(ctx, `ALTER TABLE sessions ADD COLUMN current_plan_json TEXT NOT NULL DEFAULT ''`); err != nil { + return fmt.Errorf("session: migrate sqlite schema v2 to v3 add current_plan_json: %w", err) + } + } + + if _, err := tx.ExecContext(ctx, fmt.Sprintf(`PRAGMA user_version=%d`, sqliteSchemaVersion)); err != nil { + return fmt.Errorf("session: set migrated sqlite schema version: %w", err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("session: commit schema migration tx: %w", err) + } + return nil +} + +// migrateSQLiteSchemaV3ToV4 将 v3 会话库升级到 v4 schema,补齐完整计划对齐所需字段。 +func migrateSQLiteSchemaV3ToV4(ctx context.Context, db *sql.DB) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("session: begin schema migration tx: %w", err) + } + defer rollbackTx(tx) + + hasRevisionColumn, err := sqliteTableHasColumn(ctx, tx, "sessions", "last_full_plan_revision") + if err != nil { + return err + } + if !hasRevisionColumn { + if _, err := tx.ExecContext(ctx, `ALTER TABLE sessions ADD COLUMN last_full_plan_revision INTEGER NOT NULL DEFAULT 0`); err != nil { + return fmt.Errorf("session: migrate sqlite schema v3 to v4 add last_full_plan_revision: %w", err) + } + } + + if _, err := tx.ExecContext(ctx, fmt.Sprintf(`PRAGMA user_version=%d`, sqliteSchemaVersion)); err != nil { + return fmt.Errorf("session: set migrated sqlite schema version: %w", err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("session: commit schema migration tx: %w", err) + } + return nil +} + +// migrateSQLiteSchemaV4ToV5 将 v4 会话库升级到 v5 schema,补齐计划全文注入对齐状态字段。 +func migrateSQLiteSchemaV4ToV5(ctx context.Context, db *sql.DB) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("session: begin schema migration tx: %w", err) + } + defer rollbackTx(tx) + + columns := []struct { + name string + sql string + }{ + { + name: "plan_approval_pending_full_align", + sql: `ALTER TABLE sessions ADD COLUMN plan_approval_pending_full_align INTEGER NOT NULL DEFAULT 0`, + }, + { + name: "plan_completion_pending_full_review", + sql: `ALTER TABLE sessions ADD COLUMN plan_completion_pending_full_review INTEGER NOT NULL DEFAULT 0`, + }, + { + name: "plan_context_dirty", + sql: `ALTER TABLE sessions ADD COLUMN plan_context_dirty INTEGER NOT NULL DEFAULT 0`, + }, + { + name: "plan_restore_pending_align", + sql: `ALTER TABLE sessions ADD COLUMN plan_restore_pending_align INTEGER NOT NULL DEFAULT 0`, + }, + } + for _, column := range columns { + hasColumn, err := sqliteTableHasColumn(ctx, tx, "sessions", column.name) + if err != nil { + return err + } + if hasColumn { + continue + } + if _, err := tx.ExecContext(ctx, column.sql); err != nil { + return fmt.Errorf("session: migrate sqlite schema v4 to v5 add %s: %w", column.name, err) + } + } + + if _, err := tx.ExecContext(ctx, fmt.Sprintf(`PRAGMA user_version=%d`, sqliteSchemaVersion)); err != nil { + return fmt.Errorf("session: set migrated sqlite schema version: %w", err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("session: commit schema migration tx: %w", err) + } + return nil +} + func sqliteTableHasColumn(ctx context.Context, tx *sql.Tx, table string, column string) (bool, error) { rows, err := tx.QueryContext(ctx, `PRAGMA table_info(`+table+`)`) if err != nil { @@ -957,6 +1153,12 @@ func normalizeCreateSessionInput(input CreateSessionInput) (Session, error) { } head := input.Head.clone() head.applyToSession(&session) + session.AgentMode = NormalizeAgentMode(session.AgentMode) + currentPlan, err := NormalizePlanArtifact(session.CurrentPlan) + if err != nil { + return Session{}, err + } + session.CurrentPlan = currentPlan todos, err := normalizeAndValidateTodos(head.Todos) if err != nil { return Session{}, err @@ -979,18 +1181,25 @@ func normalizeUpdateSessionStateInput(input UpdateSessionStateInput) (sqliteSess return sqliteSessionRow{}, err } return sqliteSessionRow{ - ID: stringsTrimSpace(input.SessionID), - Title: sanitizeTitle(input.Title), - Provider: stringsTrimSpace(head.Provider), - Model: stringsTrimSpace(head.Model), - UpdatedAtMS: toUnixMillis(resolveUpdatedAt(input.UpdatedAt)), - Workdir: stringsTrimSpace(head.Workdir), - TaskStateJSON: mustJSONString(normalizeAndClampTaskState(head.TaskState)), - TodosJSON: mustJSONString(todos), - ActivatedJSON: mustJSONString(normalizeSkillActivations(head.ActivatedSkills)), - TokenInputTotal: head.TokenInputTotal, - TokenOutputTotal: head.TokenOutputTotal, - HasUnknownUsage: head.HasUnknownUsage, + ID: stringsTrimSpace(input.SessionID), + Title: sanitizeTitle(input.Title), + Provider: stringsTrimSpace(head.Provider), + Model: stringsTrimSpace(head.Model), + UpdatedAtMS: toUnixMillis(resolveUpdatedAt(input.UpdatedAt)), + Workdir: stringsTrimSpace(head.Workdir), + TaskStateJSON: mustJSONString(normalizeAndClampTaskState(head.TaskState)), + TodosJSON: mustJSONString(todos), + ActivatedJSON: mustJSONString(normalizeSkillActivations(head.ActivatedSkills)), + TokenInputTotal: head.TokenInputTotal, + TokenOutputTotal: head.TokenOutputTotal, + HasUnknownUsage: head.HasUnknownUsage, + AgentMode: string(NormalizeAgentMode(head.AgentMode)), + CurrentPlanJSON: mustJSONPlanArtifact(head.CurrentPlan), + LastFullPlanRevision: head.LastFullPlanRevision, + PlanApprovalPendingFullAlign: head.PlanApprovalPendingFullAlign, + PlanCompletionPendingFullReview: head.PlanCompletionPendingFullReview, + PlanContextDirty: head.PlanContextDirty, + PlanRestorePendingAlign: head.PlanRestorePendingAlign, }, nil } @@ -1032,7 +1241,9 @@ func loadSessionRow(ctx context.Context, tx *sql.Tx, sessionID string) (sqliteSe var row sqliteSessionRow err := tx.QueryRowContext(ctx, ` SELECT id, title, provider, model, created_at_ms, updated_at_ms, workdir, - task_state_json, activated_skills_json, todos_json, token_input_total, token_output_total, has_unknown_usage + task_state_json, activated_skills_json, todos_json, token_input_total, token_output_total, has_unknown_usage, + agent_mode, current_plan_json, last_full_plan_revision, plan_approval_pending_full_align, + plan_completion_pending_full_review, plan_context_dirty, plan_restore_pending_align FROM sessions WHERE id = ? `, @@ -1051,6 +1262,13 @@ WHERE id = ? &row.TokenInputTotal, &row.TokenOutputTotal, &row.HasUnknownUsage, + &row.AgentMode, + &row.CurrentPlanJSON, + &row.LastFullPlanRevision, + &row.PlanApprovalPendingFullAlign, + &row.PlanCompletionPendingFullReview, + &row.PlanContextDirty, + &row.PlanRestorePendingAlign, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -1115,21 +1333,32 @@ func buildSessionFromRow(row sqliteSessionRow, messages []sqliteMessageRow) (Ses if err != nil { return Session{}, err } + currentPlan, err := unmarshalPlanArtifactJSON(row.CurrentPlanJSON) + if err != nil { + return Session{}, fmt.Errorf("session: decode current_plan for %s: %w", row.ID, err) + } result := Session{ - ID: row.ID, - Title: row.Title, - Provider: row.Provider, - Model: row.Model, - CreatedAt: fromUnixMillis(row.CreatedAtMS), - UpdatedAt: fromUnixMillis(row.UpdatedAtMS), - Workdir: row.Workdir, - TaskState: normalizeAndClampTaskState(taskState), - ActivatedSkills: normalizeSkillActivations(activated), - Todos: normalizedTodos, - TokenInputTotal: row.TokenInputTotal, - TokenOutputTotal: row.TokenOutputTotal, - HasUnknownUsage: row.HasUnknownUsage, + ID: row.ID, + Title: row.Title, + Provider: row.Provider, + Model: row.Model, + CreatedAt: fromUnixMillis(row.CreatedAtMS), + UpdatedAt: fromUnixMillis(row.UpdatedAtMS), + Workdir: row.Workdir, + TaskState: normalizeAndClampTaskState(taskState), + ActivatedSkills: normalizeSkillActivations(activated), + Todos: normalizedTodos, + TokenInputTotal: row.TokenInputTotal, + TokenOutputTotal: row.TokenOutputTotal, + HasUnknownUsage: row.HasUnknownUsage, + AgentMode: NormalizeAgentMode(AgentMode(row.AgentMode)), + CurrentPlan: currentPlan, + LastFullPlanRevision: row.LastFullPlanRevision, + PlanApprovalPendingFullAlign: currentPlan != nil && row.PlanApprovalPendingFullAlign, + PlanCompletionPendingFullReview: currentPlan != nil && row.PlanCompletionPendingFullReview, + PlanContextDirty: currentPlan != nil && row.PlanContextDirty, + PlanRestorePendingAlign: currentPlan != nil && row.PlanRestorePendingAlign, } if len(result.Todos) > 0 { result.TodoVersion = CurrentTodoVersion @@ -1434,6 +1663,7 @@ func cloneSessionValue(session Session) Session { cloned.TaskState = session.TaskState.Clone() cloned.ActivatedSkills = cloneSkillActivations(session.ActivatedSkills) cloned.Todos = session.ListTodos() + cloned.CurrentPlan = session.CurrentPlan.Clone() if len(session.Messages) > 0 { cloned.Messages = make([]providertypes.Message, len(session.Messages)) for idx, message := range session.Messages { @@ -1464,6 +1694,35 @@ func mustJSONString(value any) string { return string(data) } +// mustJSONPlanArtifact 将计划对象编码为 JSON 字符串;nil 计划统一编码为空字符串。 +func mustJSONPlanArtifact(plan *PlanArtifact) string { + normalized, err := NormalizePlanArtifact(plan) + if err != nil { + panic(err) + } + if normalized == nil { + return "" + } + data, err := json.Marshal(normalized) + if err != nil { + panic(err) + } + return string(data) +} + +// unmarshalPlanArtifactJSON 将持久化字符串解码回计划对象;空串视为当前无计划。 +func unmarshalPlanArtifactJSON(raw string) (*PlanArtifact, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return nil, nil + } + var plan PlanArtifact + if err := json.Unmarshal([]byte(trimmed), &plan); err != nil { + return nil, err + } + return NormalizePlanArtifact(&plan) +} + // resolveUpdatedAt 统一为写入选择更新时间,缺省时使用当前时间。 func resolveUpdatedAt(value time.Time) time.Time { if value.IsZero() { diff --git a/internal/session/store.go b/internal/session/store.go index 95c74232..7dd97377 100644 --- a/internal/session/store.go +++ b/internal/session/store.go @@ -14,7 +14,7 @@ import ( const ( sessionDatabaseFileName = "session.db" assetsDirName = "assets" - sqliteSchemaVersion = 2 + sqliteSchemaVersion = 5 // MaxSessionMessages 定义单个会话允许持久化的最大消息数,超出时自动裁剪最旧消息。 MaxSessionMessages = 8192 @@ -33,34 +33,48 @@ var ErrSessionAlreadyExists = errors.New("session: session already exists") // Session 表示单个会话的运行态与持久化聚合模型。 type Session struct { - ID string - Title string - Provider string - Model string - CreatedAt time.Time - UpdatedAt time.Time - Workdir string - TaskState TaskState - ActivatedSkills []SkillActivation - TodoVersion int - Todos []TodoItem - Messages []providertypes.Message - TokenInputTotal int - TokenOutputTotal int - HasUnknownUsage bool + ID string + Title string + Provider string + Model string + CreatedAt time.Time + UpdatedAt time.Time + Workdir string + TaskState TaskState + ActivatedSkills []SkillActivation + TodoVersion int + Todos []TodoItem + Messages []providertypes.Message + TokenInputTotal int + TokenOutputTotal int + HasUnknownUsage bool + AgentMode AgentMode + CurrentPlan *PlanArtifact + LastFullPlanRevision int + PlanApprovalPendingFullAlign bool + PlanCompletionPendingFullReview bool + PlanContextDirty bool + PlanRestorePendingAlign bool } // SessionHead 表示可独立持久化、可整体替换的会话头状态快照。 type SessionHead struct { - Provider string - Model string - Workdir string - TaskState TaskState - ActivatedSkills []SkillActivation - Todos []TodoItem - TokenInputTotal int - TokenOutputTotal int - HasUnknownUsage bool + Provider string + Model string + Workdir string + TaskState TaskState + ActivatedSkills []SkillActivation + Todos []TodoItem + TokenInputTotal int + TokenOutputTotal int + HasUnknownUsage bool + AgentMode AgentMode + CurrentPlan *PlanArtifact + LastFullPlanRevision int + PlanApprovalPendingFullAlign bool + PlanCompletionPendingFullReview bool + PlanContextDirty bool + PlanRestorePendingAlign bool } // Summary 表示会话列表视图需要的轻量摘要。 @@ -157,36 +171,51 @@ func NewWithWorkdir(title string, workdir string) Session { ActivatedSkills: []SkillActivation{}, Todos: []TodoItem{}, Messages: []providertypes.Message{}, + AgentMode: AgentModeBuild, } } // HeadSnapshot 返回当前会话头状态的深拷贝,用于持久化输入与跨层传递。 func (s Session) HeadSnapshot() SessionHead { return SessionHead{ - Provider: strings.TrimSpace(s.Provider), - Model: strings.TrimSpace(s.Model), - Workdir: strings.TrimSpace(s.Workdir), - TaskState: s.TaskState.Clone(), - ActivatedSkills: cloneSkillActivations(s.ActivatedSkills), - Todos: cloneTodoItems(s.Todos), - TokenInputTotal: s.TokenInputTotal, - TokenOutputTotal: s.TokenOutputTotal, - HasUnknownUsage: s.HasUnknownUsage, + Provider: strings.TrimSpace(s.Provider), + Model: strings.TrimSpace(s.Model), + Workdir: strings.TrimSpace(s.Workdir), + TaskState: s.TaskState.Clone(), + ActivatedSkills: cloneSkillActivations(s.ActivatedSkills), + Todos: cloneTodoItems(s.Todos), + TokenInputTotal: s.TokenInputTotal, + TokenOutputTotal: s.TokenOutputTotal, + HasUnknownUsage: s.HasUnknownUsage, + AgentMode: NormalizeAgentMode(s.AgentMode), + CurrentPlan: s.CurrentPlan.Clone(), + LastFullPlanRevision: s.LastFullPlanRevision, + PlanApprovalPendingFullAlign: s.CurrentPlan != nil && s.PlanApprovalPendingFullAlign, + PlanCompletionPendingFullReview: s.CurrentPlan != nil && s.PlanCompletionPendingFullReview, + PlanContextDirty: s.CurrentPlan != nil && s.PlanContextDirty, + PlanRestorePendingAlign: s.CurrentPlan != nil && s.PlanRestorePendingAlign, } } // clone 返回会话头状态的深拷贝,避免跨层共享底层切片。 func (h SessionHead) clone() SessionHead { return SessionHead{ - Provider: strings.TrimSpace(h.Provider), - Model: strings.TrimSpace(h.Model), - Workdir: strings.TrimSpace(h.Workdir), - TaskState: h.TaskState.Clone(), - ActivatedSkills: cloneSkillActivations(h.ActivatedSkills), - Todos: cloneTodoItems(h.Todos), - TokenInputTotal: h.TokenInputTotal, - TokenOutputTotal: h.TokenOutputTotal, - HasUnknownUsage: h.HasUnknownUsage, + Provider: strings.TrimSpace(h.Provider), + Model: strings.TrimSpace(h.Model), + Workdir: strings.TrimSpace(h.Workdir), + TaskState: h.TaskState.Clone(), + ActivatedSkills: cloneSkillActivations(h.ActivatedSkills), + Todos: cloneTodoItems(h.Todos), + TokenInputTotal: h.TokenInputTotal, + TokenOutputTotal: h.TokenOutputTotal, + HasUnknownUsage: h.HasUnknownUsage, + AgentMode: NormalizeAgentMode(h.AgentMode), + CurrentPlan: h.CurrentPlan.Clone(), + LastFullPlanRevision: h.LastFullPlanRevision, + PlanApprovalPendingFullAlign: h.CurrentPlan != nil && h.PlanApprovalPendingFullAlign, + PlanCompletionPendingFullReview: h.CurrentPlan != nil && h.PlanCompletionPendingFullReview, + PlanContextDirty: h.CurrentPlan != nil && h.PlanContextDirty, + PlanRestorePendingAlign: h.CurrentPlan != nil && h.PlanRestorePendingAlign, } } @@ -205,6 +234,13 @@ func (h SessionHead) applyToSession(session *Session) { session.TokenInputTotal = cloned.TokenInputTotal session.TokenOutputTotal = cloned.TokenOutputTotal session.HasUnknownUsage = cloned.HasUnknownUsage + session.AgentMode = cloned.AgentMode + session.CurrentPlan = cloned.CurrentPlan + session.LastFullPlanRevision = cloned.LastFullPlanRevision + session.PlanApprovalPendingFullAlign = cloned.PlanApprovalPendingFullAlign + session.PlanCompletionPendingFullReview = cloned.PlanCompletionPendingFullReview + session.PlanContextDirty = cloned.PlanContextDirty + session.PlanRestorePendingAlign = cloned.PlanRestorePendingAlign } // cloneTodoItems 深拷贝 Todo 列表,避免会话头快照共享底层切片。 diff --git a/internal/session/store_test.go b/internal/session/store_test.go index f08411cd..dd634a25 100644 --- a/internal/session/store_test.go +++ b/internal/session/store_test.go @@ -537,6 +537,101 @@ func TestSQLiteStoreMigratesSchemaV1ToV2WhenColumnAlreadyExists(t *testing.T) { assertSQLiteColumnExists(t, db, "sessions", "has_unknown_usage") } +func TestSQLiteStorePersistsPlanStateRoundTrip(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + + session, err := store.CreateSession(ctx, CreateSessionInput{ + ID: "session_plan_roundtrip", + Title: "Plan Roundtrip", + Head: SessionHead{ + Provider: "openai", + Model: "gpt-5", + Workdir: "/repo", + AgentMode: AgentModePlan, + LastFullPlanRevision: 2, + PlanApprovalPendingFullAlign: true, + PlanCompletionPendingFullReview: true, + PlanContextDirty: true, + PlanRestorePendingAlign: true, + CurrentPlan: &PlanArtifact{ + ID: "plan-1", + Revision: 2, + Status: PlanStatusDraft, + Spec: PlanSpec{ + Goal: "落地 plan/build 模式", + Steps: []string{"扩展 session", "扩展 runtime"}, + Constraints: []string{"保持 tools 边界"}, + Verify: []string{"go test ./internal/..."}, + Todos: []TodoItem{ + {ID: "todo-plan-1", Content: "补 plan 模型"}, + }, + }, + Summary: SummaryView{ + Goal: "落地 plan/build 模式", + KeySteps: []string{"扩展 session", "扩展 runtime"}, + Constraints: []string{"保持 tools 边界"}, + Verify: []string{"go test ./internal/..."}, + ActiveTodoIDs: []string{"todo-plan-1"}, + }, + }, + }, + }) + if err != nil { + t.Fatalf("CreateSession() error = %v", err) + } + + loaded, err := store.LoadSession(ctx, session.ID) + if err != nil { + t.Fatalf("LoadSession() error = %v", err) + } + if loaded.AgentMode != AgentModePlan { + t.Fatalf("expected agent mode plan, got %q", loaded.AgentMode) + } + if loaded.CurrentPlan == nil { + t.Fatal("expected current plan to be persisted") + } + if loaded.CurrentPlan.ID != "plan-1" || loaded.CurrentPlan.Revision != 2 { + t.Fatalf("unexpected loaded current plan: %+v", loaded.CurrentPlan) + } + if loaded.CurrentPlan.Summary.Goal != "落地 plan/build 模式" { + t.Fatalf("unexpected loaded summary: %+v", loaded.CurrentPlan.Summary) + } + if !loaded.PlanApprovalPendingFullAlign || !loaded.PlanCompletionPendingFullReview || + !loaded.PlanContextDirty || !loaded.PlanRestorePendingAlign { + t.Fatalf("expected plan alignment flags to round-trip, got %+v", loaded) + } +} + +func TestSQLiteStoreMigratesSchemaV2ToV3(t *testing.T) { + ctx := context.Background() + baseDir, workspaceRoot, store := newMigrationTestStore(t) + + createLegacyV2SessionDB(t, ctx, baseDir, workspaceRoot) + loaded, err := store.LoadSession(ctx, "session_v2") + if err != nil { + t.Fatalf("LoadSession() after migration error = %v", err) + } + if loaded.AgentMode != AgentModeBuild { + t.Fatalf("expected migrated AgentMode to default build, got %q", loaded.AgentMode) + } + if loaded.CurrentPlan != nil { + t.Fatalf("expected migrated CurrentPlan to default nil, got %+v", loaded.CurrentPlan) + } + + db, err := store.ensureDB(ctx) + if err != nil { + t.Fatalf("ensureDB() error = %v", err) + } + assertPragmaInt(t, db, "user_version", sqliteSchemaVersion) + assertSQLiteColumnExists(t, db, "sessions", "agent_mode") + assertSQLiteColumnExists(t, db, "sessions", "current_plan_json") + assertSQLiteColumnExists(t, db, "sessions", "plan_approval_pending_full_align") + assertSQLiteColumnExists(t, db, "sessions", "plan_completion_pending_full_review") + assertSQLiteColumnExists(t, db, "sessions", "plan_context_dirty") + assertSQLiteColumnExists(t, db, "sessions", "plan_restore_pending_align") +} + func assertPragmaString(t *testing.T, db *sql.DB, name string, want string) { t.Helper() var got string @@ -691,6 +786,76 @@ func createLegacyV1SessionDB( } } +func createLegacyV2SessionDB(t *testing.T, ctx context.Context, baseDir string, workspaceRoot string) { + t.Helper() + projectDir := projectDirectory(baseDir, workspaceRoot) + if err := os.MkdirAll(projectDir, 0o755); err != nil { + t.Fatalf("MkdirAll(projectDir) error = %v", err) + } + db, err := sql.Open("sqlite", databasePath(baseDir, workspaceRoot)) + if err != nil { + t.Fatalf("sql.Open() error = %v", err) + } + defer db.Close() + + statements := []string{ + `CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + provider TEXT NOT NULL DEFAULT '', + model TEXT NOT NULL DEFAULT '', + workdir TEXT NOT NULL DEFAULT '', + task_state_json TEXT NOT NULL, + todos_json TEXT NOT NULL, + activated_skills_json TEXT NOT NULL, + token_input_total INTEGER NOT NULL DEFAULT 0, + token_output_total INTEGER NOT NULL DEFAULT 0, + has_unknown_usage INTEGER NOT NULL DEFAULT 0, + last_seq INTEGER NOT NULL DEFAULT 0, + message_count INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE messages ( + session_id TEXT NOT NULL, + seq INTEGER NOT NULL, + role TEXT NOT NULL, + parts_json TEXT NOT NULL, + tool_calls_json TEXT NOT NULL DEFAULT '', + tool_call_id TEXT NOT NULL DEFAULT '', + is_error INTEGER NOT NULL DEFAULT 0, + tool_metadata_json TEXT NOT NULL DEFAULT '', + created_at_ms INTEGER NOT NULL, + PRIMARY KEY(session_id, seq), + FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE + )`, + `CREATE TABLE session_assets ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + mime_type TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + relative_path TEXT NOT NULL, + created_at_ms INTEGER NOT NULL, + FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE + )`, + `INSERT INTO sessions ( + id, title, created_at_ms, updated_at_ms, provider, model, workdir, + task_state_json, todos_json, activated_skills_json, + token_input_total, token_output_total, has_unknown_usage, + last_seq, message_count + ) VALUES ( + 'session_v2', 'Legacy V2', 1000, 1000, 'openai', 'gpt-5', '/repo', + '{}', '[]', '[]', 11, 7, 0, 0, 0 + )`, + `PRAGMA user_version=2`, + } + for _, statement := range statements { + if _, err := db.ExecContext(ctx, statement); err != nil { + t.Fatalf("exec legacy schema statement: %v\n%s", err, statement) + } + } +} + func renderSessionMessageParts(message providertypes.Message) string { if len(message.Parts) == 0 { return "" diff --git a/internal/tools/manager.go b/internal/tools/manager.go index cf174169..c444b53c 100644 --- a/internal/tools/manager.go +++ b/internal/tools/manager.go @@ -20,6 +20,8 @@ type SpecListInput struct { SessionID string Agent string Query string + Mode string + ReadOnly bool } // Manager is the runtime-facing tool execution and schema exposure boundary. @@ -295,7 +297,20 @@ func (m *DefaultManager) ListAvailableSpecs(ctx context.Context, input SpecListI if m == nil || m.executor == nil { return nil, errors.New("tools: manager executor is nil") } - return m.executor.ListAvailableSpecs(ctx, input) + specs, err := m.executor.ListAvailableSpecs(ctx, input) + if err != nil { + return nil, err + } + if !input.ReadOnly { + return specs, nil + } + filtered := make([]providertypes.ToolSpec, 0, len(specs)) + for _, spec := range specs { + if isReadOnlyVisibleTool(spec.Name) { + filtered = append(filtered, spec) + } + } + return filtered, nil } // MicroCompactPolicy 返回工具的 micro compact 策略;无法判断时按默认可压缩处理。 @@ -337,6 +352,12 @@ func (m *DefaultManager) Execute(ctx context.Context, input ToolCallInput) (Tool result.ToolCallID = input.ID return result, err } + if input.ReadOnly && !isReadOnlyActionAllowed(action) { + err := fmt.Errorf("tools: tool %q is not available in read-only mode", strings.TrimSpace(input.Name)) + result := NewErrorResult(input.Name, "tool blocked in read-only mode", err.Error(), actionMetadata(action)) + result.ToolCallID = input.ID + return result, err + } if err := m.verifyCapabilityToken(action); err != nil { decision := capabilityDenyDecision(action, err.Error()) m.auditCapabilityDecision(action, string(decision.Decision), decision.Reason) diff --git a/internal/tools/manager_test.go b/internal/tools/manager_test.go index f1353d75..b3d0f4d8 100644 --- a/internal/tools/manager_test.go +++ b/internal/tools/manager_test.go @@ -105,6 +105,31 @@ func TestDefaultManagerListAvailableSpecs(t *testing.T) { } } +func TestDefaultManagerListAvailableSpecsReadOnlyFiltersWriteTools(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + registry.Register(&managerStubTool{name: ToolNameFilesystemReadFile}) + registry.Register(&managerStubTool{name: ToolNameFilesystemWriteFile}) + registry.Register(&managerStubTool{name: ToolNameBash}) + + manager, err := NewManager(registry, mustAllowEngine(t), nil) + if err != nil { + t.Fatalf("new manager: %v", err) + } + + specs, err := manager.ListAvailableSpecs(context.Background(), SpecListInput{ + SessionID: "s-1", + ReadOnly: true, + }) + if err != nil { + t.Fatalf("list specs: %v", err) + } + if len(specs) != 1 || specs[0].Name != ToolNameFilesystemReadFile { + t.Fatalf("unexpected read-only specs: %+v", specs) + } +} + func TestDefaultManagerMicroCompactPolicy(t *testing.T) { t.Parallel() @@ -412,6 +437,35 @@ func TestDefaultManagerExecute(t *testing.T) { } } +func TestDefaultManagerExecuteBlocksWriteToolInReadOnlyMode(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + writeTool := &managerStubTool{name: ToolNameFilesystemWriteFile, content: "ok"} + registry.Register(writeTool) + + manager, err := NewManager(registry, mustAllowEngine(t), nil) + if err != nil { + t.Fatalf("new manager: %v", err) + } + + result, execErr := manager.Execute(context.Background(), ToolCallInput{ + ID: "call-readonly-write", + Name: ToolNameFilesystemWriteFile, + Arguments: []byte(`{"path":"note.txt","content":"hello"}`), + ReadOnly: true, + }) + if execErr == nil || !strings.Contains(execErr.Error(), "read-only mode") { + t.Fatalf("expected read-only mode error, got %v", execErr) + } + if !strings.Contains(result.Content, "read-only mode") { + t.Fatalf("expected tool result to mention read-only mode, got %q", result.Content) + } + if writeTool.callCount != 0 { + t.Fatalf("expected write tool not to execute, got %d", writeTool.callCount) + } +} + func TestDefaultManagerSandboxOutsideWriteSessionMemory(t *testing.T) { t.Parallel() diff --git a/internal/tools/mode_filter.go b/internal/tools/mode_filter.go new file mode 100644 index 00000000..1c331fdb --- /dev/null +++ b/internal/tools/mode_filter.go @@ -0,0 +1,27 @@ +package tools + +import ( + "strings" + + "neo-code/internal/security" +) + +// isReadOnlyVisibleTool 判断工具在只读阶段是否可见。 +func isReadOnlyVisibleTool(name string) bool { + switch strings.ToLower(strings.TrimSpace(name)) { + case ToolNameFilesystemReadFile, + ToolNameFilesystemGrep, + ToolNameFilesystemGlob, + ToolNameWebFetch, + ToolNameMemoRecall, + ToolNameMemoList: + return true + default: + return false + } +} + +// isReadOnlyActionAllowed 判断当前权限动作是否属于只读阶段允许执行的范围。 +func isReadOnlyActionAllowed(action security.Action) bool { + return action.Type == security.ActionTypeRead +} diff --git a/internal/tools/types.go b/internal/tools/types.go index 786a0a26..7ca35c87 100644 --- a/internal/tools/types.go +++ b/internal/tools/types.go @@ -78,6 +78,7 @@ type ToolCallInput struct { TaskID string AgentID string Workdir string + ReadOnly bool CapabilityToken *security.CapabilityToken WorkspacePlan *security.WorkspaceExecutionPlan // SessionMutator 仅对需要会话级写入的工具开放(例如 todo_write)。 From fbdb124315b617f3832f7d14cd9eed596ba7b005 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Thu, 30 Apr 2026 11:57:31 +0800 Subject: [PATCH 2/3] =?UTF-8?q?pref(runtime):=E4=BF=AE=E5=A4=8D=E8=AE=A1?= =?UTF-8?q?=E5=88=92=E6=A0=BC=E5=BC=8F=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/runtime/compact_generator.go | 10 +- internal/runtime/planning.go | 119 ++++++++++++++++---- internal/runtime/planning_test.go | 118 ++++++++++++++++++-- internal/runtime/run.go | 2 +- internal/runtime/runtime_test.go | 122 +++++++++++++++++++++ internal/runtime/session_scheduler.go | 11 +- internal/runtime/session_scheduler_test.go | 63 +++++++++++ 7 files changed, 401 insertions(+), 44 deletions(-) create mode 100644 internal/runtime/session_scheduler_test.go diff --git a/internal/runtime/compact_generator.go b/internal/runtime/compact_generator.go index 7851f4df..d93bbb6e 100644 --- a/internal/runtime/compact_generator.go +++ b/internal/runtime/compact_generator.go @@ -249,6 +249,12 @@ func extractJSONObject(text string) (string, error) { // extractJSONObjectCandidate 从给定起点抽取平衡的 JSON 对象片段。 func extractJSONObjectCandidate(text string, start int) (string, error) { + candidate, _, err := extractJSONObjectCandidateRange(text, start) + return candidate, err +} + +// extractJSONObjectCandidateRange 从给定起点抽取平衡 JSON 对象,并返回原始结束位置。 +func extractJSONObjectCandidateRange(text string, start int) (string, int, error) { depth := 0 inString := false escaped := false @@ -276,10 +282,10 @@ func extractJSONObjectCandidate(text string, start int) (string, error) { case '}': depth-- if depth == 0 { - return strings.TrimSpace(text[start : index+1]), nil + return strings.TrimSpace(text[start : index+1]), index + 1, nil } } } - return "", errors.New("runtime: compact summary response contains an incomplete JSON object") + return "", 0, errors.New("runtime: compact summary response contains an incomplete JSON object") } diff --git a/internal/runtime/planning.go b/internal/runtime/planning.go index a8008755..3a788a99 100644 --- a/internal/runtime/planning.go +++ b/internal/runtime/planning.go @@ -28,6 +28,7 @@ type summaryCandidate struct { type planTurnOutput struct { PlanSpec agentsession.PlanSpec `json:"plan_spec"` SummaryCandidate summaryCandidate `json:"summary_candidate"` + DisplayText string `json:"-"` } type taskCompletionSignal struct { @@ -129,22 +130,15 @@ func maybeParsePlanTurnOutput(message providertypes.Message) (planTurnOutput, bo if text == "" { return planTurnOutput{}, false, nil } - jsonText, ok, err := extractPlanningJSONObjectIfPresent(text) - if err != nil { - return planTurnOutput{}, false, err - } + candidate, ok := extractPlanningJSONObjectIfPresent(text, "plan_spec") if !ok { return planTurnOutput{}, false, nil } - var output planTurnOutput - if err := json.Unmarshal([]byte(jsonText), &output); err != nil { - return planTurnOutput{}, false, fmt.Errorf("runtime: decode planning json: %w", err) - } - spec, err := agentsession.NormalizePlanSpec(output.PlanSpec) + output, err := decodePlanTurnOutput(candidate.Text) if err != nil { - return planTurnOutput{}, false, err + return planTurnOutput{}, false, nil } - output.PlanSpec = spec + output.DisplayText = stripPlanningJSONObjectText(text, candidate) return output, true, nil } @@ -154,30 +148,86 @@ func maybeParseCompletionTurnOutput(message providertypes.Message) (bool, error) if text == "" || !strings.Contains(text, `"task_completion"`) { return false, nil } - jsonText, ok, err := extractPlanningJSONObjectIfPresent(text) - if err != nil { - return false, err - } + candidate, ok := extractPlanningJSONObjectIfPresent(text, "task_completion") if !ok { return false, nil } var output completionTurnOutput - if err := json.Unmarshal([]byte(jsonText), &output); err != nil { - return false, fmt.Errorf("runtime: decode completion json: %w", err) + if err := json.Unmarshal([]byte(candidate.Text), &output); err != nil { + return false, nil } return output.TaskCompletion.Completed, nil } // extractPlanningJSONObjectIfPresent 在文本中提取首个配平的 JSON 对象。 -func extractPlanningJSONObjectIfPresent(text string) (string, bool, error) { +type extractedPlanningJSONObject struct { + Text string + Start int + End int +} + +// decodePlanTurnOutput 按 planning 契约解析计划输出,并为摘要回退保留空间。 +func decodePlanTurnOutput(jsonText string) (planTurnOutput, error) { + var payload map[string]json.RawMessage + if err := json.Unmarshal([]byte(jsonText), &payload); err != nil { + return planTurnOutput{}, fmt.Errorf("runtime: decode planning json: %w", err) + } + + planSpecRaw, ok := payload["plan_spec"] + if !ok { + return planTurnOutput{}, fmt.Errorf("runtime: planning json missing plan_spec") + } + + var spec agentsession.PlanSpec + if err := json.Unmarshal(planSpecRaw, &spec); err != nil { + return planTurnOutput{}, fmt.Errorf("runtime: decode planning json plan_spec: %w", err) + } + spec, err := agentsession.NormalizePlanSpec(spec) + if err != nil { + return planTurnOutput{}, err + } + + output := planTurnOutput{PlanSpec: spec} + summaryRaw, ok := payload["summary_candidate"] + if !ok || len(summaryRaw) == 0 || string(summaryRaw) == "null" { + return output, nil + } + + var candidate summaryCandidate + if err := json.Unmarshal(summaryRaw, &candidate); err == nil { + output.SummaryCandidate = candidate + } + return output, nil +} + +// stripPlanningJSONObjectText 从原始回复中移除结构化 JSON,并尽量保留自然段落间距。 +func stripPlanningJSONObjectText(text string, candidate extractedPlanningJSONObject) string { + before := strings.TrimSpace(text[:candidate.Start]) + after := strings.TrimSpace(text[candidate.End:]) + switch { + case before == "": + return after + case after == "": + return before + default: + return strings.TrimSpace(before + "\n\n" + after) + } +} + +// extractPlanningJSONObjectIfPresent 在文本中提取首个满足指定顶层键契约的 JSON 对象。 +func extractPlanningJSONObjectIfPresent(text string, requiredKey string) (extractedPlanningJSONObject, bool) { start := strings.IndexByte(text, '{') if start < 0 { - return "", false, nil + return extractedPlanningJSONObject{}, false } for { - candidate, err := extractJSONObjectCandidate(text, start) - if err == nil { - return candidate, true, nil + candidate, end, err := extractJSONObjectCandidateRange(text, start) + if err == nil && jsonObjectContainsTopLevelKey(candidate, requiredKey) { + return extractedPlanningJSONObject{ + Text: candidate, + Start: start, + End: end, + }, true } next := strings.IndexByte(text[start+1:], '{') if next < 0 { @@ -185,7 +235,20 @@ func extractPlanningJSONObjectIfPresent(text string) (string, bool, error) { } start += next + 1 } - return "", false, fmt.Errorf("runtime: planning response does not contain a valid JSON object") + return extractedPlanningJSONObject{}, false +} + +// jsonObjectContainsTopLevelKey 判断候选 JSON 对象是否包含指定顶层键。 +func jsonObjectContainsTopLevelKey(text string, key string) bool { + if strings.TrimSpace(key) == "" { + return false + } + var payload map[string]json.RawMessage + if err := json.Unmarshal([]byte(text), &payload); err != nil { + return false + } + _, ok := payload[key] + return ok } func buildPlanArtifact(current *agentsession.PlanArtifact, output planTurnOutput) (*agentsession.PlanArtifact, error) { @@ -224,6 +287,16 @@ func buildPlanArtifact(current *agentsession.PlanArtifact, output planTurnOutput } // applyCurrentPlanRevision 用新 revision 替换当前计划,并清理旧 revision 遗留的对齐状态。 +// resolvePlanDisplayText 优先保留模型对计划的额外说明文本,缺失时回退为规范计划正文。 +// resolvePlanDisplayText 优先保留模型对计划的额外说明文本,缺失时回退为规范计划正文。 +func resolvePlanDisplayText(output planTurnOutput, spec agentsession.PlanSpec) string { + display := strings.TrimSpace(output.DisplayText) + if display != "" { + return display + } + return strings.TrimSpace(agentsession.RenderPlanContent(spec)) +} + func applyCurrentPlanRevision(session *agentsession.Session, plan *agentsession.PlanArtifact) bool { if session == nil || plan == nil { return false diff --git a/internal/runtime/planning_test.go b/internal/runtime/planning_test.go index 1596bf6d..e18ad7ae 100644 --- a/internal/runtime/planning_test.go +++ b/internal/runtime/planning_test.go @@ -1,6 +1,7 @@ package runtime import ( + "reflect" "strings" "testing" "time" @@ -89,6 +90,9 @@ func TestMaybeParsePlanTurnOutput(t *testing.T) { if len(output.PlanSpec.Todos) != 1 || output.PlanSpec.Todos[0].ID != "todo-1" { t.Fatalf("PlanSpec.Todos = %+v", output.PlanSpec.Todos) } + if output.DisplayText != "" { + t.Fatalf("DisplayText = %q, want empty", output.DisplayText) + } } func TestMaybeParsePlanTurnOutputAllowsNaturalLanguage(t *testing.T) { @@ -106,6 +110,87 @@ func TestMaybeParsePlanTurnOutputAllowsNaturalLanguage(t *testing.T) { } } +func TestMaybeParsePlanTurnOutputIgnoresBraceTextAndKeepsExplanation(t *testing.T) { + t.Parallel() + + output, ok, err := maybeParsePlanTurnOutput(providertypes.Message{ + Role: providertypes.RoleAssistant, + Parts: []providertypes.ContentPart{ + providertypes.NewTextPart("We should avoid `{broken}` examples in docs.\n\n" + + "{\"plan_spec\":{\"goal\":\"ship plan\",\"steps\":[\"step\"],\"verify\":[\"test\"]}}\n\n" + + "Then execute the plan in small steps."), + }, + }) + if err != nil { + t.Fatalf("maybeParsePlanTurnOutput() error = %v", err) + } + if !ok { + t.Fatal("expected plan JSON to be detected") + } + want := "We should avoid `{broken}` examples in docs.\n\nThen execute the plan in small steps." + if output.DisplayText != want { + t.Fatalf("DisplayText = %q, want %q", output.DisplayText, want) + } +} + +func TestMaybeParsePlanTurnOutputFallsBackWhenSummaryIsInvalid(t *testing.T) { + t.Parallel() + + output, ok, err := maybeParsePlanTurnOutput(providertypes.Message{ + Role: providertypes.RoleAssistant, + Parts: []providertypes.ContentPart{providertypes.NewTextPart(`{ + "plan_spec": { + "goal": "ship plan", + "steps": ["step one", "step two"], + "verify": ["go test ./internal/runtime"], + "todos": [{"id":"todo-1","content":"step one","status":"pending"}] + }, + "summary_candidate": { + "goal": ["bad type"], + "key_steps": "invalid", + "verify": ["broken"], + "active_todo_ids": "todo-1" + } +}`)}, + }) + if err != nil { + t.Fatalf("maybeParsePlanTurnOutput() error = %v", err) + } + if !ok { + t.Fatal("expected plan JSON to be detected") + } + plan, err := buildPlanArtifact(nil, output) + if err != nil { + t.Fatalf("buildPlanArtifact() error = %v", err) + } + want := agentsession.BuildSummaryView(output.PlanSpec) + if !reflect.DeepEqual(plan.Summary, want) { + t.Fatalf("Summary = %+v, want %+v", plan.Summary, want) + } +} + +func TestMaybeParsePlanTurnOutputTreatsInvalidPlanSpecAsPlainText(t *testing.T) { + t.Parallel() + + output, ok, err := maybeParsePlanTurnOutput(providertypes.Message{ + Role: providertypes.RoleAssistant, + Parts: []providertypes.ContentPart{providertypes.NewTextPart(`{ + "plan_spec": { + "goal": "", + "steps": ["step"], + "verify": ["test"] + } +} +Explanation still continues.`)}, + }) + if err != nil { + t.Fatalf("maybeParsePlanTurnOutput() error = %v", err) + } + if ok { + t.Fatalf("expected invalid plan_spec to be ignored, got %+v", output) + } +} + func TestMaybeParseCompletionTurnOutput(t *testing.T) { t.Parallel() @@ -134,31 +219,40 @@ func TestMaybeParseCompletionTurnOutput(t *testing.T) { } } -func TestExtractPlanningJSONObjectIfPresent(t *testing.T) { +func TestMaybeParseCompletionTurnOutputIgnoresInvalidStructuredReply(t *testing.T) { t.Parallel() - text := "preface\n{\"plan_spec\":{\"goal\":\"x\",\"steps\":[\"s\"],\"verify\":[\"v\"]},\"summary_candidate\":{\"goal\":\"x\",\"key_steps\":[\"s\"],\"constraints\":[],\"verify\":[\"v\"],\"active_todo_ids\":[]}}\ntrailing" - got, ok, err := extractPlanningJSONObjectIfPresent(text) + completed, err := maybeParseCompletionTurnOutput(providertypes.Message{ + Role: providertypes.RoleAssistant, + Parts: []providertypes.ContentPart{providertypes.NewTextPart(`{"task_completion":"done"}`)}, + }) if err != nil { - t.Fatalf("extractPlanningJSONObjectIfPresent() error = %v", err) + t.Fatalf("maybeParseCompletionTurnOutput() error = %v", err) + } + if completed { + t.Fatal("expected invalid completion payload to be ignored") } +} + +func TestExtractPlanningJSONObjectIfPresent(t *testing.T) { + t.Parallel() + + text := "preface\n{\"example\":true}\n{\"plan_spec\":{\"goal\":\"x\",\"steps\":[\"s\"],\"verify\":[\"v\"]},\"summary_candidate\":{\"goal\":\"x\",\"key_steps\":[\"s\"],\"constraints\":[],\"verify\":[\"v\"],\"active_todo_ids\":[]}}\ntrailing" + got, ok := extractPlanningJSONObjectIfPresent(text, "plan_spec") if !ok { t.Fatalf("expected JSON object to be detected") } - if !strings.HasPrefix(got, "{") || !strings.Contains(got, "\"plan_spec\"") { - t.Fatalf("extractPlanningJSONObjectIfPresent() = %q", got) + if !strings.HasPrefix(got.Text, "{") || !strings.Contains(got.Text, "\"plan_spec\"") { + t.Fatalf("extractPlanningJSONObjectIfPresent() = %q", got.Text) } } func TestExtractPlanningJSONObjectIfPresentWithoutJSON(t *testing.T) { t.Parallel() - got, ok, err := extractPlanningJSONObjectIfPresent("plain text only") - if err != nil { - t.Fatalf("extractPlanningJSONObjectIfPresent() error = %v", err) - } - if ok || got != "" { - t.Fatalf("expected no JSON result, got ok=%v text=%q", ok, got) + got, ok := extractPlanningJSONObjectIfPresent("plain text only", "plan_spec") + if ok || got.Text != "" { + t.Fatalf("expected no JSON result, got ok=%v text=%q", ok, got.Text) } } diff --git a/internal/runtime/run.go b/internal/runtime/run.go index 9de226a3..2374e848 100644 --- a/internal/runtime/run.go +++ b/internal/runtime/run.go @@ -313,7 +313,7 @@ func (s *Service) Run(ctx context.Context, input UserInput) (err error) { planMessage := providertypes.Message{ Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{ - providertypes.NewTextPart(strings.TrimSpace(agentsession.RenderPlanContent(nextPlan.Spec))), + providertypes.NewTextPart(resolvePlanDisplayText(planOutput, nextPlan.Spec)), }, } if err := s.appendAssistantMessageOnlyAndSave(ctx, &state, planMessage); err != nil { diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 65e90ef1..02d5c4df 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -3554,6 +3554,128 @@ func TestServiceRunPlanModePersistsDraftPlan(t *testing.T) { } } +func TestServiceRunPlanModeShowsExplanationTextOutsidePlanningJSON(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + builder := &stubContextBuilder{} + scripted := &scriptedProvider{ + responses: []scriptedResponse{ + { + Message: providertypes.Message{ + Role: providertypes.RoleAssistant, + Parts: []providertypes.ContentPart{providertypes.NewTextPart(`先确认范围,再按下面计划推进。 + +{ + "plan_spec": { + "goal": "Preserve prose around planning JSON", + "steps": ["persist plan", "show explanation"], + "verify": ["go test ./internal/runtime"], + "todos": [{"id":"todo-plan-prose","content":"persist plan","status":"pending"}] + } +} + +我会先完成计划落库,再继续执行。`)}, + }, + FinishReason: "stop", + }, + }, + } + + service := NewWithFactory(manager, tools.NewRegistry(), store, &scriptedProviderFactory{provider: scripted}, builder) + if err := service.Run(context.Background(), UserInput{ + RunID: "run-plan-preserve-prose", + Mode: string(agentsession.AgentModePlan), + Parts: []providertypes.ContentPart{providertypes.NewTextPart("请先给出计划并解释")}, + }); err != nil { + t.Fatalf("Run() error = %v", err) + } + + saved := onlySession(t, store) + if saved.CurrentPlan == nil || saved.CurrentPlan.Spec.Goal != "Preserve prose around planning JSON" { + t.Fatalf("expected current plan to be updated, got %+v", saved.CurrentPlan) + } + if len(saved.Messages) != 2 { + t.Fatalf("message count = %d, want 2", len(saved.Messages)) + } + got := renderPartsForTest(saved.Messages[1].Parts) + if strings.Contains(got, "\"plan_spec\"") { + t.Fatalf("expected persisted assistant text to strip planning JSON, got %q", got) + } + if !strings.Contains(got, "先确认范围") || !strings.Contains(got, "继续执行") { + t.Fatalf("expected prose explanation to be preserved, got %q", got) + } +} + +func TestServiceRunPlanModeKeepsExistingPlanWhenPlanSpecIsInvalid(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + builder := &stubContextBuilder{} + seed := agentsession.New("invalid plan spec") + seed.AgentMode = agentsession.AgentModePlan + seed.CurrentPlan = &agentsession.PlanArtifact{ + ID: "plan-existing", + Revision: 2, + Status: agentsession.PlanStatusApproved, + Spec: agentsession.PlanSpec{ + Goal: "Keep previous plan", + Steps: []string{"existing step"}, + Verify: []string{"existing verify"}, + }, + Summary: agentsession.SummaryView{ + Goal: "Keep previous plan", + KeySteps: []string{"existing step"}, + Verify: []string{"existing verify"}, + }, + } + seed.LastFullPlanRevision = 2 + if _, err := store.CreateSession(context.Background(), createSessionInputFromSession(seed)); err != nil { + t.Fatalf("CreateSession() error = %v", err) + } + + scripted := &scriptedProvider{ + responses: []scriptedResponse{ + { + Message: providertypes.Message{ + Role: providertypes.RoleAssistant, + Parts: []providertypes.ContentPart{providertypes.NewTextPart(`{ + "plan_spec": { + "goal": "", + "steps": ["bad update"], + "verify": ["should be ignored"] + } +} + +这里先解释一下当前风险。`)}, + }, + FinishReason: "stop", + }, + }, + } + + service := NewWithFactory(manager, tools.NewRegistry(), store, &scriptedProviderFactory{provider: scripted}, builder) + if err := service.Run(context.Background(), UserInput{ + SessionID: seed.ID, + RunID: "run-plan-invalid-spec", + Mode: string(agentsession.AgentModePlan), + Parts: []providertypes.ContentPart{providertypes.NewTextPart("继续讨论")}, + }); err != nil { + t.Fatalf("Run() error = %v", err) + } + + saved := onlySession(t, store) + if saved.CurrentPlan == nil || saved.CurrentPlan.Spec.Goal != "Keep previous plan" { + t.Fatalf("expected invalid plan_spec not to overwrite current plan, got %+v", saved.CurrentPlan) + } + got := renderPartsForTest(saved.Messages[len(saved.Messages)-1].Parts) + if !strings.Contains(got, "\"plan_spec\"") { + t.Fatalf("expected invalid planning payload to fall back to normal assistant text, got %q", got) + } +} + func TestServiceRunBuildModeDoesNotRequireCurrentPlan(t *testing.T) { t.Parallel() diff --git a/internal/runtime/session_scheduler.go b/internal/runtime/session_scheduler.go index b70240d5..a23fa114 100644 --- a/internal/runtime/session_scheduler.go +++ b/internal/runtime/session_scheduler.go @@ -8,6 +8,7 @@ import ( "time" "neo-code/internal/partsrender" + providertypes "neo-code/internal/provider/types" agentsession "neo-code/internal/session" ) @@ -75,13 +76,11 @@ func sessionHasCompactedTranscript(session agentsession.Session) bool { if len(session.Messages) == 0 { return false } - for _, message := range session.Messages { - if !strings.Contains(partsrender.RenderDisplayParts(message.Parts), "[compact_summary]") { - continue - } - return true + message := session.Messages[0] + if message.Role != providertypes.RoleAssistant { + return false } - return false + return strings.HasPrefix(strings.TrimSpace(partsrender.RenderDisplayParts(message.Parts)), "[compact_summary]") } // establishSessionVerificationProfile 在创建新会话的边界显式写入验收 profile,避免运行时依赖隐式零值。 diff --git a/internal/runtime/session_scheduler_test.go b/internal/runtime/session_scheduler_test.go new file mode 100644 index 00000000..64ec242d --- /dev/null +++ b/internal/runtime/session_scheduler_test.go @@ -0,0 +1,63 @@ +package runtime + +import ( + "testing" + + providertypes "neo-code/internal/provider/types" + agentsession "neo-code/internal/session" +) + +func TestSessionHasCompactedTranscript(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + session agentsession.Session + want bool + }{ + { + name: "empty messages", + session: agentsession.New("empty"), + want: false, + }, + { + name: "first message compact summary", + session: agentsession.Session{ + Messages: []providertypes.Message{ + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("[compact_summary]\ndone:\n- ok")}}, + }, + }, + want: true, + }, + { + name: "first message is not assistant", + session: agentsession.Session{ + Messages: []providertypes.Message{ + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("[compact_summary]\ndone:\n- ok")}}, + }, + }, + want: false, + }, + { + name: "later message compact summary does not count", + session: agentsession.Session{ + Messages: []providertypes.Message{ + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("normal reply")}}, + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("[compact_summary]\ndone:\n- archived")}}, + }, + }, + want: false, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := sessionHasCompactedTranscript(tt.session); got != tt.want { + t.Fatalf("sessionHasCompactedTranscript() = %v, want %v", got, tt.want) + } + }) + } +} From 68fc00c37867e225b114a4e1d1ad0a6d424a57c6 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Thu, 30 Apr 2026 04:05:33 +0000 Subject: [PATCH 3/3] test(runtime): improve coverage for plan/build state branches Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com> --- internal/context/builder_test.go | 23 ++++++++ internal/runtime/planning_test.go | 91 +++++++++++++++++++++++++++++++ internal/runtime/runtime_test.go | 57 +++++++++++++++++++ 3 files changed, 171 insertions(+) diff --git a/internal/context/builder_test.go b/internal/context/builder_test.go index 41f53806..d40c0eac 100644 --- a/internal/context/builder_test.go +++ b/internal/context/builder_test.go @@ -220,6 +220,29 @@ func TestDefaultBuilderBuildIncludesTodosBeforeSystemState(t *testing.T) { } } +func TestNewBuilderWithMemoAndSummarizersIncludesMemoSection(t *testing.T) { + t.Parallel() + + builder := NewBuilderWithMemoAndSummarizers(nil, nil, stubPromptSectionSource{ + sections: []promptSection{ + NewPromptSection("memo", "remember this"), + }, + }) + + got, err := builder.Build(stdcontext.Background(), BuildInput{ + Messages: []providertypes.Message{ + {Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}, + }, + Metadata: testMetadata(t.TempDir()), + }) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + if !strings.Contains(got.SystemPrompt, "## memo") { + t.Fatalf("expected memo section in prompt, got %q", got.SystemPrompt) + } +} + func TestDefaultBuilderBuildUsesSpanTrimPolicyWhenTrimPolicyIsUnset(t *testing.T) { t.Parallel() diff --git a/internal/runtime/planning_test.go b/internal/runtime/planning_test.go index e18ad7ae..5bcd873e 100644 --- a/internal/runtime/planning_test.go +++ b/internal/runtime/planning_test.go @@ -460,3 +460,94 @@ func TestRememberFullPlanRevisionClearsAlignmentFlags(t *testing.T) { t.Fatalf("expected one-shot alignment flags to be cleared, got %+v", session) } } + +func TestMarkCurrentPlanRestorePendingAndContextDirty(t *testing.T) { + t.Parallel() + + session := agentsession.New("mark restore/context dirty") + if markCurrentPlanRestorePending(&session) { + t.Fatal("expected false when current plan is missing") + } + if markCurrentPlanContextDirty(&session) { + t.Fatal("expected false when current plan is missing") + } + + session.CurrentPlan = &agentsession.PlanArtifact{ + ID: "plan-restore", + Revision: 1, + Status: agentsession.PlanStatusApproved, + Spec: agentsession.PlanSpec{ + Goal: "restore full plan", + Steps: []string{"step one"}, + Verify: []string{"verify one"}, + }, + } + if !markCurrentPlanRestorePending(&session) { + t.Fatal("expected first restore mark to succeed") + } + if markCurrentPlanRestorePending(&session) { + t.Fatal("expected duplicated restore mark to be ignored") + } + if !markCurrentPlanContextDirty(&session) { + t.Fatal("expected first context dirty mark to succeed") + } + if markCurrentPlanContextDirty(&session) { + t.Fatal("expected duplicated context dirty mark to be ignored") + } + + session.CurrentPlan.Status = agentsession.PlanStatusCompleted + session.PlanCompletionPendingFullReview = false + session.PlanRestorePendingAlign = false + session.PlanContextDirty = false + if markCurrentPlanRestorePending(&session) { + t.Fatal("expected completed plan without full review pending not to mark restore align") + } + if markCurrentPlanContextDirty(&session) { + t.Fatal("expected completed plan without full review pending not to mark context dirty") + } +} + +func TestApplyCurrentPlanRevisionNilGuards(t *testing.T) { + t.Parallel() + + session := agentsession.New("apply plan revision nil guards") + plan := &agentsession.PlanArtifact{ID: "plan-1", Revision: 1} + if applyCurrentPlanRevision(nil, plan) { + t.Fatal("expected nil session to return false") + } + if applyCurrentPlanRevision(&session, nil) { + t.Fatal("expected nil plan to return false") + } +} + +func TestApproveCurrentPlanValidationErrors(t *testing.T) { + t.Parallel() + + session := agentsession.New("approve validation") + if err := approveCurrentPlan(&session, "plan-1", 1); err == nil { + t.Fatal("expected error when current plan does not exist") + } + + session.CurrentPlan = &agentsession.PlanArtifact{ + ID: "plan-1", + Revision: 2, + Status: agentsession.PlanStatusDraft, + Spec: agentsession.PlanSpec{ + Goal: "审批校验", + Steps: []string{"步骤一"}, + Verify: []string{"验证一"}, + }, + } + + if err := approveCurrentPlan(&session, "plan-2", 2); err == nil { + t.Fatal("expected id mismatch error") + } + if err := approveCurrentPlan(&session, "plan-1", 1); err == nil { + t.Fatal("expected revision mismatch error") + } + + session.CurrentPlan.Status = agentsession.PlanStatusApproved + if err := approveCurrentPlan(&session, "plan-1", 2); err == nil { + t.Fatal("expected status mismatch error") + } +} diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 02d5c4df..99ae1206 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -4186,6 +4186,63 @@ func TestServiceApproveCurrentPlanNilService(t *testing.T) { } } +func TestServiceApproveCurrentPlanCanceledContext(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + service := NewWithFactory(manager, tools.NewRegistry(), store, &scriptedProviderFactory{provider: &scriptedProvider{}}, &stubContextBuilder{}) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err := service.ApproveCurrentPlan(ctx, ApproveCurrentPlanInput{ + SessionID: "session-1", + PlanID: "plan-1", + Revision: 1, + }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context canceled, got %v", err) + } +} + +func TestServiceApproveCurrentPlanTrimsSessionID(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + seed := agentsession.New("approve current plan with trimmed session id") + seed.CurrentPlan = &agentsession.PlanArtifact{ + ID: "plan-trim", + Revision: 1, + Status: agentsession.PlanStatusDraft, + Spec: agentsession.PlanSpec{ + Goal: "trim session id before load", + Steps: []string{"step one"}, + Verify: []string{"verify one"}, + }, + } + if _, err := store.CreateSession(context.Background(), createSessionInputFromSession(seed)); err != nil { + t.Fatalf("CreateSession() error = %v", err) + } + + service := NewWithFactory(manager, tools.NewRegistry(), store, &scriptedProviderFactory{provider: &scriptedProvider{}}, &stubContextBuilder{}) + if err := service.ApproveCurrentPlan(context.Background(), ApproveCurrentPlanInput{ + SessionID: " " + seed.ID + " ", + PlanID: "plan-trim", + Revision: 1, + }); err != nil { + t.Fatalf("ApproveCurrentPlan() error = %v", err) + } + + saved, err := store.LoadSession(context.Background(), seed.ID) + if err != nil { + t.Fatalf("LoadSession() error = %v", err) + } + if saved.CurrentPlan == nil || saved.CurrentPlan.Status != agentsession.PlanStatusApproved { + t.Fatalf("expected approved plan after trimming session id, got %+v", saved.CurrentPlan) + } +} + func TestServiceRunBuildModeIgnoresPlanningJSON(t *testing.T) { t.Parallel()