feat(plan): allow several in_progress steps for parallel work
- commit
- 00396bf90ef684a9dfe931cd6bf979a70cb2c756
- author
- brice · · 2026-10-10 01:44 UTC
- parent
- deaab59
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email - refs
- tag: v0.49.0
6 files · +43 -41
M README.md +1 -1
| @@ -274,7 +274,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens | |||
| 274 | 274 | - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. Successful native edits, writes, renames, and applied code actions automatically attach bounded diagnostics when available. See [docs/lsp.md](docs/lsp.md). | |
| 275 | 275 | - `web_search`: search DuckDuckGo HTML and return bounded source titles, URLs, snippets, and opaque references. `web_open` opens either a complete public URL or a referenced page, while `web_find` and `web_click` respectively find literal text in an opened page and follow a displayed link. The four tools are enabled together by default for normal CLI sessions and unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md). | |
| 276 | 276 | - `update_goal`: let the main agent start its own mission when none is active, set the next bounded goal within an active mission, pause work while a decision is pending, resume it once settled, mark the active goal `done` or `blocked`, or atomically supersede an invalid goal with a necessary in-mission replacement. Users can also pause and resume through `/goal`; clearing remains user-controlled. | |
| 277 | - | - `plan`: maintain a session-scoped checklist of `pending`, `in_progress`, and `completed` steps through `set` (replace every step), `add`, `update` one step, `remove` one step, `clear`, and `show`. At most one step may be `in_progress`. `set` requires at least one step; `clear` is how every step is removed. There is no step-count limit, but a plan carried across compaction shows only its first 12 steps. The plan is persisted in every mode through the tool-result commit path, inherited by forks and imports, and cleared by `/clear`. Successful updates appear inline as a checklist and emit `plan_update` events in JSON and RPC modes. | |
| 277 | + | - `plan`: maintain a session-scoped checklist of `pending`, `in_progress`, and `completed` steps through `set` (replace every step), `add`, `update` one step, `remove` one step, `clear`, and `show`. Several steps may be `in_progress` at once to track parallel work such as concurrent sub-agents. `set` requires at least one step; `clear` is how every step is removed. There is no step-count limit, but a plan carried across compaction shows only its first 12 steps. The plan is persisted in every mode through the tool-result commit path, inherited by forks and imports, and cleared by `/clear`. Successful updates appear inline as a checklist and emit `plan_update` events in JSON and RPC modes. | |
| 278 | 278 | - `schedule`: add, list, or cancel recurring five-field cron prompts for the current interactive session. A task captures the machine's current timezone when it is created. While this zut process remains open, a due task waits for an active target turn to finish, or resumes an inactive target session in the background; its output is recorded in that target transcript. Resumed background tasks also show a completion notification in the current TUI. Scheduling is in-memory only: quitting zut cancels pending tasks, and schedules do not survive restart or reboot. Print, stream, JSON, and RPC modes do not expose this tool. | |
| 279 | 279 | ||
| 280 | 280 | Tool results carry resource provenance in the durable session. Before each provider request, Zut replaces an older read only when an equivalent newer view supersedes it, and replaces a multi-file search only after every discovered file has been consumed or mutated. This provider-only projection removes known-stale context without rewriting session history. Because an older projected result changes, prompt caching restarts from that point; compact deterministic markers limit the cost. | |
| @@ -274,7 +274,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens | |||||
| 274 | - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. Successful native edits, writes, renames, and applied code actions automatically attach bounded diagnostics when available. See [docs/lsp.md](docs/lsp.md). | 274 | - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. Successful native edits, writes, renames, and applied code actions automatically attach bounded diagnostics when available. See [docs/lsp.md](docs/lsp.md). | ||
| 275 | - `web_search`: search DuckDuckGo HTML and return bounded source titles, URLs, snippets, and opaque references. `web_open` opens either a complete public URL or a referenced page, while `web_find` and `web_click` respectively find literal text in an opened page and follow a displayed link. The four tools are enabled together by default for normal CLI sessions and unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md). | 275 | - `web_search`: search DuckDuckGo HTML and return bounded source titles, URLs, snippets, and opaque references. `web_open` opens either a complete public URL or a referenced page, while `web_find` and `web_click` respectively find literal text in an opened page and follow a displayed link. The four tools are enabled together by default for normal CLI sessions and unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md). | ||
| 276 | - `update_goal`: let the main agent start its own mission when none is active, set the next bounded goal within an active mission, pause work while a decision is pending, resume it once settled, mark the active goal `done` or `blocked`, or atomically supersede an invalid goal with a necessary in-mission replacement. Users can also pause and resume through `/goal`; clearing remains user-controlled. | 276 | - `update_goal`: let the main agent start its own mission when none is active, set the next bounded goal within an active mission, pause work while a decision is pending, resume it once settled, mark the active goal `done` or `blocked`, or atomically supersede an invalid goal with a necessary in-mission replacement. Users can also pause and resume through `/goal`; clearing remains user-controlled. | ||
| 277 | - | - `plan`: maintain a session-scoped checklist of `pending`, `in_progress`, and `completed` steps through `set` (replace every step), `add`, `update` one step, `remove` one step, `clear`, and `show`. At most one step may be `in_progress`. `set` requires at least one step; `clear` is how every step is removed. There is no step-count limit, but a plan carried across compaction shows only its first 12 steps. The plan is persisted in every mode through the tool-result commit path, inherited by forks and imports, and cleared by `/clear`. Successful updates appear inline as a checklist and emit `plan_update` events in JSON and RPC modes. | 277 | + | - `plan`: maintain a session-scoped checklist of `pending`, `in_progress`, and `completed` steps through `set` (replace every step), `add`, `update` one step, `remove` one step, `clear`, and `show`. Several steps may be `in_progress` at once to track parallel work such as concurrent sub-agents. `set` requires at least one step; `clear` is how every step is removed. There is no step-count limit, but a plan carried across compaction shows only its first 12 steps. The plan is persisted in every mode through the tool-result commit path, inherited by forks and imports, and cleared by `/clear`. Successful updates appear inline as a checklist and emit `plan_update` events in JSON and RPC modes. |
| 278 | - `schedule`: add, list, or cancel recurring five-field cron prompts for the current interactive session. A task captures the machine's current timezone when it is created. While this zut process remains open, a due task waits for an active target turn to finish, or resumes an inactive target session in the background; its output is recorded in that target transcript. Resumed background tasks also show a completion notification in the current TUI. Scheduling is in-memory only: quitting zut cancels pending tasks, and schedules do not survive restart or reboot. Print, stream, JSON, and RPC modes do not expose this tool. | 278 | - `schedule`: add, list, or cancel recurring five-field cron prompts for the current interactive session. A task captures the machine's current timezone when it is created. While this zut process remains open, a due task waits for an active target turn to finish, or resumes an inactive target session in the background; its output is recorded in that target transcript. Resumed background tasks also show a completion notification in the current TUI. Scheduling is in-memory only: quitting zut cancels pending tasks, and schedules do not survive restart or reboot. Print, stream, JSON, and RPC modes do not expose this tool. | ||
| 279 | 279 | ||||
| 280 | Tool results carry resource provenance in the durable session. Before each provider request, Zut replaces an older read only when an equivalent newer view supersedes it, and replaces a multi-file search only after every discovered file has been consumed or mutated. This provider-only projection removes known-stale context without rewriting session history. Because an older projected result changes, prompt caching restarts from that point; compact deterministic markers limit the cost. | 280 | Tool results carry resource provenance in the durable session. Before each provider request, Zut replaces an older read only when an equivalent newer view supersedes it, and replaces a multi-file search only after every discovered file has been consumed or mutated. This provider-only projection removes known-stale context without rewriting session history. Because an older projected result changes, prompt caching restarts from that point; compact deterministic markers limit the cost. | ||
M packages/agent/systemprompt.go +1 -1
| @@ -111,7 +111,7 @@ const compactedSummaryHandoffInstruction = `When you see a "## Context Summary ( | |||
| 111 | 111 | ||
| 112 | 112 | const taskExecutionGuidance = `Treat requests such as "can you" as instructions to do the requested work within the user's intended scope; do not stop at a plan or an offer to continue. Proceed with authorized, non-destructive work and reasonable assumptions until the task is complete or genuinely blocked. Follow each progress statement with the action it describes: pair every intermediate update with tool calls or a concrete next step in the same turn. Final answers contain the result of the work or a concrete blocker, never a bare promise to continue. Respect requests for explanation, planning, or review without making unsolicited changes. Ask only when a necessary decision blocks safe, correct progress; complete independent authorized work first. Verify changes with checks appropriate to their scope. Report which checks ran and what remains unverified. Preserve tool permissions, required confirmations, and explicit approval requirements; do not infer permission for destructive or irreversible actions.` | |
| 113 | 113 | ||
| 114 | - | const planGuidance = `For multi-phase or dependency-ordered work, and whenever the user asks for a plan, maintain the plan checklist instead of tracking steps in prose. Skip it for single-step or informational requests. Keep at most one step in_progress and update the plan at phase boundaries, not after every command. Group related steps instead of appending endlessly: a plan is phases you can verify, not a task log. Never mark a step completed without doing the work, and do not batch-complete steps after the fact. Prefer add, update, and remove over re-sending the whole list with set; use set only to replace the checklist and clear to remove it. After a plan call, do not restate the plan in prose because the interface already shows it. After compaction, or whenever step identity is unclear, call plan with action:"show" instead of guessing an index. A plan carried over from earlier work stays visible until it is cleared or replaced, so clear it once the work it describes is no longer in flight.` | |
| 114 | + | const planGuidance = `For multi-phase or dependency-ordered work, and whenever the user asks for a plan, maintain the plan checklist instead of tracking steps in prose. Skip it for single-step or informational requests. Mark a step in_progress while it is actively worked on; several steps may be in_progress only when they truly run in parallel, such as concurrent sub-agents. Update the plan at phase boundaries, not after every command. Group related steps instead of appending endlessly: a plan is phases you can verify, not a task log. Never mark a step completed without doing the work, and do not batch-complete steps after the fact. Prefer add, update, and remove over re-sending the whole list with set; use set only to replace the checklist and clear to remove it. After a plan call, do not restate the plan in prose because the interface already shows it. After compaction, or whenever step identity is unclear, call plan with action:"show" instead of guessing an index. A plan carried over from earlier work stays visible until it is cleared or replaced, so clear it once the work it describes is no longer in flight.` | |
| 115 | 115 | ||
| 116 | 116 | const skillPriorityGuidance = `Explicit user instructions take precedence over skill guidelines. Skills do not grant permissions or override system or developer constraints. If a skill would block requested work or change its scope, identify its source and the relevant instruction, explain the conflict, and follow the applicable higher-priority instruction.` | |
| 117 | 117 | ||
| @@ -111,7 +111,7 @@ const compactedSummaryHandoffInstruction = `When you see a "## Context Summary ( | |||||
| 111 | 111 | ||||
| 112 | const taskExecutionGuidance = `Treat requests such as "can you" as instructions to do the requested work within the user's intended scope; do not stop at a plan or an offer to continue. Proceed with authorized, non-destructive work and reasonable assumptions until the task is complete or genuinely blocked. Follow each progress statement with the action it describes: pair every intermediate update with tool calls or a concrete next step in the same turn. Final answers contain the result of the work or a concrete blocker, never a bare promise to continue. Respect requests for explanation, planning, or review without making unsolicited changes. Ask only when a necessary decision blocks safe, correct progress; complete independent authorized work first. Verify changes with checks appropriate to their scope. Report which checks ran and what remains unverified. Preserve tool permissions, required confirmations, and explicit approval requirements; do not infer permission for destructive or irreversible actions.` | 112 | const taskExecutionGuidance = `Treat requests such as "can you" as instructions to do the requested work within the user's intended scope; do not stop at a plan or an offer to continue. Proceed with authorized, non-destructive work and reasonable assumptions until the task is complete or genuinely blocked. Follow each progress statement with the action it describes: pair every intermediate update with tool calls or a concrete next step in the same turn. Final answers contain the result of the work or a concrete blocker, never a bare promise to continue. Respect requests for explanation, planning, or review without making unsolicited changes. Ask only when a necessary decision blocks safe, correct progress; complete independent authorized work first. Verify changes with checks appropriate to their scope. Report which checks ran and what remains unverified. Preserve tool permissions, required confirmations, and explicit approval requirements; do not infer permission for destructive or irreversible actions.` | ||
| 113 | 113 | ||||
| 114 | - | const planGuidance = `For multi-phase or dependency-ordered work, and whenever the user asks for a plan, maintain the plan checklist instead of tracking steps in prose. Skip it for single-step or informational requests. Keep at most one step in_progress and update the plan at phase boundaries, not after every command. Group related steps instead of appending endlessly: a plan is phases you can verify, not a task log. Never mark a step completed without doing the work, and do not batch-complete steps after the fact. Prefer add, update, and remove over re-sending the whole list with set; use set only to replace the checklist and clear to remove it. After a plan call, do not restate the plan in prose because the interface already shows it. After compaction, or whenever step identity is unclear, call plan with action:"show" instead of guessing an index. A plan carried over from earlier work stays visible until it is cleared or replaced, so clear it once the work it describes is no longer in flight.` | 114 | + | const planGuidance = `For multi-phase or dependency-ordered work, and whenever the user asks for a plan, maintain the plan checklist instead of tracking steps in prose. Skip it for single-step or informational requests. Mark a step in_progress while it is actively worked on; several steps may be in_progress only when they truly run in parallel, such as concurrent sub-agents. Update the plan at phase boundaries, not after every command. Group related steps instead of appending endlessly: a plan is phases you can verify, not a task log. Never mark a step completed without doing the work, and do not batch-complete steps after the fact. Prefer add, update, and remove over re-sending the whole list with set; use set only to replace the checklist and clear to remove it. After a plan call, do not restate the plan in prose because the interface already shows it. After compaction, or whenever step identity is unclear, call plan with action:"show" instead of guessing an index. A plan carried over from earlier work stays visible until it is cleared or replaced, so clear it once the work it describes is no longer in flight.` |
| 115 | 115 | ||||
| 116 | const skillPriorityGuidance = `Explicit user instructions take precedence over skill guidelines. Skills do not grant permissions or override system or developer constraints. If a skill would block requested work or change its scope, identify its source and the relevant instruction, explain the conflict, and follow the applicable higher-priority instruction.` | 116 | const skillPriorityGuidance = `Explicit user instructions take precedence over skill guidelines. Skills do not grant permissions or override system or developer constraints. If a skill would block requested work or change its scope, identify its source and the relevant instruction, explain the conflict, and follow the applicable higher-priority instruction.` | ||
| 117 | 117 | ||||
M packages/agent/tools/plan.go +1 -1
| @@ -56,7 +56,7 @@ func (t *PlanTool) Name() string { return PlanToolName } | |||
| 56 | 56 | func (t *PlanTool) AllowArgumentRewrite() bool { return false } | |
| 57 | 57 | ||
| 58 | 58 | func (t *PlanTool) Description() string { | |
| 59 | - | return "Maintain a persisted task checklist. Actions: set (replace every step), add, update one step, remove one step, clear, show the current checklist. At most one step may be in_progress at a time; prefer add/update/remove deltas over re-sending the whole list with set." | |
| 59 | + | return "Maintain a persisted task checklist. Actions: set (replace every step), add, update one step, remove one step, clear, show the current checklist. Several steps may be in_progress at once when work runs in parallel (for example concurrent sub-agents); prefer add/update/remove deltas over re-sending the whole list with set." | |
| 60 | 60 | } | |
| 61 | 61 | ||
| 62 | 62 | func (t *PlanTool) Schema() json.RawMessage { return json.RawMessage(planSchema) } | |
| @@ -56,7 +56,7 @@ func (t *PlanTool) Name() string { return PlanToolName } | |||||
| 56 | func (t *PlanTool) AllowArgumentRewrite() bool { return false } | 56 | func (t *PlanTool) AllowArgumentRewrite() bool { return false } | ||
| 57 | 57 | ||||
| 58 | func (t *PlanTool) Description() string { | 58 | func (t *PlanTool) Description() string { | ||
| 59 | - | return "Maintain a persisted task checklist. Actions: set (replace every step), add, update one step, remove one step, clear, show the current checklist. At most one step may be in_progress at a time; prefer add/update/remove deltas over re-sending the whole list with set." | 59 | + | return "Maintain a persisted task checklist. Actions: set (replace every step), add, update one step, remove one step, clear, show the current checklist. Several steps may be in_progress at once when work runs in parallel (for example concurrent sub-agents); prefer add/update/remove deltas over re-sending the whole list with set." |
| 60 | } | 60 | } | ||
| 61 | 61 | ||||
| 62 | func (t *PlanTool) Schema() json.RawMessage { return json.RawMessage(planSchema) } | 62 | func (t *PlanTool) Schema() json.RawMessage { return json.RawMessage(planSchema) } | ||
M packages/core/plan.go +12 -12
| @@ -2,6 +2,7 @@ package core | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | + | "strconv" | |
| 5 | 6 | "strings" | |
| 6 | 7 | "time" | |
| 7 | 8 | ) | |
| @@ -140,25 +141,20 @@ func (a *Agent) previewPlanOperationLocked(op PlanOperation) (PlanUpdate, error) | |||
| 140 | 141 | } | |
| 141 | 142 | ||
| 142 | 143 | // 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 | + | // text or an unknown status. Several steps may be in_progress at once so | |
| 145 | + | // parallel work (for example concurrent sub-agents) can be tracked. There is | |
| 144 | 146 | // deliberately no length cap. | |
| 145 | 147 | func validatePlanSteps(plan []PlanStep) error { | |
| 146 | - | inProgress := 0 | |
| 147 | 148 | for idx := range plan { | |
| 148 | 149 | if strings.TrimSpace(plan[idx].Step) == "" { | |
| 149 | 150 | return fmt.Errorf("plan: step text is required") | |
| 150 | 151 | } | |
| 151 | 152 | switch plan[idx].Status { | |
| 152 | - | case PlanPending, PlanCompleted: | |
| 153 | - | case PlanInProgress: | |
| 154 | - | inProgress++ | |
| 153 | + | case PlanPending, PlanCompleted, PlanInProgress: | |
| 155 | 154 | default: | |
| 156 | 155 | return fmt.Errorf("plan: unknown status %q", plan[idx].Status) | |
| 157 | 156 | } | |
| 158 | 157 | } | |
| 159 | - | if inProgress > 1 { | |
| 160 | - | return fmt.Errorf("plan: at most one step can be in_progress") | |
| 161 | - | } | |
| 162 | 158 | return nil | |
| 163 | 159 | } | |
| 164 | 160 | ||
| @@ -191,18 +187,22 @@ func planMutationSummary(plan []PlanStep) string { | |||
| 191 | 187 | return "Plan cleared" | |
| 192 | 188 | } | |
| 193 | 189 | completed := 0 | |
| 194 | - | inProgressIndex := 0 | |
| 190 | + | var inProgress []string | |
| 195 | 191 | for idx, step := range plan { | |
| 196 | 192 | switch step.Status { | |
| 197 | 193 | case PlanCompleted: | |
| 198 | 194 | completed++ | |
| 199 | 195 | case PlanInProgress: | |
| 200 | - | inProgressIndex = idx + 1 | |
| 196 | + | inProgress = append(inProgress, strconv.Itoa(idx+1)) | |
| 201 | 197 | } | |
| 202 | 198 | } | |
| 203 | 199 | summary := fmt.Sprintf("Plan updated (%d/%d completed", completed, len(plan)) | |
| 204 | - | if inProgressIndex > 0 { | |
| 205 | - | summary += fmt.Sprintf("; in progress: step %d", inProgressIndex) | |
| 200 | + | switch len(inProgress) { | |
| 201 | + | case 0: | |
| 202 | + | case 1: | |
| 203 | + | summary += "; in progress: step " + inProgress[0] | |
| 204 | + | default: | |
| 205 | + | summary += "; in progress: steps " + strings.Join(inProgress, ", ") | |
| 206 | 206 | } | |
| 207 | 207 | return summary + ")" | |
| 208 | 208 | } | |
| @@ -2,6 +2,7 @@ package core | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | + | "strconv" | |||
| 5 | "strings" | 6 | "strings" | ||
| 6 | "time" | 7 | "time" | ||
| 7 | ) | 8 | ) | ||
| @@ -140,25 +141,20 @@ func (a *Agent) previewPlanOperationLocked(op PlanOperation) (PlanUpdate, error) | |||||
| 140 | } | 141 | } | ||
| 141 | 142 | ||||
| 142 | // validatePlanSteps rejects steps that cannot be rendered or persisted: empty | 143 | // 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 | + | // text or an unknown status. Several steps may be in_progress at once so |
| 145 | + | // parallel work (for example concurrent sub-agents) can be tracked. There is | |||
| 144 | // deliberately no length cap. | 146 | // deliberately no length cap. | ||
| 145 | func validatePlanSteps(plan []PlanStep) error { | 147 | func validatePlanSteps(plan []PlanStep) error { | ||
| 146 | - | inProgress := 0 | |||
| 147 | for idx := range plan { | 148 | for idx := range plan { | ||
| 148 | if strings.TrimSpace(plan[idx].Step) == "" { | 149 | if strings.TrimSpace(plan[idx].Step) == "" { | ||
| 149 | return fmt.Errorf("plan: step text is required") | 150 | return fmt.Errorf("plan: step text is required") | ||
| 150 | } | 151 | } | ||
| 151 | switch plan[idx].Status { | 152 | switch plan[idx].Status { | ||
| 152 | - | case PlanPending, PlanCompleted: | 153 | + | case PlanPending, PlanCompleted, PlanInProgress: |
| 153 | - | case PlanInProgress: | |||
| 154 | - | inProgress++ | |||
| 155 | default: | 154 | default: | ||
| 156 | return fmt.Errorf("plan: unknown status %q", plan[idx].Status) | 155 | return fmt.Errorf("plan: unknown status %q", plan[idx].Status) | ||
| 157 | } | 156 | } | ||
| 158 | } | 157 | } | ||
| 159 | - | if inProgress > 1 { | |||
| 160 | - | return fmt.Errorf("plan: at most one step can be in_progress") | |||
| 161 | - | } | |||
| 162 | return nil | 158 | return nil | ||
| 163 | } | 159 | } | ||
| 164 | 160 | ||||
| @@ -191,18 +187,22 @@ func planMutationSummary(plan []PlanStep) string { | |||||
| 191 | return "Plan cleared" | 187 | return "Plan cleared" | ||
| 192 | } | 188 | } | ||
| 193 | completed := 0 | 189 | completed := 0 | ||
| 194 | - | inProgressIndex := 0 | 190 | + | var inProgress []string |
| 195 | for idx, step := range plan { | 191 | for idx, step := range plan { | ||
| 196 | switch step.Status { | 192 | switch step.Status { | ||
| 197 | case PlanCompleted: | 193 | case PlanCompleted: | ||
| 198 | completed++ | 194 | completed++ | ||
| 199 | case PlanInProgress: | 195 | case PlanInProgress: | ||
| 200 | - | inProgressIndex = idx + 1 | 196 | + | inProgress = append(inProgress, strconv.Itoa(idx+1)) |
| 201 | } | 197 | } | ||
| 202 | } | 198 | } | ||
| 203 | summary := fmt.Sprintf("Plan updated (%d/%d completed", completed, len(plan)) | 199 | summary := fmt.Sprintf("Plan updated (%d/%d completed", completed, len(plan)) | ||
| 204 | - | if inProgressIndex > 0 { | 200 | + | switch len(inProgress) { |
| 205 | - | summary += fmt.Sprintf("; in progress: step %d", inProgressIndex) | 201 | + | case 0: |
| 202 | + | case 1: | |||
| 203 | + | summary += "; in progress: step " + inProgress[0] | |||
| 204 | + | default: | |||
| 205 | + | summary += "; in progress: steps " + strings.Join(inProgress, ", ") | |||
| 206 | } | 206 | } | ||
| 207 | return summary + ")" | 207 | return summary + ")" | ||
| 208 | } | 208 | } | ||
M packages/core/plan_test.go +25 -17
| @@ -68,6 +68,28 @@ func TestPreviewPlanOperationDoesNotMutate(t *testing.T) { | |||
| 68 | 68 | } | |
| 69 | 69 | } | |
| 70 | 70 | ||
| 71 | + | func TestPreviewPlanOperationAllowsParallelInProgress(t *testing.T) { | |
| 72 | + | agent := &Agent{} | |
| 73 | + | agent.SetPlan([]PlanStep{ | |
| 74 | + | planStep("first", PlanInProgress), | |
| 75 | + | planStep("second", PlanPending), | |
| 76 | + | planStep("third", PlanPending), | |
| 77 | + | }) | |
| 78 | + | ||
| 79 | + | update, err := agent.previewPlanOperation(PlanOperation{Action: "update", Index: 3, Status: PlanInProgress}) | |
| 80 | + | if err != nil { | |
| 81 | + | t.Fatalf("update: %v", err) | |
| 82 | + | } | |
| 83 | + | if got := planMutationSummary(update.Plan); got != "Plan updated (0/3 completed; in progress: steps 1, 3)" { | |
| 84 | + | t.Fatalf("summary = %q", got) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | parallel := []PlanStep{planStep("a", PlanInProgress), planStep("b", PlanInProgress)} | |
| 88 | + | if _, err := agent.previewPlanOperation(PlanOperation{Action: "set", Steps: parallel}); err != nil { | |
| 89 | + | t.Fatalf("set: %v", err) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 71 | 93 | func TestPreviewPlanOperationErrors(t *testing.T) { | |
| 72 | 94 | base := []PlanStep{ | |
| 73 | 95 | planStep("first", PlanInProgress), | |
| @@ -135,21 +157,6 @@ func TestPreviewPlanOperationErrors(t *testing.T) { | |||
| 135 | 157 | op: PlanOperation{Action: "set"}, | |
| 136 | 158 | want: "plan: steps are required for set (use clear to remove every step)", | |
| 137 | 159 | }, | |
| 138 | - | { | |
| 139 | - | name: "set two in progress", | |
| 140 | - | op: PlanOperation{Action: "set", Steps: []PlanStep{planStep("a", PlanInProgress), planStep("b", PlanInProgress)}}, | |
| 141 | - | want: "plan: at most one step can be in_progress", | |
| 142 | - | }, | |
| 143 | - | { | |
| 144 | - | name: "add second in progress", | |
| 145 | - | op: PlanOperation{Action: "add", Steps: []PlanStep{planStep("fourth", PlanInProgress)}}, | |
| 146 | - | want: "plan: at most one step can be in_progress", | |
| 147 | - | }, | |
| 148 | - | { | |
| 149 | - | name: "update second in progress", | |
| 150 | - | op: PlanOperation{Action: "update", Index: 2, Status: PlanInProgress}, | |
| 151 | - | want: "plan: at most one step can be in_progress", | |
| 152 | - | }, | |
| 153 | 160 | { | |
| 154 | 161 | name: "set unknown status", | |
| 155 | 162 | op: PlanOperation{Action: "set", Steps: []PlanStep{planStep("a", PlanStepStatus("done"))}}, | |
| @@ -463,6 +470,7 @@ func TestSessionPlanNormalizesOnLoad(t *testing.T) { | |||
| 463 | 470 | defer reopened.Close() | |
| 464 | 471 | want := []PlanStep{ | |
| 465 | 472 | planStep("first", PlanInProgress), | |
| 473 | + | planStep("second", PlanInProgress), | |
| 466 | 474 | planStep("third", PlanCompleted), | |
| 467 | 475 | } | |
| 468 | 476 | if reopened.Meta.Plan == nil || !slices.Equal(reopened.Meta.Plan.Steps, want) { | |
| @@ -801,13 +809,13 @@ func TestNormalizeSessionPlan(t *testing.T) { | |||
| 801 | 809 | want: []PlanStep{planStep("kept", PlanCompleted)}, | |
| 802 | 810 | }, | |
| 803 | 811 | { | |
| 804 | - | name: "keeps only first in progress", | |
| 812 | + | name: "keeps parallel in progress steps", | |
| 805 | 813 | in: &SessionPlan{Steps: []PlanStep{ | |
| 806 | 814 | planStep("first", PlanInProgress), | |
| 807 | 815 | planStep("second", PlanInProgress), | |
| 808 | 816 | planStep("third", PlanPending), | |
| 809 | 817 | }}, | |
| 810 | - | want: []PlanStep{planStep("first", PlanInProgress), planStep("third", PlanPending)}, | |
| 818 | + | want: []PlanStep{planStep("first", PlanInProgress), planStep("second", PlanInProgress), planStep("third", PlanPending)}, | |
| 811 | 819 | }, | |
| 812 | 820 | } | |
| 813 | 821 | for _, tt := range tests { | |
| @@ -68,6 +68,28 @@ func TestPreviewPlanOperationDoesNotMutate(t *testing.T) { | |||||
| 68 | } | 68 | } | ||
| 69 | } | 69 | } | ||
| 70 | 70 | ||||
| 71 | + | func TestPreviewPlanOperationAllowsParallelInProgress(t *testing.T) { | |||
| 72 | + | agent := &Agent{} | |||
| 73 | + | agent.SetPlan([]PlanStep{ | |||
| 74 | + | planStep("first", PlanInProgress), | |||
| 75 | + | planStep("second", PlanPending), | |||
| 76 | + | planStep("third", PlanPending), | |||
| 77 | + | }) | |||
| 78 | + | ||||
| 79 | + | update, err := agent.previewPlanOperation(PlanOperation{Action: "update", Index: 3, Status: PlanInProgress}) | |||
| 80 | + | if err != nil { | |||
| 81 | + | t.Fatalf("update: %v", err) | |||
| 82 | + | } | |||
| 83 | + | if got := planMutationSummary(update.Plan); got != "Plan updated (0/3 completed; in progress: steps 1, 3)" { | |||
| 84 | + | t.Fatalf("summary = %q", got) | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | parallel := []PlanStep{planStep("a", PlanInProgress), planStep("b", PlanInProgress)} | |||
| 88 | + | if _, err := agent.previewPlanOperation(PlanOperation{Action: "set", Steps: parallel}); err != nil { | |||
| 89 | + | t.Fatalf("set: %v", err) | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 71 | func TestPreviewPlanOperationErrors(t *testing.T) { | 93 | func TestPreviewPlanOperationErrors(t *testing.T) { | ||
| 72 | base := []PlanStep{ | 94 | base := []PlanStep{ | ||
| 73 | planStep("first", PlanInProgress), | 95 | planStep("first", PlanInProgress), | ||
| @@ -135,21 +157,6 @@ func TestPreviewPlanOperationErrors(t *testing.T) { | |||||
| 135 | op: PlanOperation{Action: "set"}, | 157 | op: PlanOperation{Action: "set"}, | ||
| 136 | want: "plan: steps are required for set (use clear to remove every step)", | 158 | want: "plan: steps are required for set (use clear to remove every step)", | ||
| 137 | }, | 159 | }, | ||
| 138 | - | { | |||
| 139 | - | name: "set two in progress", | |||
| 140 | - | op: PlanOperation{Action: "set", Steps: []PlanStep{planStep("a", PlanInProgress), planStep("b", PlanInProgress)}}, | |||
| 141 | - | want: "plan: at most one step can be in_progress", | |||
| 142 | - | }, | |||
| 143 | - | { | |||
| 144 | - | name: "add second in progress", | |||
| 145 | - | op: PlanOperation{Action: "add", Steps: []PlanStep{planStep("fourth", PlanInProgress)}}, | |||
| 146 | - | want: "plan: at most one step can be in_progress", | |||
| 147 | - | }, | |||
| 148 | - | { | |||
| 149 | - | name: "update second in progress", | |||
| 150 | - | op: PlanOperation{Action: "update", Index: 2, Status: PlanInProgress}, | |||
| 151 | - | want: "plan: at most one step can be in_progress", | |||
| 152 | - | }, | |||
| 153 | { | 160 | { | ||
| 154 | name: "set unknown status", | 161 | name: "set unknown status", | ||
| 155 | op: PlanOperation{Action: "set", Steps: []PlanStep{planStep("a", PlanStepStatus("done"))}}, | 162 | op: PlanOperation{Action: "set", Steps: []PlanStep{planStep("a", PlanStepStatus("done"))}}, | ||
| @@ -463,6 +470,7 @@ func TestSessionPlanNormalizesOnLoad(t *testing.T) { | |||||
| 463 | defer reopened.Close() | 470 | defer reopened.Close() | ||
| 464 | want := []PlanStep{ | 471 | want := []PlanStep{ | ||
| 465 | planStep("first", PlanInProgress), | 472 | planStep("first", PlanInProgress), | ||
| 473 | + | planStep("second", PlanInProgress), | |||
| 466 | planStep("third", PlanCompleted), | 474 | planStep("third", PlanCompleted), | ||
| 467 | } | 475 | } | ||
| 468 | if reopened.Meta.Plan == nil || !slices.Equal(reopened.Meta.Plan.Steps, want) { | 476 | if reopened.Meta.Plan == nil || !slices.Equal(reopened.Meta.Plan.Steps, want) { | ||
| @@ -801,13 +809,13 @@ func TestNormalizeSessionPlan(t *testing.T) { | |||||
| 801 | want: []PlanStep{planStep("kept", PlanCompleted)}, | 809 | want: []PlanStep{planStep("kept", PlanCompleted)}, | ||
| 802 | }, | 810 | }, | ||
| 803 | { | 811 | { | ||
| 804 | - | name: "keeps only first in progress", | 812 | + | name: "keeps parallel in progress steps", |
| 805 | in: &SessionPlan{Steps: []PlanStep{ | 813 | in: &SessionPlan{Steps: []PlanStep{ | ||
| 806 | planStep("first", PlanInProgress), | 814 | planStep("first", PlanInProgress), | ||
| 807 | planStep("second", PlanInProgress), | 815 | planStep("second", PlanInProgress), | ||
| 808 | planStep("third", PlanPending), | 816 | planStep("third", PlanPending), | ||
| 809 | }}, | 817 | }}, | ||
| 810 | - | want: []PlanStep{planStep("first", PlanInProgress), planStep("third", PlanPending)}, | 818 | + | want: []PlanStep{planStep("first", PlanInProgress), planStep("second", PlanInProgress), planStep("third", PlanPending)}, |
| 811 | }, | 819 | }, | ||
| 812 | } | 820 | } | ||
| 813 | for _, tt := range tests { | 821 | for _, tt := range tests { | ||
M packages/core/session.go +3 -9
| @@ -1464,15 +1464,14 @@ func (s *Session) assignGoalToMission(goal, previous *SessionGoal) error { | |||
| 1464 | 1464 | return nil | |
| 1465 | 1465 | } | |
| 1466 | 1466 | ||
| 1467 | - | // normalizeSessionPlan drops steps that carry no text or an unknown status and | |
| 1468 | - | // keeps only the first in_progress step. It returns nil when nothing remains. | |
| 1467 | + | // normalizeSessionPlan drops steps that carry no text or an unknown status. It | |
| 1468 | + | // returns nil when nothing remains. | |
| 1469 | 1469 | // It does not cap the plan length. | |
| 1470 | 1470 | func normalizeSessionPlan(plan *SessionPlan) *SessionPlan { | |
| 1471 | 1471 | if plan == nil { | |
| 1472 | 1472 | return nil | |
| 1473 | 1473 | } | |
| 1474 | 1474 | steps := make([]PlanStep, 0, len(plan.Steps)) | |
| 1475 | - | seenInProgress := false | |
| 1476 | 1475 | for _, step := range plan.Steps { | |
| 1477 | 1476 | if strings.TrimSpace(step.Step) == "" { | |
| 1478 | 1477 | continue | |
| @@ -1485,12 +1484,7 @@ func normalizeSessionPlan(plan *SessionPlan) *SessionPlan { | |||
| 1485 | 1484 | status = PlanPending | |
| 1486 | 1485 | } | |
| 1487 | 1486 | switch status { | |
| 1488 | - | case PlanPending, PlanCompleted: | |
| 1489 | - | case PlanInProgress: | |
| 1490 | - | if seenInProgress { | |
| 1491 | - | continue | |
| 1492 | - | } | |
| 1493 | - | seenInProgress = true | |
| 1487 | + | case PlanPending, PlanCompleted, PlanInProgress: | |
| 1494 | 1488 | default: | |
| 1495 | 1489 | continue | |
| 1496 | 1490 | } | |
| @@ -1464,15 +1464,14 @@ func (s *Session) assignGoalToMission(goal, previous *SessionGoal) error { | |||||
| 1464 | return nil | 1464 | return nil | ||
| 1465 | } | 1465 | } | ||
| 1466 | 1466 | ||||
| 1467 | - | // normalizeSessionPlan drops steps that carry no text or an unknown status and | 1467 | + | // normalizeSessionPlan drops steps that carry no text or an unknown status. It |
| 1468 | - | // keeps only the first in_progress step. It returns nil when nothing remains. | 1468 | + | // returns nil when nothing remains. |
| 1469 | // It does not cap the plan length. | 1469 | // It does not cap the plan length. | ||
| 1470 | func normalizeSessionPlan(plan *SessionPlan) *SessionPlan { | 1470 | func normalizeSessionPlan(plan *SessionPlan) *SessionPlan { | ||
| 1471 | if plan == nil { | 1471 | if plan == nil { | ||
| 1472 | return nil | 1472 | return nil | ||
| 1473 | } | 1473 | } | ||
| 1474 | steps := make([]PlanStep, 0, len(plan.Steps)) | 1474 | steps := make([]PlanStep, 0, len(plan.Steps)) | ||
| 1475 | - | seenInProgress := false | |||
| 1476 | for _, step := range plan.Steps { | 1475 | for _, step := range plan.Steps { | ||
| 1477 | if strings.TrimSpace(step.Step) == "" { | 1476 | if strings.TrimSpace(step.Step) == "" { | ||
| 1478 | continue | 1477 | continue | ||
| @@ -1485,12 +1484,7 @@ func normalizeSessionPlan(plan *SessionPlan) *SessionPlan { | |||||
| 1485 | status = PlanPending | 1484 | status = PlanPending | ||
| 1486 | } | 1485 | } | ||
| 1487 | switch status { | 1486 | switch status { | ||
| 1488 | - | case PlanPending, PlanCompleted: | 1487 | + | case PlanPending, PlanCompleted, PlanInProgress: |
| 1489 | - | case PlanInProgress: | |||
| 1490 | - | if seenInProgress { | |||
| 1491 | - | continue | |||
| 1492 | - | } | |||
| 1493 | - | seenInProgress = true | |||
| 1494 | default: | 1488 | default: | ||
| 1495 | continue | 1489 | continue | ||
| 1496 | } | 1490 | } | ||