merge(main): update Ponytail branch with latest main
6 files · +163 -37
M examples/extensions/tasked-phases/README.md +5 -3
| @@ -57,6 +57,8 @@ available, the plan is also stored as extension-owned state on each session | |||
| 57 | 57 | branch and restored when sessions are opened, switched, or forked. | |
| 58 | 58 | ||
| 59 | 59 | Before each model turn, the extension supplies only the active phase, its goal, | |
| 60 | - | and a bounded set of incomplete tasks as hidden context. Completed phases and | |
| 61 | - | future phases are not repeated in every provider request; use `get_status` or | |
| 62 | - | `/phases` when the full checklist is needed. | |
| 60 | + | and a bounded set of incomplete tasks as hidden context. The same context | |
| 61 | + | reminds the model to update the checklist continuously, mark each task as soon | |
| 62 | + | as it is completed, and advance the current phase when work moves forward. | |
| 63 | + | Completed phases and future phases are not repeated in every provider request; | |
| 64 | + | use `get_status` or `/phases` when the full checklist is needed. | |
| @@ -57,6 +57,8 @@ available, the plan is also stored as extension-owned state on each session | |||||
| 57 | branch and restored when sessions are opened, switched, or forked. | 57 | branch and restored when sessions are opened, switched, or forked. | ||
| 58 | 58 | ||||
| 59 | Before each model turn, the extension supplies only the active phase, its goal, | 59 | Before each model turn, the extension supplies only the active phase, its goal, | ||
| 60 | - | and a bounded set of incomplete tasks as hidden context. Completed phases and | 60 | + | and a bounded set of incomplete tasks as hidden context. The same context |
| 61 | - | future phases are not repeated in every provider request; use `get_status` or | 61 | + | reminds the model to update the checklist continuously, mark each task as soon |
| 62 | - | `/phases` when the full checklist is needed. | 62 | + | as it is completed, and advance the current phase when work moves forward. |
| 63 | + | Completed phases and future phases are not repeated in every provider request; | |||
| 64 | + | use `get_status` or `/phases` when the full checklist is needed. | |||
M examples/extensions/tasked-phases/main.go +1 -1
| @@ -118,7 +118,7 @@ func main() { | |||
| 118 | 118 | }) | |
| 119 | 119 | ||
| 120 | 120 | e.Tool(toolName, | |
| 121 | - | "Persist and update a structured spec, phased plan, current phase, and checklist tasks. Use it for spec-driven planning and progress tracking.", | |
| 121 | + | "Persist and update a structured spec, phased plan, current phase, and checklist tasks. Keep the plan current throughout implementation: update it continuously, call set_task_checked immediately after each completed checklist task, and call set_current_phase when moving to another phase.", | |
| 122 | 122 | json.RawMessage(toolSchema), a.handleTool) | |
| 123 | 123 | e.Command("phases", "Show the current spec, phases, and checklist state", a.handleCommand) | |
| 124 | 124 | e.OnPanelKey(panelID, a.handlePanelKey, a.handlePanelClose) | |
| @@ -118,7 +118,7 @@ func main() { | |||||
| 118 | }) | 118 | }) | ||
| 119 | 119 | ||||
| 120 | e.Tool(toolName, | 120 | e.Tool(toolName, | ||
| 121 | - | "Persist and update a structured spec, phased plan, current phase, and checklist tasks. Use it for spec-driven planning and progress tracking.", | 121 | + | "Persist and update a structured spec, phased plan, current phase, and checklist tasks. Keep the plan current throughout implementation: update it continuously, call set_task_checked immediately after each completed checklist task, and call set_current_phase when moving to another phase.", |
| 122 | json.RawMessage(toolSchema), a.handleTool) | 122 | json.RawMessage(toolSchema), a.handleTool) | ||
| 123 | e.Command("phases", "Show the current spec, phases, and checklist state", a.handleCommand) | 123 | e.Command("phases", "Show the current spec, phases, and checklist state", a.handleCommand) | ||
| 124 | e.OnPanelKey(panelID, a.handlePanelKey, a.handlePanelClose) | 124 | e.OnPanelKey(panelID, a.handlePanelKey, a.handlePanelClose) | ||
M examples/extensions/tasked-phases/state.go +7 -0
| @@ -915,7 +915,14 @@ func buildTurnContext(state PlanState) string { | |||
| 915 | 915 | return "" | |
| 916 | 916 | } | |
| 917 | 917 | lines := []string{ | |
| 918 | + | "[TASKED PHASES STATE - SOURCE OF TRUTH]", | |
| 918 | 919 | "Tasked phases focus:", | |
| 920 | + | "Call tasked_phases get_status if the exact full checklist is needed.", | |
| 921 | + | "Update tasked_phases continuously while implementing, not only at the end.", | |
| 922 | + | "After completing each checklist task, immediately call tasked_phases set_task_checked.", | |
| 923 | + | "After moving to another phase, immediately call tasked_phases set_current_phase.", | |
| 924 | + | "When a phase is shown as Title [id], pass phaseId as the raw id only, without brackets.", | |
| 925 | + | "Do not rely on prose alone for completion state.", | |
| 919 | 926 | fmt.Sprintf("Current phase: %s [%s]", formatPhaseTitle(*phase), formatTurnContextID(phase.ID)), | |
| 920 | 927 | } | |
| 921 | 928 | if phase.Goal != "" { | |
| @@ -915,7 +915,14 @@ func buildTurnContext(state PlanState) string { | |||||
| 915 | return "" | 915 | return "" | ||
| 916 | } | 916 | } | ||
| 917 | lines := []string{ | 917 | lines := []string{ | ||
| 918 | + | "[TASKED PHASES STATE - SOURCE OF TRUTH]", | |||
| 918 | "Tasked phases focus:", | 919 | "Tasked phases focus:", | ||
| 920 | + | "Call tasked_phases get_status if the exact full checklist is needed.", | |||
| 921 | + | "Update tasked_phases continuously while implementing, not only at the end.", | |||
| 922 | + | "After completing each checklist task, immediately call tasked_phases set_task_checked.", | |||
| 923 | + | "After moving to another phase, immediately call tasked_phases set_current_phase.", | |||
| 924 | + | "When a phase is shown as Title [id], pass phaseId as the raw id only, without brackets.", | |||
| 925 | + | "Do not rely on prose alone for completion state.", | |||
| 919 | fmt.Sprintf("Current phase: %s [%s]", formatPhaseTitle(*phase), formatTurnContextID(phase.ID)), | 926 | fmt.Sprintf("Current phase: %s [%s]", formatPhaseTitle(*phase), formatTurnContextID(phase.ID)), | ||
| 920 | } | 927 | } | ||
| 921 | if phase.Goal != "" { | 928 | if phase.Goal != "" { | ||
M examples/extensions/tasked-phases/state_test.go +16 -0
| @@ -238,6 +238,22 @@ func TestTurnContextContainsOnlyCurrentFocus(t *testing.T) { | |||
| 238 | 238 | } | |
| 239 | 239 | } | |
| 240 | 240 | ||
| 241 | + | func TestTurnContextRemindsAgentToTrackProgressContinuously(t *testing.T) { | |
| 242 | + | context := buildTurnContext(sampleState()) | |
| 243 | + | for _, want := range []string{ | |
| 244 | + | "[TASKED PHASES STATE - SOURCE OF TRUTH]", | |
| 245 | + | "Update tasked_phases continuously while implementing, not only at the end.", | |
| 246 | + | "After completing each checklist task, immediately call tasked_phases set_task_checked.", | |
| 247 | + | "After moving to another phase, immediately call tasked_phases set_current_phase.", | |
| 248 | + | "When a phase is shown as Title [id], pass phaseId as the raw id only, without brackets.", | |
| 249 | + | "Do not rely on prose alone for completion state.", | |
| 250 | + | } { | |
| 251 | + | if !strings.Contains(context, want) { | |
| 252 | + | t.Errorf("turn context missing continuous-tracking guidance %q: %s", want, context) | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | ||
| 241 | 257 | func TestTurnContextIsBoundedForUntrustedIdentifiers(t *testing.T) { | |
| 242 | 258 | state := sampleState() | |
| 243 | 259 | state.Phases[1].ID = strings.Repeat("phase-id-", 1000) | |
| @@ -238,6 +238,22 @@ func TestTurnContextContainsOnlyCurrentFocus(t *testing.T) { | |||||
| 238 | } | 238 | } | ||
| 239 | } | 239 | } | ||
| 240 | 240 | ||||
| 241 | + | func TestTurnContextRemindsAgentToTrackProgressContinuously(t *testing.T) { | |||
| 242 | + | context := buildTurnContext(sampleState()) | |||
| 243 | + | for _, want := range []string{ | |||
| 244 | + | "[TASKED PHASES STATE - SOURCE OF TRUTH]", | |||
| 245 | + | "Update tasked_phases continuously while implementing, not only at the end.", | |||
| 246 | + | "After completing each checklist task, immediately call tasked_phases set_task_checked.", | |||
| 247 | + | "After moving to another phase, immediately call tasked_phases set_current_phase.", | |||
| 248 | + | "When a phase is shown as Title [id], pass phaseId as the raw id only, without brackets.", | |||
| 249 | + | "Do not rely on prose alone for completion state.", | |||
| 250 | + | } { | |||
| 251 | + | if !strings.Contains(context, want) { | |||
| 252 | + | t.Errorf("turn context missing continuous-tracking guidance %q: %s", want, context) | |||
| 253 | + | } | |||
| 254 | + | } | |||
| 255 | + | } | |||
| 256 | + | ||||
| 241 | func TestTurnContextIsBoundedForUntrustedIdentifiers(t *testing.T) { | 257 | func TestTurnContextIsBoundedForUntrustedIdentifiers(t *testing.T) { | ||
| 242 | state := sampleState() | 258 | state := sampleState() | ||
| 243 | state.Phases[1].ID = strings.Repeat("phase-id-", 1000) | 259 | state.Phases[1].ID = strings.Repeat("phase-id-", 1000) | ||
M packages/agent/modes/auto_subagents_test.go +85 -0
| @@ -4,11 +4,13 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | 6 | "strings" | |
| 7 | + | "sync" | |
| 7 | 8 | "testing" | |
| 8 | 9 | "time" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/patriceckhart/zot/packages/agent/subagents" | |
| 11 | 12 | "github.com/patriceckhart/zot/packages/core" | |
| 13 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 12 | 14 | ) | |
| 13 | 15 | ||
| 14 | 16 | func TestAutoSubagentsSystemPromptTogglesProfileManifestWithDelegationGuidance(t *testing.T) { | |
| @@ -151,6 +153,89 @@ func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) { | |||
| 151 | 153 | } | |
| 152 | 154 | } | |
| 153 | 155 | ||
| 156 | + | type autoSubagentsQueueClient struct { | |
| 157 | + | requests chan provider.Request | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func (c *autoSubagentsQueueClient) Name() string { return "auto-subagents-queue-test" } | |
| 161 | + | ||
| 162 | + | func (c *autoSubagentsQueueClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) { | |
| 163 | + | c.requests <- req | |
| 164 | + | out := make(chan provider.Event, 1) | |
| 165 | + | go func() { | |
| 166 | + | defer close(out) | |
| 167 | + | out <- provider.EventDone{ | |
| 168 | + | Stop: provider.StopEnd, | |
| 169 | + | Message: provider.Message{ | |
| 170 | + | Role: provider.RoleAssistant, | |
| 171 | + | Content: []provider.Content{provider.TextBlock{Text: "done"}}, | |
| 172 | + | }, | |
| 173 | + | } | |
| 174 | + | }() | |
| 175 | + | return out, nil | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func TestAutoSubagentsUpdateQueuedAtTurnEndStartsFollowUp(t *testing.T) { | |
| 179 | + | client := &autoSubagentsQueueClient{requests: make(chan provider.Request, 2)} | |
| 180 | + | agent := core.NewAgent(client, "test-model", "", nil) | |
| 181 | + | interactive := NewInteractive(InteractiveConfig{Agent: agent}) | |
| 182 | + | interactive.runCtx = context.Background() | |
| 183 | + | ||
| 184 | + | var once sync.Once | |
| 185 | + | agent.OnEvent = func(ev core.AgentEvent) { | |
| 186 | + | if _, ok := ev.(core.EvDone); !ok { | |
| 187 | + | return | |
| 188 | + | } | |
| 189 | + | once.Do(func() { | |
| 190 | + | interactive.flushSupervisorSummary([]*subagentWatchEntry{{ | |
| 191 | + | agent: &subagents.Agent{}, | |
| 192 | + | task: "smoke test", | |
| 193 | + | }}) | |
| 194 | + | }) | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | interactive.startTurn(context.Background(), "initial") | |
| 198 | + | select { | |
| 199 | + | case <-client.requests: | |
| 200 | + | case <-time.After(2 * time.Second): | |
| 201 | + | t.Fatal("initial request did not start") | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | var followUp provider.Request | |
| 205 | + | select { | |
| 206 | + | case followUp = <-client.requests: | |
| 207 | + | case <-time.After(2 * time.Second): | |
| 208 | + | t.Fatal("queued auto-subagents update did not start a follow-up request") | |
| 209 | + | } | |
| 210 | + | foundUpdate := false | |
| 211 | + | for _, message := range followUp.Messages { | |
| 212 | + | for _, content := range message.Content { | |
| 213 | + | if text, ok := content.(provider.TextBlock); ok && strings.Contains(text.Text, "[auto-subagents update]") { | |
| 214 | + | foundUpdate = true | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | } | |
| 218 | + | if !foundUpdate { | |
| 219 | + | t.Fatalf("follow-up request did not contain the auto-subagents update: %#v", followUp.Messages) | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | deadline := time.NewTimer(2 * time.Second) | |
| 223 | + | defer deadline.Stop() | |
| 224 | + | for { | |
| 225 | + | interactive.mu.Lock() | |
| 226 | + | busy := interactive.busy | |
| 227 | + | interactive.mu.Unlock() | |
| 228 | + | if !busy && agent.QueuedMessageCount() == 0 { | |
| 229 | + | return | |
| 230 | + | } | |
| 231 | + | select { | |
| 232 | + | case <-deadline.C: | |
| 233 | + | t.Fatal("interactive turn remained busy or retained a queued update") | |
| 234 | + | case <-time.After(time.Millisecond): | |
| 235 | + | } | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | ||
| 154 | 239 | func newQueuedAutoSubagentsInteractive() *Interactive { | |
| 155 | 240 | return &Interactive{ | |
| 156 | 241 | agent: &core.Agent{}, | |
| @@ -4,11 +4,13 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "strings" | 6 | "strings" | ||
| 7 | + | "sync" | |||
| 7 | "testing" | 8 | "testing" | ||
| 8 | "time" | 9 | "time" | ||
| 9 | 10 | ||||
| 10 | "github.com/patriceckhart/zot/packages/agent/subagents" | 11 | "github.com/patriceckhart/zot/packages/agent/subagents" | ||
| 11 | "github.com/patriceckhart/zot/packages/core" | 12 | "github.com/patriceckhart/zot/packages/core" | ||
| 13 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 12 | ) | 14 | ) | ||
| 13 | 15 | ||||
| 14 | func TestAutoSubagentsSystemPromptTogglesProfileManifestWithDelegationGuidance(t *testing.T) { | 16 | func TestAutoSubagentsSystemPromptTogglesProfileManifestWithDelegationGuidance(t *testing.T) { | ||
| @@ -151,6 +153,89 @@ func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) { | |||||
| 151 | } | 153 | } | ||
| 152 | } | 154 | } | ||
| 153 | 155 | ||||
| 156 | + | type autoSubagentsQueueClient struct { | |||
| 157 | + | requests chan provider.Request | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | func (c *autoSubagentsQueueClient) Name() string { return "auto-subagents-queue-test" } | |||
| 161 | + | ||||
| 162 | + | func (c *autoSubagentsQueueClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) { | |||
| 163 | + | c.requests <- req | |||
| 164 | + | out := make(chan provider.Event, 1) | |||
| 165 | + | go func() { | |||
| 166 | + | defer close(out) | |||
| 167 | + | out <- provider.EventDone{ | |||
| 168 | + | Stop: provider.StopEnd, | |||
| 169 | + | Message: provider.Message{ | |||
| 170 | + | Role: provider.RoleAssistant, | |||
| 171 | + | Content: []provider.Content{provider.TextBlock{Text: "done"}}, | |||
| 172 | + | }, | |||
| 173 | + | } | |||
| 174 | + | }() | |||
| 175 | + | return out, nil | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func TestAutoSubagentsUpdateQueuedAtTurnEndStartsFollowUp(t *testing.T) { | |||
| 179 | + | client := &autoSubagentsQueueClient{requests: make(chan provider.Request, 2)} | |||
| 180 | + | agent := core.NewAgent(client, "test-model", "", nil) | |||
| 181 | + | interactive := NewInteractive(InteractiveConfig{Agent: agent}) | |||
| 182 | + | interactive.runCtx = context.Background() | |||
| 183 | + | ||||
| 184 | + | var once sync.Once | |||
| 185 | + | agent.OnEvent = func(ev core.AgentEvent) { | |||
| 186 | + | if _, ok := ev.(core.EvDone); !ok { | |||
| 187 | + | return | |||
| 188 | + | } | |||
| 189 | + | once.Do(func() { | |||
| 190 | + | interactive.flushSupervisorSummary([]*subagentWatchEntry{{ | |||
| 191 | + | agent: &subagents.Agent{}, | |||
| 192 | + | task: "smoke test", | |||
| 193 | + | }}) | |||
| 194 | + | }) | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | interactive.startTurn(context.Background(), "initial") | |||
| 198 | + | select { | |||
| 199 | + | case <-client.requests: | |||
| 200 | + | case <-time.After(2 * time.Second): | |||
| 201 | + | t.Fatal("initial request did not start") | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | var followUp provider.Request | |||
| 205 | + | select { | |||
| 206 | + | case followUp = <-client.requests: | |||
| 207 | + | case <-time.After(2 * time.Second): | |||
| 208 | + | t.Fatal("queued auto-subagents update did not start a follow-up request") | |||
| 209 | + | } | |||
| 210 | + | foundUpdate := false | |||
| 211 | + | for _, message := range followUp.Messages { | |||
| 212 | + | for _, content := range message.Content { | |||
| 213 | + | if text, ok := content.(provider.TextBlock); ok && strings.Contains(text.Text, "[auto-subagents update]") { | |||
| 214 | + | foundUpdate = true | |||
| 215 | + | } | |||
| 216 | + | } | |||
| 217 | + | } | |||
| 218 | + | if !foundUpdate { | |||
| 219 | + | t.Fatalf("follow-up request did not contain the auto-subagents update: %#v", followUp.Messages) | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | deadline := time.NewTimer(2 * time.Second) | |||
| 223 | + | defer deadline.Stop() | |||
| 224 | + | for { | |||
| 225 | + | interactive.mu.Lock() | |||
| 226 | + | busy := interactive.busy | |||
| 227 | + | interactive.mu.Unlock() | |||
| 228 | + | if !busy && agent.QueuedMessageCount() == 0 { | |||
| 229 | + | return | |||
| 230 | + | } | |||
| 231 | + | select { | |||
| 232 | + | case <-deadline.C: | |||
| 233 | + | t.Fatal("interactive turn remained busy or retained a queued update") | |||
| 234 | + | case <-time.After(time.Millisecond): | |||
| 235 | + | } | |||
| 236 | + | } | |||
| 237 | + | } | |||
| 238 | + | ||||
| 154 | func newQueuedAutoSubagentsInteractive() *Interactive { | 239 | func newQueuedAutoSubagentsInteractive() *Interactive { | ||
| 155 | return &Interactive{ | 240 | return &Interactive{ | ||
| 156 | agent: &core.Agent{}, | 241 | agent: &core.Agent{}, | ||
M packages/agent/modes/interactive.go +49 -33
| @@ -3608,19 +3608,17 @@ func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) { | |||
| 3608 | 3608 | i.maybeStartSessionTitle(i.runCtx, text) | |
| 3609 | 3609 | i.mu.Lock() | |
| 3610 | 3610 | if i.busy { | |
| 3611 | - | // Queue text only; images are dropped for queued prompts. Compaction | |
| 3612 | - | // uses the host queue because it has no active agent loop to drain | |
| 3613 | - | // Agent.QueueMessage entries. | |
| 3614 | - | ag := i.agent | |
| 3615 | - | compacting := i.compacting | |
| 3616 | - | i.mu.Unlock() | |
| 3617 | - | if ag != nil && !compacting { | |
| 3618 | - | ag.QueueMessage(text) | |
| 3611 | + | // Queue text only; images are dropped for queued prompts. Keep the | |
| 3612 | + | // interactive mutex held through Agent.QueueMessage so turn teardown | |
| 3613 | + | // cannot inspect the agent queue between the busy check and enqueue. | |
| 3614 | + | // Compaction uses the host queue because it has no active agent loop | |
| 3615 | + | // to drain Agent.QueueMessage entries. | |
| 3616 | + | if i.agent != nil && !i.compacting { | |
| 3617 | + | i.agent.QueueMessage(text) | |
| 3619 | 3618 | } else { | |
| 3620 | - | i.mu.Lock() | |
| 3621 | 3619 | i.queued = append(i.queued, text) | |
| 3622 | - | i.mu.Unlock() | |
| 3623 | 3620 | } | |
| 3621 | + | i.mu.Unlock() | |
| 3624 | 3622 | i.invalidate() | |
| 3625 | 3623 | return | |
| 3626 | 3624 | } | |
| @@ -5802,22 +5800,20 @@ func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) { | |||
| 5802 | 5800 | i.invalidate() | |
| 5803 | 5801 | return | |
| 5804 | 5802 | } | |
| 5805 | - | busy := i.busy | |
| 5806 | - | compacting := i.compacting | |
| 5807 | - | ag := i.agent | |
| 5808 | - | i.mu.Unlock() | |
| 5809 | - | ||
| 5810 | - | if busy { | |
| 5811 | - | if !compacting { | |
| 5812 | - | ag.QueueMessage(prompt) | |
| 5813 | - | } else { | |
| 5814 | - | i.mu.Lock() | |
| 5803 | + | if i.busy { | |
| 5804 | + | // Keep the mutex held while enqueueing so the turn-completion | |
| 5805 | + | // goroutine cannot publish idle and inspect an empty queue between | |
| 5806 | + | // this check and the append. | |
| 5807 | + | if i.compacting { | |
| 5815 | 5808 | i.queued = append(i.queued, prompt) | |
| 5816 | - | i.mu.Unlock() | |
| 5809 | + | } else { | |
| 5810 | + | i.agent.QueueMessage(prompt) | |
| 5817 | 5811 | } | |
| 5812 | + | i.mu.Unlock() | |
| 5818 | 5813 | i.invalidate() | |
| 5819 | 5814 | return | |
| 5820 | 5815 | } | |
| 5816 | + | i.mu.Unlock() | |
| 5821 | 5817 | i.startTurn(ctx, prompt) | |
| 5822 | 5818 | } | |
| 5823 | 5819 | ||
| @@ -6250,8 +6246,9 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { | |||
| 6250 | 6246 | summary, err := i.agent.Compact(ctx, keepTail, sink) | |
| 6251 | 6247 | _ = summary | |
| 6252 | 6248 | i.mu.Lock() | |
| 6253 | - | i.busy = false | |
| 6254 | - | i.compacting = false | |
| 6249 | + | // Keep busy/compacting asserted while cleanup and queue selection run. | |
| 6250 | + | // Completion updates can arrive in this window; clearing busy before | |
| 6251 | + | // inspecting the queues lets them race a new turn or be stranded. | |
| 6255 | 6252 | i.resetStreamingStateLocked() | |
| 6256 | 6253 | i.cancelTurn = nil | |
| 6257 | 6254 | i.autoCompacting = false | |
| @@ -6333,12 +6330,13 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { | |||
| 6333 | 6330 | continueAutomatically = true | |
| 6334 | 6331 | } | |
| 6335 | 6332 | } | |
| 6336 | - | if hasNext || continueExisting || continueAutomatically { | |
| 6337 | - | // Keep the host busy while handing the next turn back to the | |
| 6338 | - | // agent. Prompts submitted in this hand-off window then enter | |
| 6339 | - | // the agent queue instead of racing a second startTurn call. | |
| 6340 | - | i.busy = true | |
| 6341 | - | } | |
| 6333 | + | // Keep the host busy until the hand-off decision is committed under | |
| 6334 | + | // the mutex. A completion update arriving after the compaction result | |
| 6335 | + | // but before this assignment is then queued for the selected follow-up | |
| 6336 | + | // instead of starting a competing turn. | |
| 6337 | + | i.busy = hasNext || continueExisting || continueAutomatically | |
| 6338 | + | i.compacting = false | |
| 6339 | + | i.autoCompacting = false | |
| 6342 | 6340 | i.mu.Unlock() | |
| 6343 | 6341 | runPendingIdleWork(pendingIdleWork) | |
| 6344 | 6342 | i.invalidate() | |
| @@ -6520,9 +6518,19 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 6520 | 6518 | i.mu.Lock() | |
| 6521 | 6519 | needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked() | |
| 6522 | 6520 | if needsPreCompact { | |
| 6523 | - | i.pendingCompactPrompt = prompt | |
| 6524 | - | i.pendingCompactImages = append([]provider.ImageBlock(nil), images...) | |
| 6525 | - | i.hasPendingCompactPrompt = true | |
| 6521 | + | // Reserve the compaction hand-off before releasing i.mu. A completion | |
| 6522 | + | // update arriving in the small gap before runCompact acquires the | |
| 6523 | + | // mutex must join the host queue rather than starting a competing turn. | |
| 6524 | + | i.busy = true | |
| 6525 | + | i.compacting = true | |
| 6526 | + | i.autoCompacting = true | |
| 6527 | + | if continueExisting { | |
| 6528 | + | i.continueAfterCompact = true | |
| 6529 | + | } else { | |
| 6530 | + | i.pendingCompactPrompt = prompt | |
| 6531 | + | i.pendingCompactImages = append([]provider.ImageBlock(nil), images...) | |
| 6532 | + | i.hasPendingCompactPrompt = true | |
| 6533 | + | } | |
| 6526 | 6534 | i.statusErr = "" | |
| 6527 | 6535 | i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending...")) | |
| 6528 | 6536 | i.pendingPostCompactNote = "context auto-compacted; sending your last message" | |
| @@ -6596,7 +6604,11 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 6596 | 6604 | err = i.agent.Prompt(ctx, prompt, images, sink) | |
| 6597 | 6605 | } | |
| 6598 | 6606 | i.mu.Lock() | |
| 6599 | - | i.busy = false | |
| 6607 | + | // Keep busy asserted through final cleanup and queue selection. A | |
| 6608 | + | // watcher may enqueue a completion summary concurrently with the | |
| 6609 | + | // provider's final event; publishing idle before inspecting queues can | |
| 6610 | + | // strand that summary in the core agent queue or start an overlapping | |
| 6611 | + | // turn. | |
| 6600 | 6612 | // Don't touch streamPending / streamFlushPending here — the | |
| 6601 | 6613 | // pacer may still be draining the final deltas and needs to | |
| 6602 | 6614 | // paint them even though Prompt has returned. It will reset | |
| @@ -6686,8 +6698,10 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 6686 | 6698 | if i.agent != nil { | |
| 6687 | 6699 | agentQueued = i.agent.QueuedMessageCount() | |
| 6688 | 6700 | } | |
| 6701 | + | continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil | |
| 6689 | 6702 | shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked() | |
| 6690 | 6703 | alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact) | |
| 6704 | + | i.busy = hasNext || continueQueued || recoverContextOverflow || shouldAutoCompact | |
| 6691 | 6705 | i.mu.Unlock() | |
| 6692 | 6706 | if alertReason != "" { | |
| 6693 | 6707 | i.scheduleMainAlert(alertReason) | |
| @@ -6704,6 +6718,8 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 6704 | 6718 | switch { | |
| 6705 | 6719 | case hasNext: | |
| 6706 | 6720 | i.startTurn(parent, next) | |
| 6721 | + | case continueQueued: | |
| 6722 | + | i.startTurnRequest(parent, "", nil, true, false) | |
| 6707 | 6723 | case offer: | |
| 6708 | 6724 | i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs) | |
| 6709 | 6725 | case recoverContextOverflow: | |
| @@ -3608,19 +3608,17 @@ func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) { | |||||
| 3608 | i.maybeStartSessionTitle(i.runCtx, text) | 3608 | i.maybeStartSessionTitle(i.runCtx, text) | ||
| 3609 | i.mu.Lock() | 3609 | i.mu.Lock() | ||
| 3610 | if i.busy { | 3610 | if i.busy { | ||
| 3611 | - | // Queue text only; images are dropped for queued prompts. Compaction | 3611 | + | // Queue text only; images are dropped for queued prompts. Keep the |
| 3612 | - | // uses the host queue because it has no active agent loop to drain | 3612 | + | // interactive mutex held through Agent.QueueMessage so turn teardown |
| 3613 | - | // Agent.QueueMessage entries. | 3613 | + | // cannot inspect the agent queue between the busy check and enqueue. |
| 3614 | - | ag := i.agent | 3614 | + | // Compaction uses the host queue because it has no active agent loop |
| 3615 | - | compacting := i.compacting | 3615 | + | // to drain Agent.QueueMessage entries. |
| 3616 | - | i.mu.Unlock() | 3616 | + | if i.agent != nil && !i.compacting { |
| 3617 | - | if ag != nil && !compacting { | 3617 | + | i.agent.QueueMessage(text) |
| 3618 | - | ag.QueueMessage(text) | |||
| 3619 | } else { | 3618 | } else { | ||
| 3620 | - | i.mu.Lock() | |||
| 3621 | i.queued = append(i.queued, text) | 3619 | i.queued = append(i.queued, text) | ||
| 3622 | - | i.mu.Unlock() | |||
| 3623 | } | 3620 | } | ||
| 3621 | + | i.mu.Unlock() | |||
| 3624 | i.invalidate() | 3622 | i.invalidate() | ||
| 3625 | return | 3623 | return | ||
| 3626 | } | 3624 | } | ||
| @@ -5802,22 +5800,20 @@ func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) { | |||||
| 5802 | i.invalidate() | 5800 | i.invalidate() | ||
| 5803 | return | 5801 | return | ||
| 5804 | } | 5802 | } | ||
| 5805 | - | busy := i.busy | 5803 | + | if i.busy { |
| 5806 | - | compacting := i.compacting | 5804 | + | // Keep the mutex held while enqueueing so the turn-completion |
| 5807 | - | ag := i.agent | 5805 | + | // goroutine cannot publish idle and inspect an empty queue between |
| 5808 | - | i.mu.Unlock() | 5806 | + | // this check and the append. |
| 5809 | - | 5807 | + | if i.compacting { | |
| 5810 | - | if busy { | |||
| 5811 | - | if !compacting { | |||
| 5812 | - | ag.QueueMessage(prompt) | |||
| 5813 | - | } else { | |||
| 5814 | - | i.mu.Lock() | |||
| 5815 | i.queued = append(i.queued, prompt) | 5808 | i.queued = append(i.queued, prompt) | ||
| 5816 | - | i.mu.Unlock() | 5809 | + | } else { |
| 5810 | + | i.agent.QueueMessage(prompt) | |||
| 5817 | } | 5811 | } | ||
| 5812 | + | i.mu.Unlock() | |||
| 5818 | i.invalidate() | 5813 | i.invalidate() | ||
| 5819 | return | 5814 | return | ||
| 5820 | } | 5815 | } | ||
| 5816 | + | i.mu.Unlock() | |||
| 5821 | i.startTurn(ctx, prompt) | 5817 | i.startTurn(ctx, prompt) | ||
| 5822 | } | 5818 | } | ||
| 5823 | 5819 | ||||
| @@ -6250,8 +6246,9 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { | |||||
| 6250 | summary, err := i.agent.Compact(ctx, keepTail, sink) | 6246 | summary, err := i.agent.Compact(ctx, keepTail, sink) | ||
| 6251 | _ = summary | 6247 | _ = summary | ||
| 6252 | i.mu.Lock() | 6248 | i.mu.Lock() | ||
| 6253 | - | i.busy = false | 6249 | + | // Keep busy/compacting asserted while cleanup and queue selection run. |
| 6254 | - | i.compacting = false | 6250 | + | // Completion updates can arrive in this window; clearing busy before |
| 6251 | + | // inspecting the queues lets them race a new turn or be stranded. | |||
| 6255 | i.resetStreamingStateLocked() | 6252 | i.resetStreamingStateLocked() | ||
| 6256 | i.cancelTurn = nil | 6253 | i.cancelTurn = nil | ||
| 6257 | i.autoCompacting = false | 6254 | i.autoCompacting = false | ||
| @@ -6333,12 +6330,13 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { | |||||
| 6333 | continueAutomatically = true | 6330 | continueAutomatically = true | ||
| 6334 | } | 6331 | } | ||
| 6335 | } | 6332 | } | ||
| 6336 | - | if hasNext || continueExisting || continueAutomatically { | 6333 | + | // Keep the host busy until the hand-off decision is committed under |
| 6337 | - | // Keep the host busy while handing the next turn back to the | 6334 | + | // the mutex. A completion update arriving after the compaction result |
| 6338 | - | // agent. Prompts submitted in this hand-off window then enter | 6335 | + | // but before this assignment is then queued for the selected follow-up |
| 6339 | - | // the agent queue instead of racing a second startTurn call. | 6336 | + | // instead of starting a competing turn. |
| 6340 | - | i.busy = true | 6337 | + | i.busy = hasNext || continueExisting || continueAutomatically |
| 6341 | - | } | 6338 | + | i.compacting = false |
| 6339 | + | i.autoCompacting = false | |||
| 6342 | i.mu.Unlock() | 6340 | i.mu.Unlock() | ||
| 6343 | runPendingIdleWork(pendingIdleWork) | 6341 | runPendingIdleWork(pendingIdleWork) | ||
| 6344 | i.invalidate() | 6342 | i.invalidate() | ||
| @@ -6520,9 +6518,19 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 6520 | i.mu.Lock() | 6518 | i.mu.Lock() | ||
| 6521 | needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked() | 6519 | needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked() | ||
| 6522 | if needsPreCompact { | 6520 | if needsPreCompact { | ||
| 6523 | - | i.pendingCompactPrompt = prompt | 6521 | + | // Reserve the compaction hand-off before releasing i.mu. A completion |
| 6524 | - | i.pendingCompactImages = append([]provider.ImageBlock(nil), images...) | 6522 | + | // update arriving in the small gap before runCompact acquires the |
| 6525 | - | i.hasPendingCompactPrompt = true | 6523 | + | // mutex must join the host queue rather than starting a competing turn. |
| 6524 | + | i.busy = true | |||
| 6525 | + | i.compacting = true | |||
| 6526 | + | i.autoCompacting = true | |||
| 6527 | + | if continueExisting { | |||
| 6528 | + | i.continueAfterCompact = true | |||
| 6529 | + | } else { | |||
| 6530 | + | i.pendingCompactPrompt = prompt | |||
| 6531 | + | i.pendingCompactImages = append([]provider.ImageBlock(nil), images...) | |||
| 6532 | + | i.hasPendingCompactPrompt = true | |||
| 6533 | + | } | |||
| 6526 | i.statusErr = "" | 6534 | i.statusErr = "" | ||
| 6527 | i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending...")) | 6535 | i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending...")) | ||
| 6528 | i.pendingPostCompactNote = "context auto-compacted; sending your last message" | 6536 | i.pendingPostCompactNote = "context auto-compacted; sending your last message" | ||
| @@ -6596,7 +6604,11 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 6596 | err = i.agent.Prompt(ctx, prompt, images, sink) | 6604 | err = i.agent.Prompt(ctx, prompt, images, sink) | ||
| 6597 | } | 6605 | } | ||
| 6598 | i.mu.Lock() | 6606 | i.mu.Lock() | ||
| 6599 | - | i.busy = false | 6607 | + | // Keep busy asserted through final cleanup and queue selection. A |
| 6608 | + | // watcher may enqueue a completion summary concurrently with the | |||
| 6609 | + | // provider's final event; publishing idle before inspecting queues can | |||
| 6610 | + | // strand that summary in the core agent queue or start an overlapping | |||
| 6611 | + | // turn. | |||
| 6600 | // Don't touch streamPending / streamFlushPending here — the | 6612 | // Don't touch streamPending / streamFlushPending here — the | ||
| 6601 | // pacer may still be draining the final deltas and needs to | 6613 | // pacer may still be draining the final deltas and needs to | ||
| 6602 | // paint them even though Prompt has returned. It will reset | 6614 | // paint them even though Prompt has returned. It will reset | ||
| @@ -6686,8 +6698,10 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 6686 | if i.agent != nil { | 6698 | if i.agent != nil { | ||
| 6687 | agentQueued = i.agent.QueuedMessageCount() | 6699 | agentQueued = i.agent.QueuedMessageCount() | ||
| 6688 | } | 6700 | } | ||
| 6701 | + | continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil | |||
| 6689 | shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked() | 6702 | shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked() | ||
| 6690 | alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact) | 6703 | alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact) | ||
| 6704 | + | i.busy = hasNext || continueQueued || recoverContextOverflow || shouldAutoCompact | |||
| 6691 | i.mu.Unlock() | 6705 | i.mu.Unlock() | ||
| 6692 | if alertReason != "" { | 6706 | if alertReason != "" { | ||
| 6693 | i.scheduleMainAlert(alertReason) | 6707 | i.scheduleMainAlert(alertReason) | ||
| @@ -6704,6 +6718,8 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 6704 | switch { | 6718 | switch { | ||
| 6705 | case hasNext: | 6719 | case hasNext: | ||
| 6706 | i.startTurn(parent, next) | 6720 | i.startTurn(parent, next) | ||
| 6721 | + | case continueQueued: | |||
| 6722 | + | i.startTurnRequest(parent, "", nil, true, false) | |||
| 6707 | case offer: | 6723 | case offer: | ||
| 6708 | i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs) | 6724 | i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs) | ||
| 6709 | case recoverContextOverflow: | 6725 | case recoverContextOverflow: | ||