v0.45.0 cb619f7

plan.go RawHistoryFolder

1package core
2
3import (
4	"fmt"
5	"strings"
6	"time"
7)
8
9// PlanOperation is one parsed plan tool call. The tool decodes the arguments;
10// core validates the operation against the current plan, materializes the
11// resulting list, and only then commits it. Fields that do not apply to the
12// selected action are ignored.
13type PlanOperation struct {
14	Action      string
15	Explanation *string
16	Steps       []PlanStep     // set, add
17	Index       int            // update, remove (1-based)
18	Text        *string        // update
19	Status      PlanStepStatus // update
20}
21
22// SessionPlan is the agent-owned checklist persisted in session metadata.
23// Steps is optional so older sessions remain compatible.
24type SessionPlan struct {
25	Steps []PlanStep `json:"steps,omitempty"`
26	// Updated is stamped by Session.UpdatePlan when the plan is persisted.
27	Updated time.Time `json:"updated"`
28}
29
30// SetPlan seeds the agent-owned plan. It copies the steps so the caller keeps
31// ownership of its slice. Hosts seed state on resume and on /clear, where a nil
32// agent is possible, so the guard is deliberate. Seeded steps are normalized, so
33// a host cannot install state that the read action cannot report.
34func (a *Agent) SetPlan(steps []PlanStep) {
35	if a == nil {
36		return
37	}
38	var seeded []PlanStep
39	if normalized := normalizeSessionPlan(&SessionPlan{Steps: steps}); normalized != nil {
40		seeded = clonePlanSteps(normalized.Steps)
41	}
42	a.mu.Lock()
43	defer a.mu.Unlock()
44	a.plan = seeded
45}
46
47// CurrentPlan returns a copy of the agent-owned plan. Mutating the result does
48// not affect agent state.
49func (a *Agent) CurrentPlan() []PlanStep {
50	if a == nil {
51		return nil
52	}
53	a.mu.Lock()
54	defer a.mu.Unlock()
55	return clonePlanSteps(a.plan)
56}
57
58// previewPlanOperation validates op against the current plan and returns the
59// prospective update without mutating agent state. Callers commit a validated
60// operation with commitPlanUpdate once persistence succeeds.
61func (a *Agent) previewPlanOperation(op PlanOperation) (PlanUpdate, error) {
62	a.mu.Lock()
63	defer a.mu.Unlock()
64	return a.previewPlanOperationLocked(op)
65}
66
67// commitPlanUpdate stores the plan that was just persisted. Callers pass the
68// value returned by previewPlanOperation after CommitToolResult succeeded, so
69// agent state always matches what the session file holds.
70//
71// Preview and commit are separate lock windows. When a concurrent SetPlan lands
72// between them, the persisted update wins: the session file now records that
73// list, so state must agree with the file rather than with the intervening call.
74func (a *Agent) commitPlanUpdate(update PlanUpdate) {
75	a.mu.Lock()
76	defer a.mu.Unlock()
77	a.plan = clonePlanSteps(update.Plan)
78}
79
80func (a *Agent) previewPlanOperationLocked(op PlanOperation) (PlanUpdate, error) {
81	switch op.Action {
82	case "set", "add", "update", "remove", "clear", "show":
83	case "":
84		return PlanUpdate{}, fmt.Errorf("plan: action is required")
85	default:
86		return PlanUpdate{}, fmt.Errorf("plan: unknown action %q", op.Action)
87	}
88
89	plan := clonePlanSteps(a.plan)
90	switch op.Action {
91	case "set":
92		// A replace must say what it replaces: an omitted or empty step list used
93		// to wipe the checklist silently, which is too destructive a default for a
94		// model that only meant to attach an explanation. Removing every step is an
95		// explicit intent, so it has its own action.
96		if len(op.Steps) == 0 {
97			return PlanUpdate{}, fmt.Errorf("plan: steps are required for set (use clear to remove every step)")
98		}
99		plan = defaultPlanStepStatuses(clonePlanSteps(op.Steps))
100	case "add":
101		if len(op.Steps) == 0 {
102			return PlanUpdate{}, fmt.Errorf("plan: steps are required for add")
103		}
104		plan = append(plan, defaultPlanStepStatuses(clonePlanSteps(op.Steps))...)
105	case "update":
106		if op.Index < 1 || op.Index > len(plan) {
107			return PlanUpdate{}, fmt.Errorf("plan: index %d out of range (plan has %d steps)", op.Index, len(plan))
108		}
109		if op.Status == "" && op.Text == nil {
110			return PlanUpdate{}, fmt.Errorf("plan: update requires status or step")
111		}
112		step := plan[op.Index-1]
113		if op.Text != nil {
114			step.Step = *op.Text
115		}
116		if op.Status != "" {
117			step.Status = op.Status
118		}
119		plan[op.Index-1] = step
120	case "remove":
121		if op.Index < 1 || op.Index > len(plan) {
122			return PlanUpdate{}, fmt.Errorf("plan: index %d out of range (plan has %d steps)", op.Index, len(plan))
123		}
124		plan = append(plan[:op.Index-1], plan[op.Index:]...)
125		if len(plan) == 0 {
126			plan = nil
127		}
128	case "clear":
129		plan = nil
130	case "show":
131		// show is a read: report the current plan without validating it. Seeding
132		// normalizes, but a read must not fail on state a host installed directly.
133		return PlanUpdate{Explanation: op.Explanation, Plan: plan}, nil
134	}
135
136	if err := validatePlanSteps(plan); err != nil {
137		return PlanUpdate{}, err
138	}
139	return PlanUpdate{Explanation: op.Explanation, Plan: plan}, nil
140}
141
142// validatePlanSteps rejects steps that cannot be rendered or persisted: empty
143// text, an unknown status, or more than one in_progress step. There is
144// deliberately no length cap.
145func validatePlanSteps(plan []PlanStep) error {
146	inProgress := 0
147	for idx := range plan {
148		if strings.TrimSpace(plan[idx].Step) == "" {
149			return fmt.Errorf("plan: step text is required")
150		}
151		switch plan[idx].Status {
152		case PlanPending, PlanCompleted:
153		case PlanInProgress:
154			inProgress++
155		default:
156			return fmt.Errorf("plan: unknown status %q", plan[idx].Status)
157		}
158	}
159	if inProgress > 1 {
160		return fmt.Errorf("plan: at most one step can be in_progress")
161	}
162	return nil
163}
164
165// defaultPlanStepStatuses applies the schema's optional step status: a step that
166// omits it is pending. Without this, an omitted status would reach agent state
167// as an invalid value and later be dropped by normalizeSessionPlan on reload.
168func defaultPlanStepStatuses(steps []PlanStep) []PlanStep {
169	for idx := range steps {
170		if steps[idx].Status == "" {
171			steps[idx].Status = PlanPending
172		}
173	}
174	return steps
175}
176
177// planResultText renders the model-visible text for a validated plan
178// operation. Mutations return a compact summary that never echoes the list;
179// show returns the full checklist.
180func planResultText(action string, update PlanUpdate) string {
181	if action == "show" {
182		return planShowListing(update.Plan)
183	}
184	return planMutationSummary(update.Plan)
185}
186
187// planMutationSummary summarizes the resulting plan without repeating its steps:
188// the model already holds them in its own earlier messages.
189func planMutationSummary(plan []PlanStep) string {
190	if len(plan) == 0 {
191		return "Plan cleared"
192	}
193	completed := 0
194	inProgressIndex := 0
195	for idx, step := range plan {
196		switch step.Status {
197		case PlanCompleted:
198			completed++
199		case PlanInProgress:
200			inProgressIndex = idx + 1
201		}
202	}
203	summary := fmt.Sprintf("Plan updated (%d/%d completed", completed, len(plan))
204	if inProgressIndex > 0 {
205		summary += fmt.Sprintf("; in progress: step %d", inProgressIndex)
206	}
207	return summary + ")"
208}
209
210// planShowListing renders the full checklist for the show action.
211func planShowListing(plan []PlanStep) string {
212	if len(plan) == 0 {
213		return "Plan is empty"
214	}
215	var b strings.Builder
216	for idx, step := range plan {
217		if idx > 0 {
218			b.WriteByte('\n')
219		}
220		fmt.Fprintf(&b, "%d. [%s] %s", idx+1, planStepMarker(step.Status), step.Step)
221	}
222	return b.String()
223}
224
225// planStepMarker is the one-character checkbox shown for a step status.
226func planStepMarker(status PlanStepStatus) string {
227	switch status {
228	case PlanCompleted:
229		return "x"
230	case PlanInProgress:
231		return "~"
232	default:
233		return " "
234	}
235}
236
237// clonePlanSteps returns an independent copy of steps, or nil when empty.
238func clonePlanSteps(steps []PlanStep) []PlanStep {
239	if len(steps) == 0 {
240		return nil
241	}
242	clone := make([]PlanStep, len(steps))
243	copy(clone, steps)
244	return clone
245}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close