fix(modes): recall queued user input without host reports (#235)
* fix(modes): recall queued user input without host reports Skip host evidence when restoring queued drafts with Alt+Up or Escape. Show Alt+Shift+Up under Vev, which reserves Alt+Up for pane navigation. * test(modes): synchronize scheduled repetition guard follow-up Hold the eighth provider request until SubmitFollowUp registers its scheduled prompt, preventing the active turn from finishing before test setup completes.
- commit
- 1ddba1e6d2a3b24332b4f54af87c749de680c823
- author
- bnema · · 2026-09-30 04:41 UTC
- committer
- GitHub · · 2026-09-30 04:41 UTC
- parent
- 76a207d
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown - refs
- tag: v0.47.0
10 files · +281 -29
M README.md +2 -2
| @@ -989,9 +989,9 @@ ZUT_COMPACT_INPUT=1 zut # compact, just this run | |||
| 989 | 989 | ||
| 990 | 990 | ## Queued messages | |
| 991 | 991 | ||
| 992 | - | You can keep typing while the agent is working. Pressing `enter` during a turn queues the message, including pasted screenshots, instead of interrupting. It appears above the status bar as `sliding in: <text> [image]` and is delivered at the next safe model-call boundary. Queue as many messages as you want; they run in order. `alt+up` returns the most recently queued message and its image markers to the input for editing. `esc` cancels the active turn, restores the most recent queued message in the same way, and drops any remaining stale follow-ups; `ctrl+c` while busy arms the exit hint instead of interrupting, and a second `ctrl+c` within two seconds exits zut. | |
| 992 | + | You can keep typing while the agent is working. Pressing `enter` during a turn queues the message, including pasted screenshots, instead of interrupting. It appears above the status bar as `sliding in: <text> [image]` and is delivered at the next safe model-call boundary. Queue as many messages as you want; they run in order. `alt+up` returns the most recently queued user message and its image markers to the input for editing, skipping sub-agent reports and other host events. `esc` cancels the active turn, restores the most recent queued message in the same way, and drops any remaining stale follow-ups; `ctrl+c` while busy arms the exit hint instead of interrupting, and a second `ctrl+c` within two seconds exits zut. | |
| 993 | 993 | ||
| 994 | - | To recover the most recently queued message back into the editor (to tweak it before it runs), press `Option+↑`. In VS Code's integrated terminal that chord doesn't survive xterm.js's macOS key handling — use `Option+Shift+↑` there. zut's hint line under the sliding-in queue adapts automatically based on `$TERM_PROGRAM`. | |
| 994 | + | To recover your most recently queued message back into the editor (to tweak it before it runs), press `Alt+↑` (`Option+↑` on macOS). In Vev, use `Alt+Shift+↑` because `Alt+↑` is reserved for pane navigation. In VS Code's integrated terminal on macOS, use `Option+Shift+↑`. zut's hint line adapts based on `$TERM_PROGRAM` and appears only when a user message is available to recover. | |
| 995 | 995 | ||
| 996 | 996 | Slash commands also work while the agent is busy. Non-destructive ones (`/help`, `/info`, `/jump`, `/btw`, `/sessions`, `/skills`, `/reasoning`, `/settings`, `/jail`, `/unjail`, `/exit`) take effect immediately. Destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`, `/reload-ext`) cancel the active turn first and then run. | |
| 997 | 997 | ||
| @@ -989,9 +989,9 @@ ZUT_COMPACT_INPUT=1 zut # compact, just this run | |||||
| 989 | 989 | ||||
| 990 | ## Queued messages | 990 | ## Queued messages | ||
| 991 | 991 | ||||
| 992 | - | You can keep typing while the agent is working. Pressing `enter` during a turn queues the message, including pasted screenshots, instead of interrupting. It appears above the status bar as `sliding in: <text> [image]` and is delivered at the next safe model-call boundary. Queue as many messages as you want; they run in order. `alt+up` returns the most recently queued message and its image markers to the input for editing. `esc` cancels the active turn, restores the most recent queued message in the same way, and drops any remaining stale follow-ups; `ctrl+c` while busy arms the exit hint instead of interrupting, and a second `ctrl+c` within two seconds exits zut. | 992 | + | You can keep typing while the agent is working. Pressing `enter` during a turn queues the message, including pasted screenshots, instead of interrupting. It appears above the status bar as `sliding in: <text> [image]` and is delivered at the next safe model-call boundary. Queue as many messages as you want; they run in order. `alt+up` returns the most recently queued user message and its image markers to the input for editing, skipping sub-agent reports and other host events. `esc` cancels the active turn, restores the most recent queued message in the same way, and drops any remaining stale follow-ups; `ctrl+c` while busy arms the exit hint instead of interrupting, and a second `ctrl+c` within two seconds exits zut. |
| 993 | 993 | ||||
| 994 | - | To recover the most recently queued message back into the editor (to tweak it before it runs), press `Option+↑`. In VS Code's integrated terminal that chord doesn't survive xterm.js's macOS key handling — use `Option+Shift+↑` there. zut's hint line under the sliding-in queue adapts automatically based on `$TERM_PROGRAM`. | 994 | + | To recover your most recently queued message back into the editor (to tweak it before it runs), press `Alt+↑` (`Option+↑` on macOS). In Vev, use `Alt+Shift+↑` because `Alt+↑` is reserved for pane navigation. In VS Code's integrated terminal on macOS, use `Option+Shift+↑`. zut's hint line adapts based on `$TERM_PROGRAM` and appears only when a user message is available to recover. |
| 995 | 995 | ||||
| 996 | Slash commands also work while the agent is busy. Non-destructive ones (`/help`, `/info`, `/jump`, `/btw`, `/sessions`, `/skills`, `/reasoning`, `/settings`, `/jail`, `/unjail`, `/exit`) take effect immediately. Destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`, `/reload-ext`) cancel the active turn first and then run. | 996 | Slash commands also work while the agent is busy. Non-destructive ones (`/help`, `/info`, `/jump`, `/btw`, `/sessions`, `/skills`, `/reasoning`, `/settings`, `/jail`, `/unjail`, `/exit`) take effect immediately. Destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`, `/reload-ext`) cancel the active turn first and then run. | ||
| 997 | 997 | ||||
M packages/agent/modes/interactive_host.go +21 -0
| @@ -332,6 +332,27 @@ func (i *Interactive) submitOrQueueMessage(message core.QueuedMessage, userInput | |||
| 332 | 332 | i.startQueuedTurn(i.runCtx, message) | |
| 333 | 333 | } | |
| 334 | 334 | ||
| 335 | + | // popQueuedUserMessageLocked leaves host reports queued for the agent. | |
| 336 | + | // The caller must hold i.mu, as with the queue's enqueue and teardown paths. | |
| 337 | + | func (i *Interactive) popQueuedUserMessageLocked() (core.QueuedMessage, bool) { | |
| 338 | + | if i.agent != nil { | |
| 339 | + | if message, ok := i.agent.PopQueuedUserMessage(); ok { | |
| 340 | + | return message, true | |
| 341 | + | } | |
| 342 | + | } | |
| 343 | + | for index := len(i.queued) - 1; index >= 0; index-- { | |
| 344 | + | if i.queued[index].HostEvent { | |
| 345 | + | continue | |
| 346 | + | } | |
| 347 | + | message := i.queued[index] | |
| 348 | + | copy(i.queued[index:], i.queued[index+1:]) | |
| 349 | + | i.queued[len(i.queued)-1] = core.QueuedMessage{} | |
| 350 | + | i.queued = i.queued[:len(i.queued)-1] | |
| 351 | + | return message, true | |
| 352 | + | } | |
| 353 | + | return core.QueuedMessage{}, false | |
| 354 | + | } | |
| 355 | + | ||
| 335 | 356 | func (i *Interactive) takeQueuedMessagesLocked() []core.QueuedMessage { | |
| 336 | 357 | var pending []core.QueuedMessage | |
| 337 | 358 | if i.agent != nil { | |
| @@ -332,6 +332,27 @@ func (i *Interactive) submitOrQueueMessage(message core.QueuedMessage, userInput | |||||
| 332 | i.startQueuedTurn(i.runCtx, message) | 332 | i.startQueuedTurn(i.runCtx, message) | ||
| 333 | } | 333 | } | ||
| 334 | 334 | ||||
| 335 | + | // popQueuedUserMessageLocked leaves host reports queued for the agent. | |||
| 336 | + | // The caller must hold i.mu, as with the queue's enqueue and teardown paths. | |||
| 337 | + | func (i *Interactive) popQueuedUserMessageLocked() (core.QueuedMessage, bool) { | |||
| 338 | + | if i.agent != nil { | |||
| 339 | + | if message, ok := i.agent.PopQueuedUserMessage(); ok { | |||
| 340 | + | return message, true | |||
| 341 | + | } | |||
| 342 | + | } | |||
| 343 | + | for index := len(i.queued) - 1; index >= 0; index-- { | |||
| 344 | + | if i.queued[index].HostEvent { | |||
| 345 | + | continue | |||
| 346 | + | } | |||
| 347 | + | message := i.queued[index] | |||
| 348 | + | copy(i.queued[index:], i.queued[index+1:]) | |||
| 349 | + | i.queued[len(i.queued)-1] = core.QueuedMessage{} | |||
| 350 | + | i.queued = i.queued[:len(i.queued)-1] | |||
| 351 | + | return message, true | |||
| 352 | + | } | |||
| 353 | + | return core.QueuedMessage{}, false | |||
| 354 | + | } | |||
| 355 | + | ||||
| 335 | func (i *Interactive) takeQueuedMessagesLocked() []core.QueuedMessage { | 356 | func (i *Interactive) takeQueuedMessagesLocked() []core.QueuedMessage { | ||
| 336 | var pending []core.QueuedMessage | 357 | var pending []core.QueuedMessage | ||
| 337 | if i.agent != nil { | 358 | if i.agent != nil { | ||
M packages/agent/modes/interactive_input_routing.go +3 -22
| @@ -614,15 +614,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 614 | 614 | if busyCancel { | |
| 615 | 615 | // Keep the most recently queued follow-up as an editable draft | |
| 616 | 616 | // instead of losing it with the cancelled turn's stale queue. | |
| 617 | - | if i.agent != nil { | |
| 618 | - | restoreQueued, hasRestoreQueued = i.agent.PopQueuedMessage() | |
| 619 | - | } | |
| 620 | - | if !hasRestoreQueued && len(i.queued) > 0 { | |
| 621 | - | n := len(i.queued) - 1 | |
| 622 | - | restoreQueued = i.queued[n] | |
| 623 | - | i.queued = i.queued[:n] | |
| 624 | - | hasRestoreQueued = true | |
| 625 | - | } | |
| 617 | + | restoreQueued, hasRestoreQueued = i.popQueuedUserMessageLocked() | |
| 626 | 618 | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 627 | 619 | } | |
| 628 | 620 | i.mu.Unlock() | |
| @@ -670,25 +662,14 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 670 | 662 | return false | |
| 671 | 663 | case tui.KeyUp: | |
| 672 | 664 | // Alt/Option+Up: pop the most recently queued ("sliding in") | |
| 673 | - | // message back into the editor so the user can edit and | |
| 665 | + | // user message (never host evidence) so the user can edit and | |
| 674 | 666 | // resend it. Repeated presses keep peeling messages off the | |
| 675 | 667 | // tail of the queue; each press *replaces* the editor | |
| 676 | 668 | // contents (we don't append/push). When the queue is empty | |
| 677 | 669 | // the keypress falls through to the normal scroll behavior. | |
| 678 | 670 | if k.Alt { | |
| 679 | 671 | i.mu.Lock() | |
| 680 | - | var message core.QueuedMessage | |
| 681 | - | var ok bool | |
| 682 | - | if i.agent != nil { | |
| 683 | - | message, ok = i.agent.PopQueuedMessage() | |
| 684 | - | } | |
| 685 | - | if !ok { | |
| 686 | - | if n := len(i.queued); n > 0 { | |
| 687 | - | message = i.queued[n-1] | |
| 688 | - | i.queued = i.queued[:n-1] | |
| 689 | - | ok = true | |
| 690 | - | } | |
| 691 | - | } | |
| 672 | + | message, ok := i.popQueuedUserMessageLocked() | |
| 692 | 673 | i.mu.Unlock() | |
| 693 | 674 | if ok && i.restoreQueuedMessageToEditor(message) { | |
| 694 | 675 | i.invalidate() | |
| @@ -614,15 +614,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 614 | if busyCancel { | 614 | if busyCancel { | ||
| 615 | // Keep the most recently queued follow-up as an editable draft | 615 | // Keep the most recently queued follow-up as an editable draft | ||
| 616 | // instead of losing it with the cancelled turn's stale queue. | 616 | // instead of losing it with the cancelled turn's stale queue. | ||
| 617 | - | if i.agent != nil { | 617 | + | restoreQueued, hasRestoreQueued = i.popQueuedUserMessageLocked() |
| 618 | - | restoreQueued, hasRestoreQueued = i.agent.PopQueuedMessage() | |||
| 619 | - | } | |||
| 620 | - | if !hasRestoreQueued && len(i.queued) > 0 { | |||
| 621 | - | n := len(i.queued) - 1 | |||
| 622 | - | restoreQueued = i.queued[n] | |||
| 623 | - | i.queued = i.queued[:n] | |||
| 624 | - | hasRestoreQueued = true | |||
| 625 | - | } | |||
| 626 | handoff, persistHandoff = i.resetCompactContinuationLocked() | 618 | handoff, persistHandoff = i.resetCompactContinuationLocked() | ||
| 627 | } | 619 | } | ||
| 628 | i.mu.Unlock() | 620 | i.mu.Unlock() | ||
| @@ -670,25 +662,14 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 670 | return false | 662 | return false | ||
| 671 | case tui.KeyUp: | 663 | case tui.KeyUp: | ||
| 672 | // Alt/Option+Up: pop the most recently queued ("sliding in") | 664 | // Alt/Option+Up: pop the most recently queued ("sliding in") | ||
| 673 | - | // message back into the editor so the user can edit and | 665 | + | // user message (never host evidence) so the user can edit and |
| 674 | // resend it. Repeated presses keep peeling messages off the | 666 | // resend it. Repeated presses keep peeling messages off the | ||
| 675 | // tail of the queue; each press *replaces* the editor | 667 | // tail of the queue; each press *replaces* the editor | ||
| 676 | // contents (we don't append/push). When the queue is empty | 668 | // contents (we don't append/push). When the queue is empty | ||
| 677 | // the keypress falls through to the normal scroll behavior. | 669 | // the keypress falls through to the normal scroll behavior. | ||
| 678 | if k.Alt { | 670 | if k.Alt { | ||
| 679 | i.mu.Lock() | 671 | i.mu.Lock() | ||
| 680 | - | var message core.QueuedMessage | 672 | + | message, ok := i.popQueuedUserMessageLocked() |
| 681 | - | var ok bool | |||
| 682 | - | if i.agent != nil { | |||
| 683 | - | message, ok = i.agent.PopQueuedMessage() | |||
| 684 | - | } | |||
| 685 | - | if !ok { | |||
| 686 | - | if n := len(i.queued); n > 0 { | |||
| 687 | - | message = i.queued[n-1] | |||
| 688 | - | i.queued = i.queued[:n-1] | |||
| 689 | - | ok = true | |||
| 690 | - | } | |||
| 691 | - | } | |||
| 692 | i.mu.Unlock() | 673 | i.mu.Unlock() | ||
| 693 | if ok && i.restoreQueuedMessageToEditor(message) { | 674 | if ok && i.restoreQueuedMessageToEditor(message) { | ||
| 694 | i.invalidate() | 675 | i.invalidate() | ||
M packages/agent/modes/interactive_queue_test.go +141 -0
| @@ -2,6 +2,7 @@ package modes | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "io" | |
| 5 | 6 | "strings" | |
| 6 | 7 | "testing" | |
| 7 | 8 | ||
| @@ -68,6 +69,146 @@ func TestSlideBackRestoresQueuedImagesToEditor(t *testing.T) { | |||
| 68 | 69 | } | |
| 69 | 70 | } | |
| 70 | 71 | ||
| 72 | + | func TestSlideBackSkipsHostEvents(t *testing.T) { | |
| 73 | + | for _, hostQueue := range []bool{false, true} { | |
| 74 | + | name := "agent queue" | |
| 75 | + | if hostQueue { | |
| 76 | + | name = "host queue" | |
| 77 | + | } | |
| 78 | + | t.Run(name, func(t *testing.T) { | |
| 79 | + | agent := core.NewAgent(nil, "test-model", "", nil) | |
| 80 | + | i := NewInteractive(InteractiveConfig{Agent: agent}) | |
| 81 | + | i.busy = true | |
| 82 | + | messages := []core.QueuedMessage{ | |
| 83 | + | {Text: "first report", HostEvent: true}, | |
| 84 | + | {Text: "older draft"}, | |
| 85 | + | {Text: "recover this draft"}, | |
| 86 | + | {Text: "latest report", HostEvent: true}, | |
| 87 | + | } | |
| 88 | + | if hostQueue { | |
| 89 | + | i.queued = messages | |
| 90 | + | } else { | |
| 91 | + | for _, message := range messages { | |
| 92 | + | agent.QueuePrompt(message) | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | for _, want := range []string{"recover this draft", "older draft"} { | |
| 96 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyUp, Alt: true}) | |
| 97 | + | if got := i.ed.Value(); got != want { | |
| 98 | + | t.Fatalf("editor = %q, want %q", got, want) | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyUp, Alt: true}) | |
| 102 | + | if got := i.ed.Value(); got != "older draft" { | |
| 103 | + | t.Fatalf("host-only queue changed editor to %q", got) | |
| 104 | + | } | |
| 105 | + | pending := i.queued | |
| 106 | + | if !hostQueue { | |
| 107 | + | pending = agent.PendingQueuedMessages() | |
| 108 | + | } | |
| 109 | + | if len(pending) != 2 || pending[0].Text != "first report" || pending[1].Text != "latest report" || !pending[0].HostEvent || !pending[1].HostEvent { | |
| 110 | + | t.Fatalf("remaining reports = %#v, want both reports in original order", pending) | |
| 111 | + | } | |
| 112 | + | }) | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func TestSlideBackParsesModifiedArrow(t *testing.T) { | |
| 117 | + | for name, sequence := range map[string]string{ | |
| 118 | + | "alt up": "\x1b[1;3A", | |
| 119 | + | "alt shift up": "\x1b[1;4A", | |
| 120 | + | } { | |
| 121 | + | t.Run(name, func(t *testing.T) { | |
| 122 | + | agent := core.NewAgent(nil, "test-model", "", nil) | |
| 123 | + | i := NewInteractive(InteractiveConfig{Agent: agent}) | |
| 124 | + | i.busy = true | |
| 125 | + | i.ed.SetValue("recover this draft") | |
| 126 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter}) | |
| 127 | + | agent.QueuePrompt(core.QueuedMessage{Text: "worker report", HostEvent: true}) | |
| 128 | + | reader := tui.NewReader(func() (byte, error) { | |
| 129 | + | if sequence == "" { | |
| 130 | + | return 0, io.EOF | |
| 131 | + | } | |
| 132 | + | b := sequence[0] | |
| 133 | + | sequence = sequence[1:] | |
| 134 | + | return b, nil | |
| 135 | + | }) | |
| 136 | + | key, err := reader.Read() | |
| 137 | + | if err != nil { | |
| 138 | + | t.Fatal(err) | |
| 139 | + | } | |
| 140 | + | i.handleKey(context.Background(), key) | |
| 141 | + | if got := i.ed.Value(); got != "recover this draft" { | |
| 142 | + | t.Fatalf("editor = %q, want user draft", got) | |
| 143 | + | } | |
| 144 | + | pending := agent.PendingQueuedMessages() | |
| 145 | + | if len(pending) != 1 || !pending[0].HostEvent { | |
| 146 | + | t.Fatalf("pending = %#v, want worker report", pending) | |
| 147 | + | } | |
| 148 | + | }) | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func TestEscapeSkipsHostEvents(t *testing.T) { | |
| 153 | + | for _, hasUserMessage := range []bool{false, true} { | |
| 154 | + | for _, hostQueue := range []bool{false, true} { | |
| 155 | + | agent := core.NewAgent(nil, "test-model", "", nil) | |
| 156 | + | i := NewInteractive(InteractiveConfig{Agent: agent}) | |
| 157 | + | i.busy = true | |
| 158 | + | i.cancelTurn = func() {} | |
| 159 | + | i.ed.SetValue("existing draft") | |
| 160 | + | messages := []core.QueuedMessage{{Text: "worker report", HostEvent: true}} | |
| 161 | + | want := "existing draft" | |
| 162 | + | if hasUserMessage { | |
| 163 | + | messages = append([]core.QueuedMessage{{Text: "recover this draft"}}, messages...) | |
| 164 | + | want = "recover this draft" | |
| 165 | + | } | |
| 166 | + | if hostQueue { | |
| 167 | + | i.queued = messages | |
| 168 | + | } else { | |
| 169 | + | for _, message := range messages { | |
| 170 | + | agent.QueuePrompt(message) | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}) | |
| 174 | + | if got := i.ed.Value(); got != want { | |
| 175 | + | t.Fatalf("user=%v hostQueue=%v: editor = %q, want %q", hasUserMessage, hostQueue, got, want) | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func TestSlidingQueueHintOnlyForUserMessages(t *testing.T) { | |
| 182 | + | t.Setenv("TERM_PROGRAM", "vev") | |
| 183 | + | for _, hostQueue := range []bool{false, true} { | |
| 184 | + | for _, hasUserMessage := range []bool{false, true} { | |
| 185 | + | agent := core.NewAgent(nil, "test-model", "", nil) | |
| 186 | + | term := &alertTestTerminal{} | |
| 187 | + | i := NewInteractive(InteractiveConfig{Agent: agent, Terminal: term, Theme: tui.Dark}) | |
| 188 | + | i.rend.Resize(80, 24) | |
| 189 | + | messages := []core.QueuedMessage{{Text: "worker report", HostEvent: true}} | |
| 190 | + | if hasUserMessage { | |
| 191 | + | messages = append(messages, core.QueuedMessage{Text: "user draft"}) | |
| 192 | + | } | |
| 193 | + | if hostQueue { | |
| 194 | + | i.queued = messages | |
| 195 | + | } else { | |
| 196 | + | for _, message := range messages { | |
| 197 | + | agent.QueuePrompt(message) | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | i.redraw() | |
| 201 | + | output := stripANSIBytes(term.String()) | |
| 202 | + | if !strings.Contains(output, "worker report") { | |
| 203 | + | t.Fatal("host report missing from sliding queue") | |
| 204 | + | } | |
| 205 | + | if got := strings.Contains(output, "Press Alt+Shift+↑"); got != hasUserMessage { | |
| 206 | + | t.Fatalf("user=%v hostQueue=%v: recall hint visible=%v", hasUserMessage, hostQueue, got) | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | ||
| 71 | 212 | func TestEscapeRestoresMostRecentQueuedMessageToEditor(t *testing.T) { | |
| 72 | 213 | agent := core.NewAgent(nil, "test-model", "", nil) | |
| 73 | 214 | agent.QueueMessage("older follow-up", nil) | |
| @@ -2,6 +2,7 @@ package modes | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "io" | |||
| 5 | "strings" | 6 | "strings" | ||
| 6 | "testing" | 7 | "testing" | ||
| 7 | 8 | ||||
| @@ -68,6 +69,146 @@ func TestSlideBackRestoresQueuedImagesToEditor(t *testing.T) { | |||||
| 68 | } | 69 | } | ||
| 69 | } | 70 | } | ||
| 70 | 71 | ||||
| 72 | + | func TestSlideBackSkipsHostEvents(t *testing.T) { | |||
| 73 | + | for _, hostQueue := range []bool{false, true} { | |||
| 74 | + | name := "agent queue" | |||
| 75 | + | if hostQueue { | |||
| 76 | + | name = "host queue" | |||
| 77 | + | } | |||
| 78 | + | t.Run(name, func(t *testing.T) { | |||
| 79 | + | agent := core.NewAgent(nil, "test-model", "", nil) | |||
| 80 | + | i := NewInteractive(InteractiveConfig{Agent: agent}) | |||
| 81 | + | i.busy = true | |||
| 82 | + | messages := []core.QueuedMessage{ | |||
| 83 | + | {Text: "first report", HostEvent: true}, | |||
| 84 | + | {Text: "older draft"}, | |||
| 85 | + | {Text: "recover this draft"}, | |||
| 86 | + | {Text: "latest report", HostEvent: true}, | |||
| 87 | + | } | |||
| 88 | + | if hostQueue { | |||
| 89 | + | i.queued = messages | |||
| 90 | + | } else { | |||
| 91 | + | for _, message := range messages { | |||
| 92 | + | agent.QueuePrompt(message) | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | for _, want := range []string{"recover this draft", "older draft"} { | |||
| 96 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyUp, Alt: true}) | |||
| 97 | + | if got := i.ed.Value(); got != want { | |||
| 98 | + | t.Fatalf("editor = %q, want %q", got, want) | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyUp, Alt: true}) | |||
| 102 | + | if got := i.ed.Value(); got != "older draft" { | |||
| 103 | + | t.Fatalf("host-only queue changed editor to %q", got) | |||
| 104 | + | } | |||
| 105 | + | pending := i.queued | |||
| 106 | + | if !hostQueue { | |||
| 107 | + | pending = agent.PendingQueuedMessages() | |||
| 108 | + | } | |||
| 109 | + | if len(pending) != 2 || pending[0].Text != "first report" || pending[1].Text != "latest report" || !pending[0].HostEvent || !pending[1].HostEvent { | |||
| 110 | + | t.Fatalf("remaining reports = %#v, want both reports in original order", pending) | |||
| 111 | + | } | |||
| 112 | + | }) | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func TestSlideBackParsesModifiedArrow(t *testing.T) { | |||
| 117 | + | for name, sequence := range map[string]string{ | |||
| 118 | + | "alt up": "\x1b[1;3A", | |||
| 119 | + | "alt shift up": "\x1b[1;4A", | |||
| 120 | + | } { | |||
| 121 | + | t.Run(name, func(t *testing.T) { | |||
| 122 | + | agent := core.NewAgent(nil, "test-model", "", nil) | |||
| 123 | + | i := NewInteractive(InteractiveConfig{Agent: agent}) | |||
| 124 | + | i.busy = true | |||
| 125 | + | i.ed.SetValue("recover this draft") | |||
| 126 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter}) | |||
| 127 | + | agent.QueuePrompt(core.QueuedMessage{Text: "worker report", HostEvent: true}) | |||
| 128 | + | reader := tui.NewReader(func() (byte, error) { | |||
| 129 | + | if sequence == "" { | |||
| 130 | + | return 0, io.EOF | |||
| 131 | + | } | |||
| 132 | + | b := sequence[0] | |||
| 133 | + | sequence = sequence[1:] | |||
| 134 | + | return b, nil | |||
| 135 | + | }) | |||
| 136 | + | key, err := reader.Read() | |||
| 137 | + | if err != nil { | |||
| 138 | + | t.Fatal(err) | |||
| 139 | + | } | |||
| 140 | + | i.handleKey(context.Background(), key) | |||
| 141 | + | if got := i.ed.Value(); got != "recover this draft" { | |||
| 142 | + | t.Fatalf("editor = %q, want user draft", got) | |||
| 143 | + | } | |||
| 144 | + | pending := agent.PendingQueuedMessages() | |||
| 145 | + | if len(pending) != 1 || !pending[0].HostEvent { | |||
| 146 | + | t.Fatalf("pending = %#v, want worker report", pending) | |||
| 147 | + | } | |||
| 148 | + | }) | |||
| 149 | + | } | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | func TestEscapeSkipsHostEvents(t *testing.T) { | |||
| 153 | + | for _, hasUserMessage := range []bool{false, true} { | |||
| 154 | + | for _, hostQueue := range []bool{false, true} { | |||
| 155 | + | agent := core.NewAgent(nil, "test-model", "", nil) | |||
| 156 | + | i := NewInteractive(InteractiveConfig{Agent: agent}) | |||
| 157 | + | i.busy = true | |||
| 158 | + | i.cancelTurn = func() {} | |||
| 159 | + | i.ed.SetValue("existing draft") | |||
| 160 | + | messages := []core.QueuedMessage{{Text: "worker report", HostEvent: true}} | |||
| 161 | + | want := "existing draft" | |||
| 162 | + | if hasUserMessage { | |||
| 163 | + | messages = append([]core.QueuedMessage{{Text: "recover this draft"}}, messages...) | |||
| 164 | + | want = "recover this draft" | |||
| 165 | + | } | |||
| 166 | + | if hostQueue { | |||
| 167 | + | i.queued = messages | |||
| 168 | + | } else { | |||
| 169 | + | for _, message := range messages { | |||
| 170 | + | agent.QueuePrompt(message) | |||
| 171 | + | } | |||
| 172 | + | } | |||
| 173 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}) | |||
| 174 | + | if got := i.ed.Value(); got != want { | |||
| 175 | + | t.Fatalf("user=%v hostQueue=%v: editor = %q, want %q", hasUserMessage, hostQueue, got, want) | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | func TestSlidingQueueHintOnlyForUserMessages(t *testing.T) { | |||
| 182 | + | t.Setenv("TERM_PROGRAM", "vev") | |||
| 183 | + | for _, hostQueue := range []bool{false, true} { | |||
| 184 | + | for _, hasUserMessage := range []bool{false, true} { | |||
| 185 | + | agent := core.NewAgent(nil, "test-model", "", nil) | |||
| 186 | + | term := &alertTestTerminal{} | |||
| 187 | + | i := NewInteractive(InteractiveConfig{Agent: agent, Terminal: term, Theme: tui.Dark}) | |||
| 188 | + | i.rend.Resize(80, 24) | |||
| 189 | + | messages := []core.QueuedMessage{{Text: "worker report", HostEvent: true}} | |||
| 190 | + | if hasUserMessage { | |||
| 191 | + | messages = append(messages, core.QueuedMessage{Text: "user draft"}) | |||
| 192 | + | } | |||
| 193 | + | if hostQueue { | |||
| 194 | + | i.queued = messages | |||
| 195 | + | } else { | |||
| 196 | + | for _, message := range messages { | |||
| 197 | + | agent.QueuePrompt(message) | |||
| 198 | + | } | |||
| 199 | + | } | |||
| 200 | + | i.redraw() | |||
| 201 | + | output := stripANSIBytes(term.String()) | |||
| 202 | + | if !strings.Contains(output, "worker report") { | |||
| 203 | + | t.Fatal("host report missing from sliding queue") | |||
| 204 | + | } | |||
| 205 | + | if got := strings.Contains(output, "Press Alt+Shift+↑"); got != hasUserMessage { | |||
| 206 | + | t.Fatalf("user=%v hostQueue=%v: recall hint visible=%v", hasUserMessage, hostQueue, got) | |||
| 207 | + | } | |||
| 208 | + | } | |||
| 209 | + | } | |||
| 210 | + | } | |||
| 211 | + | ||||
| 71 | func TestEscapeRestoresMostRecentQueuedMessageToEditor(t *testing.T) { | 212 | func TestEscapeRestoresMostRecentQueuedMessageToEditor(t *testing.T) { | ||
| 72 | agent := core.NewAgent(nil, "test-model", "", nil) | 213 | agent := core.NewAgent(nil, "test-model", "", nil) | ||
| 73 | agent.QueueMessage("older follow-up", nil) | 214 | agent.QueueMessage("older follow-up", nil) | ||
M packages/agent/modes/interactive_rendering.go +6 -2
| @@ -717,7 +717,9 @@ func (i *Interactive) redraw() { | |||
| 717 | 717 | } | |
| 718 | 718 | if len(queued) > 0 { | |
| 719 | 719 | queue = append(queue, "") | |
| 720 | + | hasUserMessage := false | |
| 720 | 721 | for _, q := range queued { | |
| 722 | + | hasUserMessage = hasUserMessage || !q.HostEvent | |
| 721 | 723 | label := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, " sliding in: ") | |
| 722 | 724 | text := queuedMessageSummary(q, mainCols-17) | |
| 723 | 725 | queue = append(queue, label+i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text)) | |
| @@ -726,8 +728,10 @@ func (i *Interactive) redraw() { | |||
| 726 | 728 | // info on the status bar so it reads as ambient metadata | |
| 727 | 729 | // rather than a chip. Tells the user how to recover the | |
| 728 | 730 | // most recent queued message back into the editor. | |
| 729 | - | hint := " Press " + slideBackChordHint() + " to slide back into input" | |
| 730 | - | queue = append(queue, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, hint)) | |
| 731 | + | if hasUserMessage { | |
| 732 | + | hint := " Press " + slideBackChordHint() + " to slide your message back into input" | |
| 733 | + | queue = append(queue, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, hint)) | |
| 734 | + | } | |
| 731 | 735 | } | |
| 732 | 736 | ||
| 733 | 737 | extensionLines := i.extensionChromeLinesLocked(mainCols) | |
| @@ -717,7 +717,9 @@ func (i *Interactive) redraw() { | |||||
| 717 | } | 717 | } | ||
| 718 | if len(queued) > 0 { | 718 | if len(queued) > 0 { | ||
| 719 | queue = append(queue, "") | 719 | queue = append(queue, "") | ||
| 720 | + | hasUserMessage := false | |||
| 720 | for _, q := range queued { | 721 | for _, q := range queued { | ||
| 722 | + | hasUserMessage = hasUserMessage || !q.HostEvent | |||
| 721 | label := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, " sliding in: ") | 723 | label := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, " sliding in: ") | ||
| 722 | text := queuedMessageSummary(q, mainCols-17) | 724 | text := queuedMessageSummary(q, mainCols-17) | ||
| 723 | queue = append(queue, label+i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text)) | 725 | queue = append(queue, label+i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text)) | ||
| @@ -726,8 +728,10 @@ func (i *Interactive) redraw() { | |||||
| 726 | // info on the status bar so it reads as ambient metadata | 728 | // info on the status bar so it reads as ambient metadata | ||
| 727 | // rather than a chip. Tells the user how to recover the | 729 | // rather than a chip. Tells the user how to recover the | ||
| 728 | // most recent queued message back into the editor. | 730 | // most recent queued message back into the editor. | ||
| 729 | - | hint := " Press " + slideBackChordHint() + " to slide back into input" | 731 | + | if hasUserMessage { |
| 730 | - | queue = append(queue, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, hint)) | 732 | + | hint := " Press " + slideBackChordHint() + " to slide your message back into input" |
| 733 | + | queue = append(queue, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, hint)) | |||
| 734 | + | } | |||
| 731 | } | 735 | } | ||
| 732 | 736 | ||||
| 733 | extensionLines := i.extensionChromeLinesLocked(mainCols) | 737 | extensionLines := i.extensionChromeLinesLocked(mainCols) | ||
M packages/agent/modes/keychord_hint.go +6 -0
| @@ -26,12 +26,18 @@ import ( | |||
| 26 | 26 | // alt=true, so the binding fires; we just need to tell the | |
| 27 | 27 | // user the right chord. | |
| 28 | 28 | // | |
| 29 | + | // - Vev reserves Alt+Up for pane navigation. Alt+Shift+Up is | |
| 30 | + | // forwarded to the child process as \x1b[1;4A instead. | |
| 31 | + | // | |
| 29 | 32 | // Detection is by $TERM_PROGRAM; VS Code sets this to "vscode" on | |
| 30 | 33 | // every platform it ships an integrated terminal on. The decoder | |
| 31 | 34 | // accepts both chords on any terminal, so getting the env-var sniff | |
| 32 | 35 | // wrong only ever shows a slightly-off hint string — never breaks | |
| 33 | 36 | // the binding itself. | |
| 34 | 37 | func slideBackChordHint() string { | |
| 38 | + | if strings.EqualFold(os.Getenv("TERM_PROGRAM"), "vev") { | |
| 39 | + | return "Alt+Shift+↑" | |
| 40 | + | } | |
| 35 | 41 | if strings.EqualFold(os.Getenv("TERM_PROGRAM"), "vscode") { | |
| 36 | 42 | return "Option+Shift+↑" | |
| 37 | 43 | } | |
| @@ -26,12 +26,18 @@ import ( | |||||
| 26 | // alt=true, so the binding fires; we just need to tell the | 26 | // alt=true, so the binding fires; we just need to tell the | ||
| 27 | // user the right chord. | 27 | // user the right chord. | ||
| 28 | // | 28 | // | ||
| 29 | + | // - Vev reserves Alt+Up for pane navigation. Alt+Shift+Up is | |||
| 30 | + | // forwarded to the child process as \x1b[1;4A instead. | |||
| 31 | + | // | |||
| 29 | // Detection is by $TERM_PROGRAM; VS Code sets this to "vscode" on | 32 | // Detection is by $TERM_PROGRAM; VS Code sets this to "vscode" on | ||
| 30 | // every platform it ships an integrated terminal on. The decoder | 33 | // every platform it ships an integrated terminal on. The decoder | ||
| 31 | // accepts both chords on any terminal, so getting the env-var sniff | 34 | // accepts both chords on any terminal, so getting the env-var sniff | ||
| 32 | // wrong only ever shows a slightly-off hint string — never breaks | 35 | // wrong only ever shows a slightly-off hint string — never breaks | ||
| 33 | // the binding itself. | 36 | // the binding itself. | ||
| 34 | func slideBackChordHint() string { | 37 | func slideBackChordHint() string { | ||
| 38 | + | if strings.EqualFold(os.Getenv("TERM_PROGRAM"), "vev") { | |||
| 39 | + | return "Alt+Shift+↑" | |||
| 40 | + | } | |||
| 35 | if strings.EqualFold(os.Getenv("TERM_PROGRAM"), "vscode") { | 41 | if strings.EqualFold(os.Getenv("TERM_PROGRAM"), "vscode") { | ||
| 36 | return "Option+Shift+↑" | 42 | return "Option+Shift+↑" | ||
| 37 | } | 43 | } | ||
M packages/agent/modes/keychord_hint_test.go +11 -0
| @@ -27,6 +27,17 @@ func TestSlideBackChordHintVSCodeCaseInsensitive(t *testing.T) { | |||
| 27 | 27 | } | |
| 28 | 28 | } | |
| 29 | 29 | ||
| 30 | + | func TestSlideBackChordHintVev(t *testing.T) { | |
| 31 | + | for _, value := range []string{"vev", "VEV"} { | |
| 32 | + | t.Run(value, func(t *testing.T) { | |
| 33 | + | t.Setenv("TERM_PROGRAM", value) | |
| 34 | + | if got, want := slideBackChordHint(), "Alt+Shift+↑"; got != want { | |
| 35 | + | t.Errorf("Vev hint = %q; want %q", got, want) | |
| 36 | + | } | |
| 37 | + | }) | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 30 | 41 | // TestSlideBackChordHintDefault pins the default for every | |
| 31 | 42 | // non-VS-Code terminal: the snappier Option+↑ chord, which Ghostty, | |
| 32 | 43 | // iTerm2 (with Meta=Option), Terminal.app (with Use Option as Meta), | |
| @@ -27,6 +27,17 @@ func TestSlideBackChordHintVSCodeCaseInsensitive(t *testing.T) { | |||||
| 27 | } | 27 | } | ||
| 28 | } | 28 | } | ||
| 29 | 29 | ||||
| 30 | + | func TestSlideBackChordHintVev(t *testing.T) { | |||
| 31 | + | for _, value := range []string{"vev", "VEV"} { | |||
| 32 | + | t.Run(value, func(t *testing.T) { | |||
| 33 | + | t.Setenv("TERM_PROGRAM", value) | |||
| 34 | + | if got, want := slideBackChordHint(), "Alt+Shift+↑"; got != want { | |||
| 35 | + | t.Errorf("Vev hint = %q; want %q", got, want) | |||
| 36 | + | } | |||
| 37 | + | }) | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 30 | // TestSlideBackChordHintDefault pins the default for every | 41 | // TestSlideBackChordHintDefault pins the default for every | ||
| 31 | // non-VS-Code terminal: the snappier Option+↑ chord, which Ghostty, | 42 | // non-VS-Code terminal: the snappier Option+↑ chord, which Ghostty, | ||
| 32 | // iTerm2 (with Meta=Option), Terminal.app (with Use Option as Meta), | 43 | // iTerm2 (with Meta=Option), Terminal.app (with Use Option as Meta), | ||
M packages/agent/modes/repetition_guard_test.go +39 -1
| @@ -168,16 +168,53 @@ func TestInteractiveRepetitionGuardCanResumeStalledGoal(t *testing.T) { | |||
| 168 | 168 | } | |
| 169 | 169 | ||
| 170 | 170 | func TestInteractiveRepetitionGuardResolvesScheduledFollowUp(t *testing.T) { | |
| 171 | - | client := &repetitiveInteractiveClient{} | |
| 171 | + | client := &repetitiveInteractiveClient{ | |
| 172 | + | entered: make(chan struct{}), | |
| 173 | + | resume: make(chan struct{}), | |
| 174 | + | } | |
| 175 | + | defer func() { | |
| 176 | + | select { | |
| 177 | + | case <-client.resume: | |
| 178 | + | default: | |
| 179 | + | close(client.resume) | |
| 180 | + | } | |
| 181 | + | }() | |
| 172 | 182 | agent := core.NewAgent(client, "model", "system", core.Registry{"repeat": &repetitiveInteractiveTool{}}) | |
| 173 | 183 | interactive := NewInteractive(InteractiveConfig{Agent: agent}) | |
| 174 | 184 | ctx, cancel := context.WithCancel(context.Background()) | |
| 175 | 185 | defer cancel() | |
| 176 | 186 | ||
| 177 | 187 | interactive.startTurn(ctx, "start") | |
| 188 | + | select { | |
| 189 | + | case <-client.entered: | |
| 190 | + | case <-time.After(3 * time.Second): | |
| 191 | + | t.Fatal("eighth provider request did not start") | |
| 192 | + | } | |
| 178 | 193 | followUp := make(chan error, 1) | |
| 179 | 194 | go func() { followUp <- interactive.SubmitFollowUp(ctx, "scheduled task") }() | |
| 180 | 195 | ||
| 196 | + | // SubmitFollowUp blocks until teardown resolves it. Hold the eighth | |
| 197 | + | // provider call until the scheduled prompt is actually registered, not | |
| 198 | + | // merely until its goroutine has been launched. | |
| 199 | + | deadline := time.NewTimer(3 * time.Second) | |
| 200 | + | defer deadline.Stop() | |
| 201 | + | poll := time.NewTicker(time.Millisecond) | |
| 202 | + | defer poll.Stop() | |
| 203 | + | for { | |
| 204 | + | interactive.mu.Lock() | |
| 205 | + | queued := len(interactive.scheduled) == 1 | |
| 206 | + | interactive.mu.Unlock() | |
| 207 | + | if queued { | |
| 208 | + | break | |
| 209 | + | } | |
| 210 | + | select { | |
| 211 | + | case <-deadline.C: | |
| 212 | + | t.Fatal("scheduled follow-up did not enter the queue") | |
| 213 | + | case <-poll.C: | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | close(client.resume) | |
| 217 | + | ||
| 181 | 218 | select { | |
| 182 | 219 | case err := <-followUp: | |
| 183 | 220 | if !errors.Is(err, core.ErrRepetitiveLoop) { | |
| @@ -186,6 +223,7 @@ func TestInteractiveRepetitionGuardResolvesScheduledFollowUp(t *testing.T) { | |||
| 186 | 223 | case <-time.After(3 * time.Second): | |
| 187 | 224 | t.Fatal("scheduled follow-up remained blocked after repetitive loop stop") | |
| 188 | 225 | } | |
| 226 | + | waitInteractiveIdle(t, interactive) | |
| 189 | 227 | if got := client.calls.Load(); got != 8 { | |
| 190 | 228 | t.Fatalf("provider calls = %d, want 8", got) | |
| 191 | 229 | } | |
| @@ -168,16 +168,53 @@ func TestInteractiveRepetitionGuardCanResumeStalledGoal(t *testing.T) { | |||||
| 168 | } | 168 | } | ||
| 169 | 169 | ||||
| 170 | func TestInteractiveRepetitionGuardResolvesScheduledFollowUp(t *testing.T) { | 170 | func TestInteractiveRepetitionGuardResolvesScheduledFollowUp(t *testing.T) { | ||
| 171 | - | client := &repetitiveInteractiveClient{} | 171 | + | client := &repetitiveInteractiveClient{ |
| 172 | + | entered: make(chan struct{}), | |||
| 173 | + | resume: make(chan struct{}), | |||
| 174 | + | } | |||
| 175 | + | defer func() { | |||
| 176 | + | select { | |||
| 177 | + | case <-client.resume: | |||
| 178 | + | default: | |||
| 179 | + | close(client.resume) | |||
| 180 | + | } | |||
| 181 | + | }() | |||
| 172 | agent := core.NewAgent(client, "model", "system", core.Registry{"repeat": &repetitiveInteractiveTool{}}) | 182 | agent := core.NewAgent(client, "model", "system", core.Registry{"repeat": &repetitiveInteractiveTool{}}) | ||
| 173 | interactive := NewInteractive(InteractiveConfig{Agent: agent}) | 183 | interactive := NewInteractive(InteractiveConfig{Agent: agent}) | ||
| 174 | ctx, cancel := context.WithCancel(context.Background()) | 184 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 175 | defer cancel() | 185 | defer cancel() | ||
| 176 | 186 | ||||
| 177 | interactive.startTurn(ctx, "start") | 187 | interactive.startTurn(ctx, "start") | ||
| 188 | + | select { | |||
| 189 | + | case <-client.entered: | |||
| 190 | + | case <-time.After(3 * time.Second): | |||
| 191 | + | t.Fatal("eighth provider request did not start") | |||
| 192 | + | } | |||
| 178 | followUp := make(chan error, 1) | 193 | followUp := make(chan error, 1) | ||
| 179 | go func() { followUp <- interactive.SubmitFollowUp(ctx, "scheduled task") }() | 194 | go func() { followUp <- interactive.SubmitFollowUp(ctx, "scheduled task") }() | ||
| 180 | 195 | ||||
| 196 | + | // SubmitFollowUp blocks until teardown resolves it. Hold the eighth | |||
| 197 | + | // provider call until the scheduled prompt is actually registered, not | |||
| 198 | + | // merely until its goroutine has been launched. | |||
| 199 | + | deadline := time.NewTimer(3 * time.Second) | |||
| 200 | + | defer deadline.Stop() | |||
| 201 | + | poll := time.NewTicker(time.Millisecond) | |||
| 202 | + | defer poll.Stop() | |||
| 203 | + | for { | |||
| 204 | + | interactive.mu.Lock() | |||
| 205 | + | queued := len(interactive.scheduled) == 1 | |||
| 206 | + | interactive.mu.Unlock() | |||
| 207 | + | if queued { | |||
| 208 | + | break | |||
| 209 | + | } | |||
| 210 | + | select { | |||
| 211 | + | case <-deadline.C: | |||
| 212 | + | t.Fatal("scheduled follow-up did not enter the queue") | |||
| 213 | + | case <-poll.C: | |||
| 214 | + | } | |||
| 215 | + | } | |||
| 216 | + | close(client.resume) | |||
| 217 | + | ||||
| 181 | select { | 218 | select { | ||
| 182 | case err := <-followUp: | 219 | case err := <-followUp: | ||
| 183 | if !errors.Is(err, core.ErrRepetitiveLoop) { | 220 | if !errors.Is(err, core.ErrRepetitiveLoop) { | ||
| @@ -186,6 +223,7 @@ func TestInteractiveRepetitionGuardResolvesScheduledFollowUp(t *testing.T) { | |||||
| 186 | case <-time.After(3 * time.Second): | 223 | case <-time.After(3 * time.Second): | ||
| 187 | t.Fatal("scheduled follow-up remained blocked after repetitive loop stop") | 224 | t.Fatal("scheduled follow-up remained blocked after repetitive loop stop") | ||
| 188 | } | 225 | } | ||
| 226 | + | waitInteractiveIdle(t, interactive) | |||
| 189 | if got := client.calls.Load(); got != 8 { | 227 | if got := client.calls.Load(); got != 8 { | ||
| 190 | t.Fatalf("provider calls = %d, want 8", got) | 228 | t.Fatalf("provider calls = %d, want 8", got) | ||
| 191 | } | 229 | } | ||
M packages/core/agent.go +20 -2
| @@ -344,8 +344,8 @@ func (a *Agent) QueuedMessageCount() int { | |||
| 344 | 344 | return len(a.queued) | |
| 345 | 345 | } | |
| 346 | 346 | ||
| 347 | - | // PopQueuedMessage removes and returns the most recently queued | |
| 348 | - | // message. Hosts use this for the slide-back keybinding. | |
| 347 | + | // PopQueuedMessage removes and returns the most recently queued message, | |
| 348 | + | // including host events. | |
| 349 | 349 | func (a *Agent) PopQueuedMessage() (QueuedMessage, bool) { | |
| 350 | 350 | a.mu.Lock() | |
| 351 | 351 | defer a.mu.Unlock() | |
| @@ -358,6 +358,24 @@ func (a *Agent) PopQueuedMessage() (QueuedMessage, bool) { | |||
| 358 | 358 | return message, true | |
| 359 | 359 | } | |
| 360 | 360 | ||
| 361 | + | // PopQueuedUserMessage removes the most recently queued ordinary input, | |
| 362 | + | // leaving host evidence in place and preserving the remaining queue order. | |
| 363 | + | func (a *Agent) PopQueuedUserMessage() (QueuedMessage, bool) { | |
| 364 | + | a.mu.Lock() | |
| 365 | + | defer a.mu.Unlock() | |
| 366 | + | for index := len(a.queued) - 1; index >= 0; index-- { | |
| 367 | + | if a.queued[index].message.HostEvent { | |
| 368 | + | continue | |
| 369 | + | } | |
| 370 | + | message := cloneQueuedMessage(a.queued[index].message) | |
| 371 | + | copy(a.queued[index:], a.queued[index+1:]) | |
| 372 | + | a.queued[len(a.queued)-1] = queuedMessage{} | |
| 373 | + | a.queued = a.queued[:len(a.queued)-1] | |
| 374 | + | return message, true | |
| 375 | + | } | |
| 376 | + | return QueuedMessage{}, false | |
| 377 | + | } | |
| 378 | + | ||
| 361 | 379 | // DrainQueuedMessages discards and returns every queued message. | |
| 362 | 380 | // Hosts use this on explicit cancel/clear so stale follow-ups do | |
| 363 | 381 | // not run after the user aborted the turn. | |
| @@ -344,8 +344,8 @@ func (a *Agent) QueuedMessageCount() int { | |||||
| 344 | return len(a.queued) | 344 | return len(a.queued) | ||
| 345 | } | 345 | } | ||
| 346 | 346 | ||||
| 347 | - | // PopQueuedMessage removes and returns the most recently queued | 347 | + | // PopQueuedMessage removes and returns the most recently queued message, |
| 348 | - | // message. Hosts use this for the slide-back keybinding. | 348 | + | // including host events. |
| 349 | func (a *Agent) PopQueuedMessage() (QueuedMessage, bool) { | 349 | func (a *Agent) PopQueuedMessage() (QueuedMessage, bool) { | ||
| 350 | a.mu.Lock() | 350 | a.mu.Lock() | ||
| 351 | defer a.mu.Unlock() | 351 | defer a.mu.Unlock() | ||
| @@ -358,6 +358,24 @@ func (a *Agent) PopQueuedMessage() (QueuedMessage, bool) { | |||||
| 358 | return message, true | 358 | return message, true | ||
| 359 | } | 359 | } | ||
| 360 | 360 | ||||
| 361 | + | // PopQueuedUserMessage removes the most recently queued ordinary input, | |||
| 362 | + | // leaving host evidence in place and preserving the remaining queue order. | |||
| 363 | + | func (a *Agent) PopQueuedUserMessage() (QueuedMessage, bool) { | |||
| 364 | + | a.mu.Lock() | |||
| 365 | + | defer a.mu.Unlock() | |||
| 366 | + | for index := len(a.queued) - 1; index >= 0; index-- { | |||
| 367 | + | if a.queued[index].message.HostEvent { | |||
| 368 | + | continue | |||
| 369 | + | } | |||
| 370 | + | message := cloneQueuedMessage(a.queued[index].message) | |||
| 371 | + | copy(a.queued[index:], a.queued[index+1:]) | |||
| 372 | + | a.queued[len(a.queued)-1] = queuedMessage{} | |||
| 373 | + | a.queued = a.queued[:len(a.queued)-1] | |||
| 374 | + | return message, true | |||
| 375 | + | } | |||
| 376 | + | return QueuedMessage{}, false | |||
| 377 | + | } | |||
| 378 | + | ||||
| 361 | // DrainQueuedMessages discards and returns every queued message. | 379 | // DrainQueuedMessages discards and returns every queued message. | ||
| 362 | // Hosts use this on explicit cancel/clear so stale follow-ups do | 380 | // Hosts use this on explicit cancel/clear so stale follow-ups do | ||
| 363 | // not run after the user aborted the turn. | 381 | // not run after the user aborted the turn. | ||
M packages/core/queue_test.go +32 -0
| @@ -203,6 +203,38 @@ func TestQueuePromptPreservesHostProvenance(t *testing.T) { | |||
| 203 | 203 | } | |
| 204 | 204 | } | |
| 205 | 205 | ||
| 206 | + | func TestPopQueuedUserMessagePreservesHostEvents(t *testing.T) { | |
| 207 | + | a := NewAgent(nil, "fake", "", Registry{}) | |
| 208 | + | a.QueuePrompt(QueuedMessage{Text: "first report", HostEvent: true}) | |
| 209 | + | a.QueueMessage("older draft", nil) | |
| 210 | + | a.QueueMessage("newest draft", []provider.ImageBlock{{MimeType: "image/png", Data: []byte("png-1")}}) | |
| 211 | + | a.QueuePrompt(QueuedMessage{Text: "latest report", HostEvent: true}) | |
| 212 | + | before := append([]queuedMessage(nil), a.queued...) | |
| 213 | + | ||
| 214 | + | message, ok := a.PopQueuedUserMessage() | |
| 215 | + | if !ok || message.Text != "newest draft" || len(message.Images) != 1 || string(message.Images[0].Data) != "png-1" { | |
| 216 | + | t.Fatalf("popped = %#v, %v, want newest draft with image", message, ok) | |
| 217 | + | } | |
| 218 | + | for index, original := range []int{0, 1, 3} { | |
| 219 | + | if !a.queued[index].accepted.Equal(before[original].accepted) { | |
| 220 | + | t.Fatal("recall changed a remaining message's accepted time") | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | if message, ok := a.PopQueuedUserMessage(); !ok || message.Text != "older draft" { | |
| 224 | + | t.Fatalf("second pop = %#v, %v, want older draft", message, ok) | |
| 225 | + | } | |
| 226 | + | if message, ok := a.PopQueuedUserMessage(); ok { | |
| 227 | + | t.Fatalf("host-only pop = %#v, want no user message", message) | |
| 228 | + | } | |
| 229 | + | pending := a.DrainQueuedMessages() | |
| 230 | + | if len(pending) != 2 || pending[0].Text != "first report" || pending[1].Text != "latest report" || !pending[0].HostEvent || !pending[1].HostEvent { | |
| 231 | + | t.Fatalf("remaining reports = %#v, want original reports in order", pending) | |
| 232 | + | } | |
| 233 | + | if _, ok := a.PopQueuedUserMessage(); ok { | |
| 234 | + | t.Fatal("empty pop succeeded") | |
| 235 | + | } | |
| 236 | + | } | |
| 237 | + | ||
| 206 | 238 | func TestQueueMessageSnapshotPopAndDrain(t *testing.T) { | |
| 207 | 239 | a := NewAgent(nil, "fake", "", Registry{}) | |
| 208 | 240 | if a.QueueMessage(" ", nil) { | |
| @@ -203,6 +203,38 @@ func TestQueuePromptPreservesHostProvenance(t *testing.T) { | |||||
| 203 | } | 203 | } | ||
| 204 | } | 204 | } | ||
| 205 | 205 | ||||
| 206 | + | func TestPopQueuedUserMessagePreservesHostEvents(t *testing.T) { | |||
| 207 | + | a := NewAgent(nil, "fake", "", Registry{}) | |||
| 208 | + | a.QueuePrompt(QueuedMessage{Text: "first report", HostEvent: true}) | |||
| 209 | + | a.QueueMessage("older draft", nil) | |||
| 210 | + | a.QueueMessage("newest draft", []provider.ImageBlock{{MimeType: "image/png", Data: []byte("png-1")}}) | |||
| 211 | + | a.QueuePrompt(QueuedMessage{Text: "latest report", HostEvent: true}) | |||
| 212 | + | before := append([]queuedMessage(nil), a.queued...) | |||
| 213 | + | ||||
| 214 | + | message, ok := a.PopQueuedUserMessage() | |||
| 215 | + | if !ok || message.Text != "newest draft" || len(message.Images) != 1 || string(message.Images[0].Data) != "png-1" { | |||
| 216 | + | t.Fatalf("popped = %#v, %v, want newest draft with image", message, ok) | |||
| 217 | + | } | |||
| 218 | + | for index, original := range []int{0, 1, 3} { | |||
| 219 | + | if !a.queued[index].accepted.Equal(before[original].accepted) { | |||
| 220 | + | t.Fatal("recall changed a remaining message's accepted time") | |||
| 221 | + | } | |||
| 222 | + | } | |||
| 223 | + | if message, ok := a.PopQueuedUserMessage(); !ok || message.Text != "older draft" { | |||
| 224 | + | t.Fatalf("second pop = %#v, %v, want older draft", message, ok) | |||
| 225 | + | } | |||
| 226 | + | if message, ok := a.PopQueuedUserMessage(); ok { | |||
| 227 | + | t.Fatalf("host-only pop = %#v, want no user message", message) | |||
| 228 | + | } | |||
| 229 | + | pending := a.DrainQueuedMessages() | |||
| 230 | + | if len(pending) != 2 || pending[0].Text != "first report" || pending[1].Text != "latest report" || !pending[0].HostEvent || !pending[1].HostEvent { | |||
| 231 | + | t.Fatalf("remaining reports = %#v, want original reports in order", pending) | |||
| 232 | + | } | |||
| 233 | + | if _, ok := a.PopQueuedUserMessage(); ok { | |||
| 234 | + | t.Fatal("empty pop succeeded") | |||
| 235 | + | } | |||
| 236 | + | } | |||
| 237 | + | ||||
| 206 | func TestQueueMessageSnapshotPopAndDrain(t *testing.T) { | 238 | func TestQueueMessageSnapshotPopAndDrain(t *testing.T) { | ||
| 207 | a := NewAgent(nil, "fake", "", Registry{}) | 239 | a := NewAgent(nil, "fake", "", Registry{}) | ||
| 208 | if a.QueueMessage(" ", nil) { | 240 | if a.QueueMessage(" ", nil) { | ||