Merge pull request #144 from bnema/feat/queue-image-prompts
feat(modes): queue image prompts while busy
19 files · +339 -103
M README.md +1 -1
| @@ -902,7 +902,7 @@ ZUT_COMPACT_INPUT=1 zut # compact, just this run | |||
| 902 | 902 | ||
| 903 | 903 | ## Queued messages | |
| 904 | 904 | ||
| 905 | - | You can keep typing while the agent is working. Pressing `enter` during a turn queues the message instead of interrupting: it shows up above the status bar as `sliding in: <text>` and is delivered as the next user turn the moment the current one finishes. Queue as many as you want; they run in order. `esc` cancels the active turn, returns the most recently queued message to the input for editing, and drops any remaining stale follow-ups; `ctrl+c` while busy arms the exit hint instead of interrupting, a second `ctrl+c` within two seconds exits zut. | |
| 905 | + | 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. | |
| 906 | 906 | ||
| 907 | 907 | 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`. | |
| 908 | 908 | ||
| @@ -902,7 +902,7 @@ ZUT_COMPACT_INPUT=1 zut # compact, just this run | |||||
| 902 | 902 | ||||
| 903 | ## Queued messages | 903 | ## Queued messages | ||
| 904 | 904 | ||||
| 905 | - | You can keep typing while the agent is working. Pressing `enter` during a turn queues the message instead of interrupting: it shows up above the status bar as `sliding in: <text>` and is delivered as the next user turn the moment the current one finishes. Queue as many as you want; they run in order. `esc` cancels the active turn, returns the most recently queued message to the input for editing, and drops any remaining stale follow-ups; `ctrl+c` while busy arms the exit hint instead of interrupting, a second `ctrl+c` within two seconds exits zut. | 905 | + | 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. |
| 906 | 906 | ||||
| 907 | 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`. | 907 | 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`. | ||
| 908 | 908 | ||||
M packages/agent/internal/orchestration/coordinator.go +32 -10
| @@ -4,7 +4,10 @@ | |||
| 4 | 4 | // events and execute the returned actions. | |
| 5 | 5 | package orchestration | |
| 6 | 6 | ||
| 7 | - | import "github.com/bnema/zut/packages/agent/subagents" | |
| 7 | + | import ( | |
| 8 | + | "github.com/bnema/zut/packages/agent/subagents" | |
| 9 | + | "github.com/bnema/zut/packages/provider" | |
| 10 | + | ) | |
| 8 | 11 | ||
| 9 | 12 | // Coordinator collects one sealed worker wave per manager turn. A terminal | |
| 10 | 13 | // worker cannot wake the manager until the owning turn is sealed. Queued user | |
| @@ -17,7 +20,12 @@ type Coordinator struct { | |||
| 17 | 20 | goalActive bool | |
| 18 | 21 | workers map[string]*worker | |
| 19 | 22 | workerOrder []string | |
| 20 | - | queuedUser []string | |
| 23 | + | queuedUser []queuedUserInput | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | type queuedUserInput struct { | |
| 27 | + | text string | |
| 28 | + | images []provider.ImageBlock | |
| 21 | 29 | } | |
| 22 | 30 | ||
| 23 | 31 | type worker struct { | |
| @@ -43,6 +51,7 @@ type Event struct { | |||
| 43 | 51 | WorkerID string | |
| 44 | 52 | Completion subagents.Completion | |
| 45 | 53 | Text string | |
| 54 | + | Images []provider.ImageBlock | |
| 46 | 55 | GoalActive bool | |
| 47 | 56 | } | |
| 48 | 57 | ||
| @@ -66,6 +75,7 @@ type Action struct { | |||
| 66 | 75 | Kind ActionKind | |
| 67 | 76 | Reason WakeReason | |
| 68 | 77 | Text string | |
| 78 | + | Images []provider.ImageBlock | |
| 69 | 79 | Completions []subagents.Completion | |
| 70 | 80 | } | |
| 71 | 81 | ||
| @@ -124,10 +134,10 @@ func (c *Coordinator) Apply(event Event) Result { | |||
| 124 | 134 | return c.wakeIfIdle() | |
| 125 | 135 | } | |
| 126 | 136 | case EventUserInput: | |
| 127 | - | if event.Text == "" { | |
| 137 | + | if event.Text == "" && len(event.Images) == 0 { | |
| 128 | 138 | return Result{} | |
| 129 | 139 | } | |
| 130 | - | c.queuedUser = append(c.queuedUser, event.Text) | |
| 140 | + | c.queuedUser = append(c.queuedUser, queuedUserInput{text: event.Text, images: cloneImages(event.Images)}) | |
| 131 | 141 | return c.wakeIfIdle() | |
| 132 | 142 | case EventGoalChanged: | |
| 133 | 143 | c.goalActive = event.GoalActive | |
| @@ -145,22 +155,34 @@ func (c *Coordinator) wakeIfIdle() Result { | |||
| 145 | 155 | } | |
| 146 | 156 | completions := c.completedWorkers() | |
| 147 | 157 | if len(c.queuedUser) != 0 { | |
| 148 | - | text := c.queuedUser[0] | |
| 158 | + | input := c.queuedUser[0] | |
| 149 | 159 | c.queuedUser = c.queuedUser[1:] | |
| 150 | - | return c.startManager(WakeUser, text, completions) | |
| 160 | + | return c.startManager(WakeUser, input.text, input.images, completions) | |
| 151 | 161 | } | |
| 152 | 162 | if len(completions) != 0 { | |
| 153 | - | return c.startManager(WakeWorkers, "", completions) | |
| 163 | + | return c.startManager(WakeWorkers, "", nil, completions) | |
| 154 | 164 | } | |
| 155 | 165 | if c.goalActive { | |
| 156 | - | return c.startManager(WakeGoal, "", nil) | |
| 166 | + | return c.startManager(WakeGoal, "", nil, nil) | |
| 157 | 167 | } | |
| 158 | 168 | return Result{} | |
| 159 | 169 | } | |
| 160 | 170 | ||
| 161 | - | func (c *Coordinator) startManager(reason WakeReason, text string, completions []subagents.Completion) Result { | |
| 171 | + | func (c *Coordinator) startManager(reason WakeReason, text string, images []provider.ImageBlock, completions []subagents.Completion) Result { | |
| 162 | 172 | c.managerActive = true | |
| 163 | - | return Result{Actions: []Action{{Kind: ActionRunManager, Reason: reason, Text: text, Completions: completions}}} | |
| 173 | + | return Result{Actions: []Action{{Kind: ActionRunManager, Reason: reason, Text: text, Images: images, Completions: completions}}} | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func cloneImages(images []provider.ImageBlock) []provider.ImageBlock { | |
| 177 | + | if len(images) == 0 { | |
| 178 | + | return nil | |
| 179 | + | } | |
| 180 | + | cloned := make([]provider.ImageBlock, len(images)) | |
| 181 | + | for index, image := range images { | |
| 182 | + | cloned[index] = image | |
| 183 | + | cloned[index].Data = append([]byte(nil), image.Data...) | |
| 184 | + | } | |
| 185 | + | return cloned | |
| 164 | 186 | } | |
| 165 | 187 | ||
| 166 | 188 | func (c *Coordinator) hasPendingWorkers() bool { | |
| @@ -4,7 +4,10 @@ | |||||
| 4 | // events and execute the returned actions. | 4 | // events and execute the returned actions. | ||
| 5 | package orchestration | 5 | package orchestration | ||
| 6 | 6 | ||||
| 7 | - | import "github.com/bnema/zut/packages/agent/subagents" | 7 | + | import ( |
| 8 | + | "github.com/bnema/zut/packages/agent/subagents" | |||
| 9 | + | "github.com/bnema/zut/packages/provider" | |||
| 10 | + | ) | |||
| 8 | 11 | ||||
| 9 | // Coordinator collects one sealed worker wave per manager turn. A terminal | 12 | // Coordinator collects one sealed worker wave per manager turn. A terminal | ||
| 10 | // worker cannot wake the manager until the owning turn is sealed. Queued user | 13 | // worker cannot wake the manager until the owning turn is sealed. Queued user | ||
| @@ -17,7 +20,12 @@ type Coordinator struct { | |||||
| 17 | goalActive bool | 20 | goalActive bool | ||
| 18 | workers map[string]*worker | 21 | workers map[string]*worker | ||
| 19 | workerOrder []string | 22 | workerOrder []string | ||
| 20 | - | queuedUser []string | 23 | + | queuedUser []queuedUserInput |
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | type queuedUserInput struct { | |||
| 27 | + | text string | |||
| 28 | + | images []provider.ImageBlock | |||
| 21 | } | 29 | } | ||
| 22 | 30 | ||||
| 23 | type worker struct { | 31 | type worker struct { | ||
| @@ -43,6 +51,7 @@ type Event struct { | |||||
| 43 | WorkerID string | 51 | WorkerID string | ||
| 44 | Completion subagents.Completion | 52 | Completion subagents.Completion | ||
| 45 | Text string | 53 | Text string | ||
| 54 | + | Images []provider.ImageBlock | |||
| 46 | GoalActive bool | 55 | GoalActive bool | ||
| 47 | } | 56 | } | ||
| 48 | 57 | ||||
| @@ -66,6 +75,7 @@ type Action struct { | |||||
| 66 | Kind ActionKind | 75 | Kind ActionKind | ||
| 67 | Reason WakeReason | 76 | Reason WakeReason | ||
| 68 | Text string | 77 | Text string | ||
| 78 | + | Images []provider.ImageBlock | |||
| 69 | Completions []subagents.Completion | 79 | Completions []subagents.Completion | ||
| 70 | } | 80 | } | ||
| 71 | 81 | ||||
| @@ -124,10 +134,10 @@ func (c *Coordinator) Apply(event Event) Result { | |||||
| 124 | return c.wakeIfIdle() | 134 | return c.wakeIfIdle() | ||
| 125 | } | 135 | } | ||
| 126 | case EventUserInput: | 136 | case EventUserInput: | ||
| 127 | - | if event.Text == "" { | 137 | + | if event.Text == "" && len(event.Images) == 0 { |
| 128 | return Result{} | 138 | return Result{} | ||
| 129 | } | 139 | } | ||
| 130 | - | c.queuedUser = append(c.queuedUser, event.Text) | 140 | + | c.queuedUser = append(c.queuedUser, queuedUserInput{text: event.Text, images: cloneImages(event.Images)}) |
| 131 | return c.wakeIfIdle() | 141 | return c.wakeIfIdle() | ||
| 132 | case EventGoalChanged: | 142 | case EventGoalChanged: | ||
| 133 | c.goalActive = event.GoalActive | 143 | c.goalActive = event.GoalActive | ||
| @@ -145,22 +155,34 @@ func (c *Coordinator) wakeIfIdle() Result { | |||||
| 145 | } | 155 | } | ||
| 146 | completions := c.completedWorkers() | 156 | completions := c.completedWorkers() | ||
| 147 | if len(c.queuedUser) != 0 { | 157 | if len(c.queuedUser) != 0 { | ||
| 148 | - | text := c.queuedUser[0] | 158 | + | input := c.queuedUser[0] |
| 149 | c.queuedUser = c.queuedUser[1:] | 159 | c.queuedUser = c.queuedUser[1:] | ||
| 150 | - | return c.startManager(WakeUser, text, completions) | 160 | + | return c.startManager(WakeUser, input.text, input.images, completions) |
| 151 | } | 161 | } | ||
| 152 | if len(completions) != 0 { | 162 | if len(completions) != 0 { | ||
| 153 | - | return c.startManager(WakeWorkers, "", completions) | 163 | + | return c.startManager(WakeWorkers, "", nil, completions) |
| 154 | } | 164 | } | ||
| 155 | if c.goalActive { | 165 | if c.goalActive { | ||
| 156 | - | return c.startManager(WakeGoal, "", nil) | 166 | + | return c.startManager(WakeGoal, "", nil, nil) |
| 157 | } | 167 | } | ||
| 158 | return Result{} | 168 | return Result{} | ||
| 159 | } | 169 | } | ||
| 160 | 170 | ||||
| 161 | - | func (c *Coordinator) startManager(reason WakeReason, text string, completions []subagents.Completion) Result { | 171 | + | func (c *Coordinator) startManager(reason WakeReason, text string, images []provider.ImageBlock, completions []subagents.Completion) Result { |
| 162 | c.managerActive = true | 172 | c.managerActive = true | ||
| 163 | - | return Result{Actions: []Action{{Kind: ActionRunManager, Reason: reason, Text: text, Completions: completions}}} | 173 | + | return Result{Actions: []Action{{Kind: ActionRunManager, Reason: reason, Text: text, Images: images, Completions: completions}}} |
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | func cloneImages(images []provider.ImageBlock) []provider.ImageBlock { | |||
| 177 | + | if len(images) == 0 { | |||
| 178 | + | return nil | |||
| 179 | + | } | |||
| 180 | + | cloned := make([]provider.ImageBlock, len(images)) | |||
| 181 | + | for index, image := range images { | |||
| 182 | + | cloned[index] = image | |||
| 183 | + | cloned[index].Data = append([]byte(nil), image.Data...) | |||
| 184 | + | } | |||
| 185 | + | return cloned | |||
| 164 | } | 186 | } | ||
| 165 | 187 | ||||
| 166 | func (c *Coordinator) hasPendingWorkers() bool { | 188 | func (c *Coordinator) hasPendingWorkers() bool { | ||
M packages/agent/internal/orchestration/coordinator_test.go +12 -2
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | 6 | "github.com/bnema/zut/packages/agent/subagents" | |
| 7 | + | "github.com/bnema/zut/packages/provider" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| 9 | 10 | func TestCoordinatorSealsWorkerWaveAndWakesOnce(t *testing.T) { | |
| @@ -24,9 +25,10 @@ func TestCoordinatorGivesQueuedUserInputPriority(t *testing.T) { | |||
| 24 | 25 | c.Apply(Event{Kind: EventManagerStarted}) | |
| 25 | 26 | c.Apply(Event{Kind: EventWorkerRegistered, WorkerID: "worker"}) | |
| 26 | 27 | c.Apply(Event{Kind: EventManagerFinished}) | |
| 27 | - | c.Apply(Event{Kind: EventUserInput, Text: "stop and explain"}) | |
| 28 | + | image := provider.ImageBlock{MimeType: "image/png", Data: []byte("png-1")} | |
| 29 | + | c.Apply(Event{Kind: EventUserInput, Text: "stop and explain", Images: []provider.ImageBlock{image}}) | |
| 28 | 30 | assertActions(t, c.Apply(Event{Kind: EventWorkerFinished, WorkerID: "worker", Completion: subagents.Completion{AgentID: "worker", Status: "failed"}}), []Action{{ | |
| 29 | - | Kind: ActionRunManager, Reason: WakeUser, Text: "stop and explain", | |
| 31 | + | Kind: ActionRunManager, Reason: WakeUser, Text: "stop and explain", Images: []provider.ImageBlock{image}, | |
| 30 | 32 | Completions: []subagents.Completion{{AgentID: "worker", Status: "failed"}}, | |
| 31 | 33 | }}) | |
| 32 | 34 | } | |
| @@ -55,6 +57,14 @@ func assertActions(t *testing.T, got Result, want []Action) { | |||
| 55 | 57 | if got.Actions[i].Kind != want[i].Kind || got.Actions[i].Reason != want[i].Reason || got.Actions[i].Text != want[i].Text { | |
| 56 | 58 | t.Fatalf("action %d = %#v, want %#v", i, got.Actions[i], want[i]) | |
| 57 | 59 | } | |
| 60 | + | if len(got.Actions[i].Images) != len(want[i].Images) { | |
| 61 | + | t.Fatalf("action %d images = %#v, want %#v", i, got.Actions[i].Images, want[i].Images) | |
| 62 | + | } | |
| 63 | + | for j := range want[i].Images { | |
| 64 | + | if got.Actions[i].Images[j].MimeType != want[i].Images[j].MimeType || string(got.Actions[i].Images[j].Data) != string(want[i].Images[j].Data) { | |
| 65 | + | t.Fatalf("action %d image %d = %#v, want %#v", i, j, got.Actions[i].Images[j], want[i].Images[j]) | |
| 66 | + | } | |
| 67 | + | } | |
| 58 | 68 | if len(got.Actions[i].Completions) != len(want[i].Completions) { | |
| 59 | 69 | t.Fatalf("action %d completions = %#v, want %#v", i, got.Actions[i].Completions, want[i].Completions) | |
| 60 | 70 | } | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "testing" | 4 | "testing" | ||
| 5 | 5 | ||||
| 6 | "github.com/bnema/zut/packages/agent/subagents" | 6 | "github.com/bnema/zut/packages/agent/subagents" | ||
| 7 | + | "github.com/bnema/zut/packages/provider" | |||
| 7 | ) | 8 | ) | ||
| 8 | 9 | ||||
| 9 | func TestCoordinatorSealsWorkerWaveAndWakesOnce(t *testing.T) { | 10 | func TestCoordinatorSealsWorkerWaveAndWakesOnce(t *testing.T) { | ||
| @@ -24,9 +25,10 @@ func TestCoordinatorGivesQueuedUserInputPriority(t *testing.T) { | |||||
| 24 | c.Apply(Event{Kind: EventManagerStarted}) | 25 | c.Apply(Event{Kind: EventManagerStarted}) | ||
| 25 | c.Apply(Event{Kind: EventWorkerRegistered, WorkerID: "worker"}) | 26 | c.Apply(Event{Kind: EventWorkerRegistered, WorkerID: "worker"}) | ||
| 26 | c.Apply(Event{Kind: EventManagerFinished}) | 27 | c.Apply(Event{Kind: EventManagerFinished}) | ||
| 27 | - | c.Apply(Event{Kind: EventUserInput, Text: "stop and explain"}) | 28 | + | image := provider.ImageBlock{MimeType: "image/png", Data: []byte("png-1")} |
| 29 | + | c.Apply(Event{Kind: EventUserInput, Text: "stop and explain", Images: []provider.ImageBlock{image}}) | |||
| 28 | assertActions(t, c.Apply(Event{Kind: EventWorkerFinished, WorkerID: "worker", Completion: subagents.Completion{AgentID: "worker", Status: "failed"}}), []Action{{ | 30 | assertActions(t, c.Apply(Event{Kind: EventWorkerFinished, WorkerID: "worker", Completion: subagents.Completion{AgentID: "worker", Status: "failed"}}), []Action{{ | ||
| 29 | - | Kind: ActionRunManager, Reason: WakeUser, Text: "stop and explain", | 31 | + | Kind: ActionRunManager, Reason: WakeUser, Text: "stop and explain", Images: []provider.ImageBlock{image}, |
| 30 | Completions: []subagents.Completion{{AgentID: "worker", Status: "failed"}}, | 32 | Completions: []subagents.Completion{{AgentID: "worker", Status: "failed"}}, | ||
| 31 | }}) | 33 | }}) | ||
| 32 | } | 34 | } | ||
| @@ -55,6 +57,14 @@ func assertActions(t *testing.T, got Result, want []Action) { | |||||
| 55 | if got.Actions[i].Kind != want[i].Kind || got.Actions[i].Reason != want[i].Reason || got.Actions[i].Text != want[i].Text { | 57 | if got.Actions[i].Kind != want[i].Kind || got.Actions[i].Reason != want[i].Reason || got.Actions[i].Text != want[i].Text { | ||
| 56 | t.Fatalf("action %d = %#v, want %#v", i, got.Actions[i], want[i]) | 58 | t.Fatalf("action %d = %#v, want %#v", i, got.Actions[i], want[i]) | ||
| 57 | } | 59 | } | ||
| 60 | + | if len(got.Actions[i].Images) != len(want[i].Images) { | |||
| 61 | + | t.Fatalf("action %d images = %#v, want %#v", i, got.Actions[i].Images, want[i].Images) | |||
| 62 | + | } | |||
| 63 | + | for j := range want[i].Images { | |||
| 64 | + | if got.Actions[i].Images[j].MimeType != want[i].Images[j].MimeType || string(got.Actions[i].Images[j].Data) != string(want[i].Images[j].Data) { | |||
| 65 | + | t.Fatalf("action %d image %d = %#v, want %#v", i, j, got.Actions[i].Images[j], want[i].Images[j]) | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 58 | if len(got.Actions[i].Completions) != len(want[i].Completions) { | 68 | if len(got.Actions[i].Completions) != len(want[i].Completions) { | ||
| 59 | t.Fatalf("action %d completions = %#v, want %#v", i, got.Actions[i].Completions, want[i].Completions) | 69 | t.Fatalf("action %d completions = %#v, want %#v", i, got.Actions[i].Completions, want[i].Completions) | ||
| 60 | } | 70 | } | ||
M packages/agent/modes/clipboard_images.go +27 -0
| @@ -1,8 +1,10 @@ | |||
| 1 | 1 | package modes | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "fmt" | |
| 4 | 5 | "strings" | |
| 5 | 6 | ||
| 7 | + | "github.com/bnema/zut/packages/core" | |
| 6 | 8 | "github.com/bnema/zut/packages/provider" | |
| 7 | 9 | ) | |
| 8 | 10 | ||
| @@ -28,6 +30,31 @@ func preparePromptWithClipboardImages(text string, pending []clipboardImageAttac | |||
| 28 | 30 | return strings.TrimSpace(out), images | |
| 29 | 31 | } | |
| 30 | 32 | ||
| 33 | + | func (i *Interactive) restoreQueuedMessageToEditor(message core.QueuedMessage) bool { | |
| 34 | + | if message.Text == "" && len(message.Images) == 0 { | |
| 35 | + | return false | |
| 36 | + | } | |
| 37 | + | text := message.Text | |
| 38 | + | attachments := make([]clipboardImageAttachment, 0, len(message.Images)) | |
| 39 | + | for index, image := range message.Images { | |
| 40 | + | markerIndex := index + 1 | |
| 41 | + | marker := fmt.Sprintf("[clipboard image #%d]", markerIndex) | |
| 42 | + | for strings.Contains(text, marker) { | |
| 43 | + | markerIndex++ | |
| 44 | + | marker = fmt.Sprintf("[clipboard image #%d]", markerIndex) | |
| 45 | + | } | |
| 46 | + | if text != "" { | |
| 47 | + | text += " " | |
| 48 | + | } | |
| 49 | + | text += marker | |
| 50 | + | attachments = append(attachments, clipboardImageAttachment{Marker: marker, Image: image}) | |
| 51 | + | } | |
| 52 | + | i.clipboardImages = attachments | |
| 53 | + | i.ed.SetValue(text) | |
| 54 | + | i.inputHistoryIndex = -1 | |
| 55 | + | return true | |
| 56 | + | } | |
| 57 | + | ||
| 31 | 58 | func removeClipboardMarker(text, marker string) string { | |
| 32 | 59 | for { | |
| 33 | 60 | idx := strings.Index(text, marker) | |
| @@ -1,8 +1,10 @@ | |||||
| 1 | package modes | 1 | package modes | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "fmt" | |||
| 4 | "strings" | 5 | "strings" | ||
| 5 | 6 | ||||
| 7 | + | "github.com/bnema/zut/packages/core" | |||
| 6 | "github.com/bnema/zut/packages/provider" | 8 | "github.com/bnema/zut/packages/provider" | ||
| 7 | ) | 9 | ) | ||
| 8 | 10 | ||||
| @@ -28,6 +30,31 @@ func preparePromptWithClipboardImages(text string, pending []clipboardImageAttac | |||||
| 28 | return strings.TrimSpace(out), images | 30 | return strings.TrimSpace(out), images | ||
| 29 | } | 31 | } | ||
| 30 | 32 | ||||
| 33 | + | func (i *Interactive) restoreQueuedMessageToEditor(message core.QueuedMessage) bool { | |||
| 34 | + | if message.Text == "" && len(message.Images) == 0 { | |||
| 35 | + | return false | |||
| 36 | + | } | |||
| 37 | + | text := message.Text | |||
| 38 | + | attachments := make([]clipboardImageAttachment, 0, len(message.Images)) | |||
| 39 | + | for index, image := range message.Images { | |||
| 40 | + | markerIndex := index + 1 | |||
| 41 | + | marker := fmt.Sprintf("[clipboard image #%d]", markerIndex) | |||
| 42 | + | for strings.Contains(text, marker) { | |||
| 43 | + | markerIndex++ | |||
| 44 | + | marker = fmt.Sprintf("[clipboard image #%d]", markerIndex) | |||
| 45 | + | } | |||
| 46 | + | if text != "" { | |||
| 47 | + | text += " " | |||
| 48 | + | } | |||
| 49 | + | text += marker | |||
| 50 | + | attachments = append(attachments, clipboardImageAttachment{Marker: marker, Image: image}) | |||
| 51 | + | } | |||
| 52 | + | i.clipboardImages = attachments | |||
| 53 | + | i.ed.SetValue(text) | |||
| 54 | + | i.inputHistoryIndex = -1 | |||
| 55 | + | return true | |||
| 56 | + | } | |||
| 57 | + | ||||
| 31 | func removeClipboardMarker(text, marker string) string { | 58 | func removeClipboardMarker(text, marker string) string { | ||
| 32 | for { | 59 | for { | ||
| 33 | idx := strings.Index(text, marker) | 60 | idx := strings.Index(text, marker) | ||
M packages/agent/modes/clipboard_images_test.go +21 -0
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "strings" | |
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 7 | + | "github.com/bnema/zut/packages/core" | |
| 7 | 8 | "github.com/bnema/zut/packages/provider" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| @@ -14,6 +15,26 @@ func testClipboardImage(marker string, data string) clipboardImageAttachment { | |||
| 14 | 15 | } | |
| 15 | 16 | } | |
| 16 | 17 | ||
| 18 | + | func TestRestoredQueuedImageDoesNotConsumeLiteralClipboardMarker(t *testing.T) { | |
| 19 | + | i := NewInteractive(InteractiveConfig{}) | |
| 20 | + | message := core.QueuedMessage{ | |
| 21 | + | Text: "keep literal [clipboard image #1]", | |
| 22 | + | Images: []provider.ImageBlock{{MimeType: "image/png", Data: []byte("png-1")}}, | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | if !i.restoreQueuedMessageToEditor(message) { | |
| 26 | + | t.Fatal("queued message was not restored") | |
| 27 | + | } | |
| 28 | + | text, images := preparePromptWithClipboardImages(i.ed.Value(), i.clipboardImages) | |
| 29 | + | ||
| 30 | + | if text != message.Text { | |
| 31 | + | t.Fatalf("resubmitted text = %q, want %q", text, message.Text) | |
| 32 | + | } | |
| 33 | + | if len(images) != 1 || string(images[0].Data) != "png-1" { | |
| 34 | + | t.Fatalf("resubmitted images = %#v, want png-1", images) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 17 | 38 | func TestPreparePromptWithClipboardImagesStripsPresentMarker(t *testing.T) { | |
| 18 | 39 | pending := []clipboardImageAttachment{testClipboardImage("[clipboard image #1]", "png-1")} | |
| 19 | 40 | ||
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "strings" | 4 | "strings" | ||
| 5 | "testing" | 5 | "testing" | ||
| 6 | 6 | ||||
| 7 | + | "github.com/bnema/zut/packages/core" | |||
| 7 | "github.com/bnema/zut/packages/provider" | 8 | "github.com/bnema/zut/packages/provider" | ||
| 8 | ) | 9 | ) | ||
| 9 | 10 | ||||
| @@ -14,6 +15,26 @@ func testClipboardImage(marker string, data string) clipboardImageAttachment { | |||||
| 14 | } | 15 | } | ||
| 15 | } | 16 | } | ||
| 16 | 17 | ||||
| 18 | + | func TestRestoredQueuedImageDoesNotConsumeLiteralClipboardMarker(t *testing.T) { | |||
| 19 | + | i := NewInteractive(InteractiveConfig{}) | |||
| 20 | + | message := core.QueuedMessage{ | |||
| 21 | + | Text: "keep literal [clipboard image #1]", | |||
| 22 | + | Images: []provider.ImageBlock{{MimeType: "image/png", Data: []byte("png-1")}}, | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | if !i.restoreQueuedMessageToEditor(message) { | |||
| 26 | + | t.Fatal("queued message was not restored") | |||
| 27 | + | } | |||
| 28 | + | text, images := preparePromptWithClipboardImages(i.ed.Value(), i.clipboardImages) | |||
| 29 | + | ||||
| 30 | + | if text != message.Text { | |||
| 31 | + | t.Fatalf("resubmitted text = %q, want %q", text, message.Text) | |||
| 32 | + | } | |||
| 33 | + | if len(images) != 1 || string(images[0].Data) != "png-1" { | |||
| 34 | + | t.Fatalf("resubmitted images = %#v, want png-1", images) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 17 | func TestPreparePromptWithClipboardImagesStripsPresentMarker(t *testing.T) { | 38 | func TestPreparePromptWithClipboardImagesStripsPresentMarker(t *testing.T) { | ||
| 18 | pending := []clipboardImageAttachment{testClipboardImage("[clipboard image #1]", "png-1")} | 39 | pending := []clipboardImageAttachment{testClipboardImage("[clipboard image #1]", "png-1")} | ||
| 19 | 40 | ||||
M packages/agent/modes/compact_queue_test.go +2 -2
| @@ -543,7 +543,7 @@ func TestRestoredHandoffReservesTurnBeforePersistence(t *testing.T) { | |||
| 543 | 543 | }) | |
| 544 | 544 | interactive.runCtx = context.Background() | |
| 545 | 545 | interactive.mu.Lock() | |
| 546 | - | interactive.queued = []string{"newer user request"} | |
| 546 | + | interactive.queued = []core.QueuedMessage{{Text: "newer user request"}} | |
| 547 | 547 | interactive.mu.Unlock() | |
| 548 | 548 | ||
| 549 | 549 | done := make(chan struct{}) | |
| @@ -631,7 +631,7 @@ func TestRestoredHandoffDefersToQueuedUserPrompt(t *testing.T) { | |||
| 631 | 631 | }) | |
| 632 | 632 | interactive.runCtx = context.Background() | |
| 633 | 633 | interactive.mu.Lock() | |
| 634 | - | interactive.queued = []string{"newer user request"} | |
| 634 | + | interactive.queued = []core.QueuedMessage{{Text: "newer user request"}} | |
| 635 | 635 | interactive.mu.Unlock() | |
| 636 | 636 | ||
| 637 | 637 | interactive.startRestoredCompactHandoff(context.Background()) | |
| @@ -543,7 +543,7 @@ func TestRestoredHandoffReservesTurnBeforePersistence(t *testing.T) { | |||||
| 543 | }) | 543 | }) | ||
| 544 | interactive.runCtx = context.Background() | 544 | interactive.runCtx = context.Background() | ||
| 545 | interactive.mu.Lock() | 545 | interactive.mu.Lock() | ||
| 546 | - | interactive.queued = []string{"newer user request"} | 546 | + | interactive.queued = []core.QueuedMessage{{Text: "newer user request"}} |
| 547 | interactive.mu.Unlock() | 547 | interactive.mu.Unlock() | ||
| 548 | 548 | ||||
| 549 | done := make(chan struct{}) | 549 | done := make(chan struct{}) | ||
| @@ -631,7 +631,7 @@ func TestRestoredHandoffDefersToQueuedUserPrompt(t *testing.T) { | |||||
| 631 | }) | 631 | }) | ||
| 632 | interactive.runCtx = context.Background() | 632 | interactive.runCtx = context.Background() | ||
| 633 | interactive.mu.Lock() | 633 | interactive.mu.Lock() | ||
| 634 | - | interactive.queued = []string{"newer user request"} | 634 | + | interactive.queued = []core.QueuedMessage{{Text: "newer user request"}} |
| 635 | interactive.mu.Unlock() | 635 | interactive.mu.Unlock() | ||
| 636 | 636 | ||||
| 637 | interactive.startRestoredCompactHandoff(context.Background()) | 637 | interactive.startRestoredCompactHandoff(context.Background()) | ||
M packages/agent/modes/goal.go +2 -2
| @@ -100,7 +100,7 @@ func (i *Interactive) startGoalContinuation(parent context.Context, message prov | |||
| 100 | 100 | i.queued = i.queued[1:] | |
| 101 | 101 | i.mu.Unlock() | |
| 102 | 102 | i.abandonGoalRun(run) | |
| 103 | - | i.startTurn(parent, next) | |
| 103 | + | i.startTurnWithImages(parent, next.Text, next.Images) | |
| 104 | 104 | return | |
| 105 | 105 | } | |
| 106 | 106 | if ag.QueuedMessageCount() > 0 { | |
| @@ -134,7 +134,7 @@ func (i *Interactive) startGoalContinuation(parent context.Context, message prov | |||
| 134 | 134 | i.queued = i.queued[1:] | |
| 135 | 135 | i.mu.Unlock() | |
| 136 | 136 | i.abandonGoalRun(run) | |
| 137 | - | i.startTurn(parent, next) | |
| 137 | + | i.startTurnWithImages(parent, next.Text, next.Images) | |
| 138 | 138 | return | |
| 139 | 139 | } | |
| 140 | 140 | i.mu.Unlock() | |
| @@ -100,7 +100,7 @@ func (i *Interactive) startGoalContinuation(parent context.Context, message prov | |||||
| 100 | i.queued = i.queued[1:] | 100 | i.queued = i.queued[1:] | ||
| 101 | i.mu.Unlock() | 101 | i.mu.Unlock() | ||
| 102 | i.abandonGoalRun(run) | 102 | i.abandonGoalRun(run) | ||
| 103 | - | i.startTurn(parent, next) | 103 | + | i.startTurnWithImages(parent, next.Text, next.Images) |
| 104 | return | 104 | return | ||
| 105 | } | 105 | } | ||
| 106 | if ag.QueuedMessageCount() > 0 { | 106 | if ag.QueuedMessageCount() > 0 { | ||
| @@ -134,7 +134,7 @@ func (i *Interactive) startGoalContinuation(parent context.Context, message prov | |||||
| 134 | i.queued = i.queued[1:] | 134 | i.queued = i.queued[1:] | ||
| 135 | i.mu.Unlock() | 135 | i.mu.Unlock() | ||
| 136 | i.abandonGoalRun(run) | 136 | i.abandonGoalRun(run) | ||
| 137 | - | i.startTurn(parent, next) | 137 | + | i.startTurnWithImages(parent, next.Text, next.Images) |
| 138 | return | 138 | return | ||
| 139 | } | 139 | } | ||
| 140 | i.mu.Unlock() | 140 | i.mu.Unlock() | ||
M packages/agent/modes/interactive.go +6 -9
| @@ -733,7 +733,7 @@ type Interactive struct { | |||
| 733 | 733 | // Messages typed while a turn is in flight. Each is delivered as | |
| 734 | 734 | // its own follow-up turn once the current one finishes. Rendered | |
| 735 | 735 | // above the status bar as "sliding in: ..." chips. | |
| 736 | - | queued []string | |
| 736 | + | queued []core.QueuedMessage | |
| 737 | 737 | ||
| 738 | 738 | // scheduled holds prompt-only follow-ups injected by the in-process | |
| 739 | 739 | // scheduler. Unlike agent.QueueMessage, these always wait until the | |
| @@ -1533,13 +1533,10 @@ const ctrlCExitWindow = 2 * time.Second | |||
| 1533 | 1533 | // agent cancel the active turn first via the same path the editor | |
| 1534 | 1534 | // uses for typed slash commands. | |
| 1535 | 1535 | ||
| 1536 | - | // SubmitOrQueue runs text immediately if the agent is idle, or | |
| 1537 | - | // appends it to the pending queue if a turn is already in flight. | |
| 1538 | - | // Used by the telegram bridge (and by the editor submit path) so | |
| 1539 | - | // both input sources share the same "queue behind an active turn" | |
| 1540 | - | // semantics. Images are ignored for now — only the text prompt is | |
| 1541 | - | // forwarded — because the queued-prompt path is text-only; a | |
| 1542 | - | // follow-up can expand the queue entry to carry images. | |
| 1536 | + | // SubmitOrQueue runs a text-and-image prompt immediately if the agent is idle, | |
| 1537 | + | // or appends it to the pending queue if a turn is already in flight. Used by | |
| 1538 | + | // the Telegram bridge and editor submit path so both input sources share the | |
| 1539 | + | // same queue semantics. | |
| 1543 | 1540 | ||
| 1544 | 1541 | // CancelTurn aborts the active turn if one is running. Used by the | |
| 1545 | 1542 | // telegram bridge when the paired user sends /stop. | |
| @@ -1667,7 +1664,7 @@ const modalBackdropDimPercent = 50 | |||
| 1667 | 1664 | // model call immediately instead of just opening an empty dialog. | |
| 1668 | 1665 | ||
| 1669 | 1666 | // submitOrQueuePrompt submits a slash command's expanded prompt immediately, | |
| 1670 | - | // or queues it behind the active turn using the normal text-only queue. | |
| 1667 | + | // or queues it behind the active turn. | |
| 1671 | 1668 | ||
| 1672 | 1669 | // openSkillsDialog opens the skill inspector. The picker reflects | |
| 1673 | 1670 | // whatever SkillSnapshot returns at call time, so edits to a | |
| @@ -733,7 +733,7 @@ type Interactive struct { | |||||
| 733 | // Messages typed while a turn is in flight. Each is delivered as | 733 | // Messages typed while a turn is in flight. Each is delivered as | ||
| 734 | // its own follow-up turn once the current one finishes. Rendered | 734 | // its own follow-up turn once the current one finishes. Rendered | ||
| 735 | // above the status bar as "sliding in: ..." chips. | 735 | // above the status bar as "sliding in: ..." chips. | ||
| 736 | - | queued []string | 736 | + | queued []core.QueuedMessage |
| 737 | 737 | ||||
| 738 | // scheduled holds prompt-only follow-ups injected by the in-process | 738 | // scheduled holds prompt-only follow-ups injected by the in-process | ||
| 739 | // scheduler. Unlike agent.QueueMessage, these always wait until the | 739 | // scheduler. Unlike agent.QueueMessage, these always wait until the | ||
| @@ -1533,13 +1533,10 @@ const ctrlCExitWindow = 2 * time.Second | |||||
| 1533 | // agent cancel the active turn first via the same path the editor | 1533 | // agent cancel the active turn first via the same path the editor | ||
| 1534 | // uses for typed slash commands. | 1534 | // uses for typed slash commands. | ||
| 1535 | 1535 | ||||
| 1536 | - | // SubmitOrQueue runs text immediately if the agent is idle, or | 1536 | + | // SubmitOrQueue runs a text-and-image prompt immediately if the agent is idle, |
| 1537 | - | // appends it to the pending queue if a turn is already in flight. | 1537 | + | // or appends it to the pending queue if a turn is already in flight. Used by |
| 1538 | - | // Used by the telegram bridge (and by the editor submit path) so | 1538 | + | // the Telegram bridge and editor submit path so both input sources share the |
| 1539 | - | // both input sources share the same "queue behind an active turn" | 1539 | + | // same queue semantics. |
| 1540 | - | // semantics. Images are ignored for now — only the text prompt is | |||
| 1541 | - | // forwarded — because the queued-prompt path is text-only; a | |||
| 1542 | - | // follow-up can expand the queue entry to carry images. | |||
| 1543 | 1540 | ||||
| 1544 | // CancelTurn aborts the active turn if one is running. Used by the | 1541 | // CancelTurn aborts the active turn if one is running. Used by the | ||
| 1545 | // telegram bridge when the paired user sends /stop. | 1542 | // telegram bridge when the paired user sends /stop. | ||
| @@ -1667,7 +1664,7 @@ const modalBackdropDimPercent = 50 | |||||
| 1667 | // model call immediately instead of just opening an empty dialog. | 1664 | // model call immediately instead of just opening an empty dialog. | ||
| 1668 | 1665 | ||||
| 1669 | // submitOrQueuePrompt submits a slash command's expanded prompt immediately, | 1666 | // submitOrQueuePrompt submits a slash command's expanded prompt immediately, | ||
| 1670 | - | // or queues it behind the active turn using the normal text-only queue. | 1667 | + | // or queues it behind the active turn. |
| 1671 | 1668 | ||||
| 1672 | // openSkillsDialog opens the skill inspector. The picker reflects | 1669 | // openSkillsDialog opens the skill inspector. The picker reflects | ||
| 1673 | // whatever SkillSnapshot returns at call time, so edits to a | 1670 | // whatever SkillSnapshot returns at call time, so edits to a | ||
M packages/agent/modes/interactive_commands.go +4 -4
| @@ -347,12 +347,12 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 347 | 347 | if ag != nil && !compacting { | |
| 348 | 348 | i.mu.Lock() | |
| 349 | 349 | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 350 | - | ag.QueueMessage(studyPrompt) | |
| 350 | + | ag.QueueMessage(studyPrompt, nil) | |
| 351 | 351 | i.mu.Unlock() | |
| 352 | 352 | } else { | |
| 353 | 353 | i.mu.Lock() | |
| 354 | 354 | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 355 | - | i.queued = append(i.queued, studyPrompt) | |
| 355 | + | i.queued = append(i.queued, core.QueuedMessage{Text: studyPrompt}) | |
| 356 | 356 | i.mu.Unlock() | |
| 357 | 357 | } | |
| 358 | 358 | if persistHandoff { | |
| @@ -880,10 +880,10 @@ func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) { | |||
| 880 | 880 | var persistHandoff bool | |
| 881 | 881 | if i.compacting { | |
| 882 | 882 | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 883 | - | i.queued = append(i.queued, prompt) | |
| 883 | + | i.queued = append(i.queued, core.QueuedMessage{Text: prompt}) | |
| 884 | 884 | } else { | |
| 885 | 885 | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 886 | - | i.agent.QueueMessage(prompt) | |
| 886 | + | i.agent.QueueMessage(prompt, nil) | |
| 887 | 887 | } | |
| 888 | 888 | i.mu.Unlock() | |
| 889 | 889 | if persistHandoff { | |
| @@ -347,12 +347,12 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 347 | if ag != nil && !compacting { | 347 | if ag != nil && !compacting { | ||
| 348 | i.mu.Lock() | 348 | i.mu.Lock() | ||
| 349 | handoff, persistHandoff = i.resetCompactContinuationLocked() | 349 | handoff, persistHandoff = i.resetCompactContinuationLocked() | ||
| 350 | - | ag.QueueMessage(studyPrompt) | 350 | + | ag.QueueMessage(studyPrompt, nil) |
| 351 | i.mu.Unlock() | 351 | i.mu.Unlock() | ||
| 352 | } else { | 352 | } else { | ||
| 353 | i.mu.Lock() | 353 | i.mu.Lock() | ||
| 354 | handoff, persistHandoff = i.resetCompactContinuationLocked() | 354 | handoff, persistHandoff = i.resetCompactContinuationLocked() | ||
| 355 | - | i.queued = append(i.queued, studyPrompt) | 355 | + | i.queued = append(i.queued, core.QueuedMessage{Text: studyPrompt}) |
| 356 | i.mu.Unlock() | 356 | i.mu.Unlock() | ||
| 357 | } | 357 | } | ||
| 358 | if persistHandoff { | 358 | if persistHandoff { | ||
| @@ -880,10 +880,10 @@ func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) { | |||||
| 880 | var persistHandoff bool | 880 | var persistHandoff bool | ||
| 881 | if i.compacting { | 881 | if i.compacting { | ||
| 882 | handoff, persistHandoff = i.resetCompactContinuationLocked() | 882 | handoff, persistHandoff = i.resetCompactContinuationLocked() | ||
| 883 | - | i.queued = append(i.queued, prompt) | 883 | + | i.queued = append(i.queued, core.QueuedMessage{Text: prompt}) |
| 884 | } else { | 884 | } else { | ||
| 885 | handoff, persistHandoff = i.resetCompactContinuationLocked() | 885 | handoff, persistHandoff = i.resetCompactContinuationLocked() | ||
| 886 | - | i.agent.QueueMessage(prompt) | 886 | + | i.agent.QueueMessage(prompt, nil) |
| 887 | } | 887 | } | ||
| 888 | i.mu.Unlock() | 888 | i.mu.Unlock() | ||
| 889 | if persistHandoff { | 889 | if persistHandoff { | ||
M packages/agent/modes/interactive_compaction.go +12 -6
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "encoding/json" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | ||
| 8 | + | "github.com/bnema/zut/packages/core" | |
| 8 | 9 | "github.com/bnema/zut/packages/provider" | |
| 9 | 10 | "github.com/bnema/zut/packages/tui" | |
| 10 | 11 | ) | |
| @@ -109,7 +110,8 @@ func (i *Interactive) startRestoredCompactHandoff(parent context.Context) { | |||
| 109 | 110 | return | |
| 110 | 111 | } | |
| 111 | 112 | ||
| 112 | - | var next string | |
| 113 | + | var next core.QueuedMessage | |
| 114 | + | var hasNext bool | |
| 113 | 115 | continueQueued := false | |
| 114 | 116 | var resume compactHandoffResume | |
| 115 | 117 | var handoff json.RawMessage | |
| @@ -117,6 +119,7 @@ func (i *Interactive) startRestoredCompactHandoff(parent context.Context) { | |||
| 117 | 119 | switch { | |
| 118 | 120 | case state.reason != compactContinuationForcedLength && len(i.queued) > 0: | |
| 119 | 121 | next, i.queued = i.queued[0], i.queued[1:] | |
| 122 | + | hasNext = true | |
| 120 | 123 | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 121 | 124 | case state.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0: | |
| 122 | 125 | continueQueued = true | |
| @@ -129,7 +132,7 @@ func (i *Interactive) startRestoredCompactHandoff(parent context.Context) { | |||
| 129 | 132 | } | |
| 130 | 133 | // Reserve the turn slot before persisting or dispatching so concurrent input | |
| 131 | 134 | // can only queue behind the restored handoff, never start a competing turn. | |
| 132 | - | starting := next != "" || continueQueued || resume == compactHandoffContinueExisting || resume == compactHandoffAppendPrompt | |
| 135 | + | starting := hasNext || continueQueued || resume == compactHandoffContinueExisting || resume == compactHandoffAppendPrompt | |
| 133 | 136 | i.busy = starting | |
| 134 | 137 | i.mu.Unlock() | |
| 135 | 138 | if persistHandoff { | |
| @@ -140,8 +143,8 @@ func (i *Interactive) startRestoredCompactHandoff(parent context.Context) { | |||
| 140 | 143 | return | |
| 141 | 144 | } | |
| 142 | 145 | switch { | |
| 143 | - | case next != "": | |
| 144 | - | i.startTurn(parent, next) | |
| 146 | + | case hasNext: | |
| 147 | + | i.startTurnWithImages(parent, next.Text, next.Images) | |
| 145 | 148 | case continueQueued: | |
| 146 | 149 | i.startTurnRequest(parent, "", nil, true, false) | |
| 147 | 150 | case resume == compactHandoffContinueExisting: | |
| @@ -173,7 +176,7 @@ func (i *Interactive) startAutoCompactContinuation(parent context.Context) { | |||
| 173 | 176 | if persistHandoff { | |
| 174 | 177 | i.persistCompactHandoff(handoff) | |
| 175 | 178 | } | |
| 176 | - | i.startTurn(parent, next) | |
| 179 | + | i.startTurnWithImages(parent, next.Text, next.Images) | |
| 177 | 180 | return | |
| 178 | 181 | } | |
| 179 | 182 | if i.compactContinuation.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0 { | |
| @@ -338,7 +341,10 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||
| 338 | 341 | handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason}) | |
| 339 | 342 | continueAutomatically = true | |
| 340 | 343 | case len(i.queued) > 0: | |
| 341 | - | next, i.queued = i.queued[0], i.queued[1:] | |
| 344 | + | queued := i.queued[0] | |
| 345 | + | i.queued = i.queued[1:] | |
| 346 | + | next = queued.Text | |
| 347 | + | nextImages = queued.Images | |
| 342 | 348 | hasNext = true | |
| 343 | 349 | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 344 | 350 | case continuationReason == compactContinuationStructuralTail || continuationReason == compactContinuationStatusRescue: | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | 7 | ||||
| 8 | + | "github.com/bnema/zut/packages/core" | |||
| 8 | "github.com/bnema/zut/packages/provider" | 9 | "github.com/bnema/zut/packages/provider" | ||
| 9 | "github.com/bnema/zut/packages/tui" | 10 | "github.com/bnema/zut/packages/tui" | ||
| 10 | ) | 11 | ) | ||
| @@ -109,7 +110,8 @@ func (i *Interactive) startRestoredCompactHandoff(parent context.Context) { | |||||
| 109 | return | 110 | return | ||
| 110 | } | 111 | } | ||
| 111 | 112 | ||||
| 112 | - | var next string | 113 | + | var next core.QueuedMessage |
| 114 | + | var hasNext bool | |||
| 113 | continueQueued := false | 115 | continueQueued := false | ||
| 114 | var resume compactHandoffResume | 116 | var resume compactHandoffResume | ||
| 115 | var handoff json.RawMessage | 117 | var handoff json.RawMessage | ||
| @@ -117,6 +119,7 @@ func (i *Interactive) startRestoredCompactHandoff(parent context.Context) { | |||||
| 117 | switch { | 119 | switch { | ||
| 118 | case state.reason != compactContinuationForcedLength && len(i.queued) > 0: | 120 | case state.reason != compactContinuationForcedLength && len(i.queued) > 0: | ||
| 119 | next, i.queued = i.queued[0], i.queued[1:] | 121 | next, i.queued = i.queued[0], i.queued[1:] | ||
| 122 | + | hasNext = true | |||
| 120 | handoff, persistHandoff = i.resetCompactContinuationLocked() | 123 | handoff, persistHandoff = i.resetCompactContinuationLocked() | ||
| 121 | case state.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0: | 124 | case state.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0: | ||
| 122 | continueQueued = true | 125 | continueQueued = true | ||
| @@ -129,7 +132,7 @@ func (i *Interactive) startRestoredCompactHandoff(parent context.Context) { | |||||
| 129 | } | 132 | } | ||
| 130 | // Reserve the turn slot before persisting or dispatching so concurrent input | 133 | // Reserve the turn slot before persisting or dispatching so concurrent input | ||
| 131 | // can only queue behind the restored handoff, never start a competing turn. | 134 | // can only queue behind the restored handoff, never start a competing turn. | ||
| 132 | - | starting := next != "" || continueQueued || resume == compactHandoffContinueExisting || resume == compactHandoffAppendPrompt | 135 | + | starting := hasNext || continueQueued || resume == compactHandoffContinueExisting || resume == compactHandoffAppendPrompt |
| 133 | i.busy = starting | 136 | i.busy = starting | ||
| 134 | i.mu.Unlock() | 137 | i.mu.Unlock() | ||
| 135 | if persistHandoff { | 138 | if persistHandoff { | ||
| @@ -140,8 +143,8 @@ func (i *Interactive) startRestoredCompactHandoff(parent context.Context) { | |||||
| 140 | return | 143 | return | ||
| 141 | } | 144 | } | ||
| 142 | switch { | 145 | switch { | ||
| 143 | - | case next != "": | 146 | + | case hasNext: |
| 144 | - | i.startTurn(parent, next) | 147 | + | i.startTurnWithImages(parent, next.Text, next.Images) |
| 145 | case continueQueued: | 148 | case continueQueued: | ||
| 146 | i.startTurnRequest(parent, "", nil, true, false) | 149 | i.startTurnRequest(parent, "", nil, true, false) | ||
| 147 | case resume == compactHandoffContinueExisting: | 150 | case resume == compactHandoffContinueExisting: | ||
| @@ -173,7 +176,7 @@ func (i *Interactive) startAutoCompactContinuation(parent context.Context) { | |||||
| 173 | if persistHandoff { | 176 | if persistHandoff { | ||
| 174 | i.persistCompactHandoff(handoff) | 177 | i.persistCompactHandoff(handoff) | ||
| 175 | } | 178 | } | ||
| 176 | - | i.startTurn(parent, next) | 179 | + | i.startTurnWithImages(parent, next.Text, next.Images) |
| 177 | return | 180 | return | ||
| 178 | } | 181 | } | ||
| 179 | if i.compactContinuation.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0 { | 182 | if i.compactContinuation.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0 { | ||
| @@ -338,7 +341,10 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||||
| 338 | handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason}) | 341 | handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason}) | ||
| 339 | continueAutomatically = true | 342 | continueAutomatically = true | ||
| 340 | case len(i.queued) > 0: | 343 | case len(i.queued) > 0: | ||
| 341 | - | next, i.queued = i.queued[0], i.queued[1:] | 344 | + | queued := i.queued[0] |
| 345 | + | i.queued = i.queued[1:] | |||
| 346 | + | next = queued.Text | |||
| 347 | + | nextImages = queued.Images | |||
| 342 | hasNext = true | 348 | hasNext = true | ||
| 343 | handoff, persistHandoff = i.resetCompactContinuationLocked() | 349 | handoff, persistHandoff = i.resetCompactContinuationLocked() | ||
| 344 | case continuationReason == compactContinuationStructuralTail || continuationReason == compactContinuationStatusRescue: | 350 | case continuationReason == compactContinuationStructuralTail || continuationReason == compactContinuationStatusRescue: | ||
M packages/agent/modes/interactive_host.go +4 -5
| @@ -256,7 +256,7 @@ func (i *Interactive) submitOrQueue(text string, images []provider.ImageBlock, u | |||
| 256 | 256 | // the manager is dormant behind a sealed worker wave, the coordinator makes | |
| 257 | 257 | // this the next manager turn and carries any completed worker summary with it. | |
| 258 | 258 | if userInput && i.coordinatorAcceptsUserInput() { | |
| 259 | - | actions := i.applyCoordinator(orchestration.Event{Kind: orchestration.EventUserInput, Text: text}) | |
| 259 | + | actions := i.applyCoordinator(orchestration.Event{Kind: orchestration.EventUserInput, Text: text, Images: images}) | |
| 260 | 260 | i.executeCoordinatorActions(actions) | |
| 261 | 261 | // The coordinator has accepted the input for its sealed worker wave. | |
| 262 | 262 | // It may intentionally defer an action until pending workers finish; | |
| @@ -266,8 +266,7 @@ func (i *Interactive) submitOrQueue(text string, images []provider.ImageBlock, u | |||
| 266 | 266 | i.maybeStartSessionTitle(i.runCtx, text) | |
| 267 | 267 | i.mu.Lock() | |
| 268 | 268 | if i.busy { | |
| 269 | - | // Queue text only; images are dropped for queued prompts. Keep the | |
| 270 | - | // interactive mutex held through Agent.QueueMessage so turn teardown | |
| 269 | + | // Keep the interactive mutex held through Agent.QueueMessage so turn teardown | |
| 271 | 270 | // cannot inspect the agent queue between the busy check and enqueue. | |
| 272 | 271 | // Compaction uses the host queue because it has no active agent loop | |
| 273 | 272 | // to drain Agent.QueueMessage entries. | |
| @@ -275,10 +274,10 @@ func (i *Interactive) submitOrQueue(text string, images []provider.ImageBlock, u | |||
| 275 | 274 | var persistHandoff bool | |
| 276 | 275 | if i.agent != nil && !i.compacting { | |
| 277 | 276 | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 278 | - | i.agent.QueueMessage(text) | |
| 277 | + | i.agent.QueueMessage(text, images) | |
| 279 | 278 | } else { | |
| 280 | 279 | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 281 | - | i.queued = append(i.queued, text) | |
| 280 | + | i.queued = append(i.queued, core.QueuedMessage{Text: text, Images: images}) | |
| 282 | 281 | } | |
| 283 | 282 | i.mu.Unlock() | |
| 284 | 283 | if persistHandoff { | |
| @@ -256,7 +256,7 @@ func (i *Interactive) submitOrQueue(text string, images []provider.ImageBlock, u | |||||
| 256 | // the manager is dormant behind a sealed worker wave, the coordinator makes | 256 | // the manager is dormant behind a sealed worker wave, the coordinator makes | ||
| 257 | // this the next manager turn and carries any completed worker summary with it. | 257 | // this the next manager turn and carries any completed worker summary with it. | ||
| 258 | if userInput && i.coordinatorAcceptsUserInput() { | 258 | if userInput && i.coordinatorAcceptsUserInput() { | ||
| 259 | - | actions := i.applyCoordinator(orchestration.Event{Kind: orchestration.EventUserInput, Text: text}) | 259 | + | actions := i.applyCoordinator(orchestration.Event{Kind: orchestration.EventUserInput, Text: text, Images: images}) |
| 260 | i.executeCoordinatorActions(actions) | 260 | i.executeCoordinatorActions(actions) | ||
| 261 | // The coordinator has accepted the input for its sealed worker wave. | 261 | // The coordinator has accepted the input for its sealed worker wave. | ||
| 262 | // It may intentionally defer an action until pending workers finish; | 262 | // It may intentionally defer an action until pending workers finish; | ||
| @@ -266,8 +266,7 @@ func (i *Interactive) submitOrQueue(text string, images []provider.ImageBlock, u | |||||
| 266 | i.maybeStartSessionTitle(i.runCtx, text) | 266 | i.maybeStartSessionTitle(i.runCtx, text) | ||
| 267 | i.mu.Lock() | 267 | i.mu.Lock() | ||
| 268 | if i.busy { | 268 | if i.busy { | ||
| 269 | - | // Queue text only; images are dropped for queued prompts. Keep the | 269 | + | // Keep the interactive mutex held through Agent.QueueMessage so turn teardown |
| 270 | - | // interactive mutex held through Agent.QueueMessage so turn teardown | |||
| 271 | // cannot inspect the agent queue between the busy check and enqueue. | 270 | // cannot inspect the agent queue between the busy check and enqueue. | ||
| 272 | // Compaction uses the host queue because it has no active agent loop | 271 | // Compaction uses the host queue because it has no active agent loop | ||
| 273 | // to drain Agent.QueueMessage entries. | 272 | // to drain Agent.QueueMessage entries. | ||
| @@ -275,10 +274,10 @@ func (i *Interactive) submitOrQueue(text string, images []provider.ImageBlock, u | |||||
| 275 | var persistHandoff bool | 274 | var persistHandoff bool | ||
| 276 | if i.agent != nil && !i.compacting { | 275 | if i.agent != nil && !i.compacting { | ||
| 277 | handoff, persistHandoff = i.resetCompactContinuationLocked() | 276 | handoff, persistHandoff = i.resetCompactContinuationLocked() | ||
| 278 | - | i.agent.QueueMessage(text) | 277 | + | i.agent.QueueMessage(text, images) |
| 279 | } else { | 278 | } else { | ||
| 280 | handoff, persistHandoff = i.resetCompactContinuationLocked() | 279 | handoff, persistHandoff = i.resetCompactContinuationLocked() | ||
| 281 | - | i.queued = append(i.queued, text) | 280 | + | i.queued = append(i.queued, core.QueuedMessage{Text: text, Images: images}) |
| 282 | } | 281 | } | ||
| 283 | i.mu.Unlock() | 282 | i.mu.Unlock() | ||
| 284 | if persistHandoff { | 283 | if persistHandoff { | ||
M packages/agent/modes/interactive_host_test.go +20 -1
| @@ -6,15 +6,31 @@ import ( | |||
| 6 | 6 | "github.com/bnema/zut/packages/agent/internal/orchestration" | |
| 7 | 7 | "github.com/bnema/zut/packages/agent/subagents" | |
| 8 | 8 | "github.com/bnema/zut/packages/core" | |
| 9 | + | "github.com/bnema/zut/packages/provider" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| 12 | + | func TestSubmitOrQueuePreservesImagesInHostQueue(t *testing.T) { | |
| 13 | + | i := &Interactive{agent: &core.Agent{}, busy: true, compacting: true} | |
| 14 | + | image := provider.ImageBlock{MimeType: "image/png", Data: []byte("png-1")} | |
| 15 | + | ||
| 16 | + | i.submitOrQueue("inspect", []provider.ImageBlock{image}, false) | |
| 17 | + | ||
| 18 | + | if len(i.queued) != 1 || i.queued[0].Text != "inspect" { | |
| 19 | + | t.Fatalf("queued messages = %#v, want one inspect prompt", i.queued) | |
| 20 | + | } | |
| 21 | + | if len(i.queued[0].Images) != 1 || string(i.queued[0].Images[0].Data) != "png-1" { | |
| 22 | + | t.Fatalf("queued images = %#v, want png-1", i.queued[0].Images) | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | ||
| 11 | 26 | func TestSubmitOrQueuePreservesSealedWorkerWaveWithPendingWorker(t *testing.T) { | |
| 12 | 27 | i := &Interactive{agent: &core.Agent{}} | |
| 13 | 28 | i.applyCoordinator(orchestration.Event{Kind: orchestration.EventManagerStarted}) | |
| 14 | 29 | i.applyCoordinator(orchestration.Event{Kind: orchestration.EventWorkerRegistered, WorkerID: "worker#1"}) | |
| 15 | 30 | i.applyCoordinator(orchestration.Event{Kind: orchestration.EventManagerFinished}) | |
| 16 | 31 | ||
| 17 | - | i.submitOrQueue("review the result", nil, true) | |
| 32 | + | image := provider.ImageBlock{MimeType: "image/png", Data: []byte("png-1")} | |
| 33 | + | i.submitOrQueue("review the result", []provider.ImageBlock{image}, true) | |
| 18 | 34 | ||
| 19 | 35 | actions := i.applyCoordinator(orchestration.Event{ | |
| 20 | 36 | Kind: orchestration.EventWorkerFinished, | |
| @@ -30,4 +46,7 @@ func TestSubmitOrQueuePreservesSealedWorkerWaveWithPendingWorker(t *testing.T) { | |||
| 30 | 46 | if got := actions[0].Text; got != "review the result" { | |
| 31 | 47 | t.Fatalf("completion action text = %q, want queued user input", got) | |
| 32 | 48 | } | |
| 49 | + | if len(actions[0].Images) != 1 || string(actions[0].Images[0].Data) != "png-1" { | |
| 50 | + | t.Fatalf("completion action images = %#v, want queued image", actions[0].Images) | |
| 51 | + | } | |
| 33 | 52 | } | |
| @@ -6,15 +6,31 @@ import ( | |||||
| 6 | "github.com/bnema/zut/packages/agent/internal/orchestration" | 6 | "github.com/bnema/zut/packages/agent/internal/orchestration" | ||
| 7 | "github.com/bnema/zut/packages/agent/subagents" | 7 | "github.com/bnema/zut/packages/agent/subagents" | ||
| 8 | "github.com/bnema/zut/packages/core" | 8 | "github.com/bnema/zut/packages/core" | ||
| 9 | + | "github.com/bnema/zut/packages/provider" | |||
| 9 | ) | 10 | ) | ||
| 10 | 11 | ||||
| 12 | + | func TestSubmitOrQueuePreservesImagesInHostQueue(t *testing.T) { | |||
| 13 | + | i := &Interactive{agent: &core.Agent{}, busy: true, compacting: true} | |||
| 14 | + | image := provider.ImageBlock{MimeType: "image/png", Data: []byte("png-1")} | |||
| 15 | + | ||||
| 16 | + | i.submitOrQueue("inspect", []provider.ImageBlock{image}, false) | |||
| 17 | + | ||||
| 18 | + | if len(i.queued) != 1 || i.queued[0].Text != "inspect" { | |||
| 19 | + | t.Fatalf("queued messages = %#v, want one inspect prompt", i.queued) | |||
| 20 | + | } | |||
| 21 | + | if len(i.queued[0].Images) != 1 || string(i.queued[0].Images[0].Data) != "png-1" { | |||
| 22 | + | t.Fatalf("queued images = %#v, want png-1", i.queued[0].Images) | |||
| 23 | + | } | |||
| 24 | + | } | |||
| 25 | + | ||||
| 11 | func TestSubmitOrQueuePreservesSealedWorkerWaveWithPendingWorker(t *testing.T) { | 26 | func TestSubmitOrQueuePreservesSealedWorkerWaveWithPendingWorker(t *testing.T) { | ||
| 12 | i := &Interactive{agent: &core.Agent{}} | 27 | i := &Interactive{agent: &core.Agent{}} | ||
| 13 | i.applyCoordinator(orchestration.Event{Kind: orchestration.EventManagerStarted}) | 28 | i.applyCoordinator(orchestration.Event{Kind: orchestration.EventManagerStarted}) | ||
| 14 | i.applyCoordinator(orchestration.Event{Kind: orchestration.EventWorkerRegistered, WorkerID: "worker#1"}) | 29 | i.applyCoordinator(orchestration.Event{Kind: orchestration.EventWorkerRegistered, WorkerID: "worker#1"}) | ||
| 15 | i.applyCoordinator(orchestration.Event{Kind: orchestration.EventManagerFinished}) | 30 | i.applyCoordinator(orchestration.Event{Kind: orchestration.EventManagerFinished}) | ||
| 16 | 31 | ||||
| 17 | - | i.submitOrQueue("review the result", nil, true) | 32 | + | image := provider.ImageBlock{MimeType: "image/png", Data: []byte("png-1")} |
| 33 | + | i.submitOrQueue("review the result", []provider.ImageBlock{image}, true) | |||
| 18 | 34 | ||||
| 19 | actions := i.applyCoordinator(orchestration.Event{ | 35 | actions := i.applyCoordinator(orchestration.Event{ | ||
| 20 | Kind: orchestration.EventWorkerFinished, | 36 | Kind: orchestration.EventWorkerFinished, | ||
| @@ -30,4 +46,7 @@ func TestSubmitOrQueuePreservesSealedWorkerWaveWithPendingWorker(t *testing.T) { | |||||
| 30 | if got := actions[0].Text; got != "review the result" { | 46 | if got := actions[0].Text; got != "review the result" { | ||
| 31 | t.Fatalf("completion action text = %q, want queued user input", got) | 47 | t.Fatalf("completion action text = %q, want queued user input", got) | ||
| 32 | } | 48 | } | ||
| 49 | + | if len(actions[0].Images) != 1 || string(actions[0].Images[0].Data) != "png-1" { | |||
| 50 | + | t.Fatalf("completion action images = %#v, want queued image", actions[0].Images) | |||
| 51 | + | } | |||
| 33 | } | 52 | } | ||
M packages/agent/modes/interactive_input_routing.go +16 -22
| @@ -578,27 +578,28 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 578 | 578 | i.mu.Lock() | |
| 579 | 579 | busyCancel := i.busy && i.cancelTurn != nil | |
| 580 | 580 | cancelTurn := i.cancelTurn | |
| 581 | - | var restoreQueued string | |
| 581 | + | var restoreQueued core.QueuedMessage | |
| 582 | + | var hasRestoreQueued bool | |
| 582 | 583 | var handoff json.RawMessage | |
| 583 | 584 | var persistHandoff bool | |
| 584 | 585 | if busyCancel { | |
| 585 | 586 | // Keep the most recently queued follow-up as an editable draft | |
| 586 | 587 | // instead of losing it with the cancelled turn's stale queue. | |
| 587 | 588 | if i.agent != nil { | |
| 588 | - | restoreQueued, _ = i.agent.PopQueuedMessage() | |
| 589 | + | restoreQueued, hasRestoreQueued = i.agent.PopQueuedMessage() | |
| 589 | 590 | } | |
| 590 | - | if restoreQueued == "" && len(i.queued) > 0 { | |
| 591 | + | if !hasRestoreQueued && len(i.queued) > 0 { | |
| 591 | 592 | n := len(i.queued) - 1 | |
| 592 | 593 | restoreQueued = i.queued[n] | |
| 593 | 594 | i.queued = i.queued[:n] | |
| 595 | + | hasRestoreQueued = true | |
| 594 | 596 | } | |
| 595 | 597 | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 596 | 598 | } | |
| 597 | 599 | i.mu.Unlock() | |
| 598 | 600 | if busyCancel { | |
| 599 | - | if restoreQueued != "" { | |
| 600 | - | i.ed.SetValue(restoreQueued) | |
| 601 | - | i.inputHistoryIndex = -1 | |
| 601 | + | if hasRestoreQueued { | |
| 602 | + | i.restoreQueuedMessageToEditor(restoreQueued) | |
| 602 | 603 | } | |
| 603 | 604 | i.updateActiveGoal(core.GoalPaused, "interrupted by user") | |
| 604 | 605 | cancelTurn() | |
| @@ -647,20 +648,20 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 647 | 648 | // the keypress falls through to the normal scroll behavior. | |
| 648 | 649 | if k.Alt { | |
| 649 | 650 | i.mu.Lock() | |
| 650 | - | var text string | |
| 651 | + | var message core.QueuedMessage | |
| 652 | + | var ok bool | |
| 651 | 653 | if i.agent != nil { | |
| 652 | - | text, _ = i.agent.PopQueuedMessage() | |
| 654 | + | message, ok = i.agent.PopQueuedMessage() | |
| 653 | 655 | } | |
| 654 | - | if text == "" { | |
| 656 | + | if !ok { | |
| 655 | 657 | if n := len(i.queued); n > 0 { | |
| 656 | - | text = i.queued[n-1] | |
| 658 | + | message = i.queued[n-1] | |
| 657 | 659 | i.queued = i.queued[:n-1] | |
| 660 | + | ok = true | |
| 658 | 661 | } | |
| 659 | 662 | } | |
| 660 | 663 | i.mu.Unlock() | |
| 661 | - | if text != "" { | |
| 662 | - | i.ed.SetValue(text) | |
| 663 | - | i.inputHistoryIndex = -1 | |
| 664 | + | if ok && i.restoreQueuedMessageToEditor(message) { | |
| 664 | 665 | i.invalidate() | |
| 665 | 666 | return false | |
| 666 | 667 | } | |
| @@ -914,25 +915,18 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 914 | 915 | ag := i.agent | |
| 915 | 916 | i.mu.Unlock() | |
| 916 | 917 | if busy { | |
| 917 | - | if len(images) > 0 { | |
| 918 | - | i.mu.Lock() | |
| 919 | - | i.statusErr = "can't queue clipboard images while a turn is running; wait for the current turn to finish" | |
| 920 | - | i.mu.Unlock() | |
| 921 | - | i.invalidate() | |
| 922 | - | return false | |
| 923 | - | } | |
| 924 | 918 | clearSubmittedInput() | |
| 925 | 919 | var handoff json.RawMessage | |
| 926 | 920 | var persistHandoff bool | |
| 927 | 921 | if ag != nil && !compacting { | |
| 928 | 922 | i.mu.Lock() | |
| 929 | 923 | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 930 | - | ag.QueueMessage(text) | |
| 924 | + | ag.QueueMessage(text, images) | |
| 931 | 925 | i.mu.Unlock() | |
| 932 | 926 | } else { | |
| 933 | 927 | i.mu.Lock() | |
| 934 | 928 | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 935 | - | i.queued = append(i.queued, text) | |
| 929 | + | i.queued = append(i.queued, core.QueuedMessage{Text: text, Images: images}) | |
| 936 | 930 | i.mu.Unlock() | |
| 937 | 931 | } | |
| 938 | 932 | if persistHandoff { | |
| @@ -578,27 +578,28 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 578 | i.mu.Lock() | 578 | i.mu.Lock() | ||
| 579 | busyCancel := i.busy && i.cancelTurn != nil | 579 | busyCancel := i.busy && i.cancelTurn != nil | ||
| 580 | cancelTurn := i.cancelTurn | 580 | cancelTurn := i.cancelTurn | ||
| 581 | - | var restoreQueued string | 581 | + | var restoreQueued core.QueuedMessage |
| 582 | + | var hasRestoreQueued bool | |||
| 582 | var handoff json.RawMessage | 583 | var handoff json.RawMessage | ||
| 583 | var persistHandoff bool | 584 | var persistHandoff bool | ||
| 584 | if busyCancel { | 585 | if busyCancel { | ||
| 585 | // Keep the most recently queued follow-up as an editable draft | 586 | // Keep the most recently queued follow-up as an editable draft | ||
| 586 | // instead of losing it with the cancelled turn's stale queue. | 587 | // instead of losing it with the cancelled turn's stale queue. | ||
| 587 | if i.agent != nil { | 588 | if i.agent != nil { | ||
| 588 | - | restoreQueued, _ = i.agent.PopQueuedMessage() | 589 | + | restoreQueued, hasRestoreQueued = i.agent.PopQueuedMessage() |
| 589 | } | 590 | } | ||
| 590 | - | if restoreQueued == "" && len(i.queued) > 0 { | 591 | + | if !hasRestoreQueued && len(i.queued) > 0 { |
| 591 | n := len(i.queued) - 1 | 592 | n := len(i.queued) - 1 | ||
| 592 | restoreQueued = i.queued[n] | 593 | restoreQueued = i.queued[n] | ||
| 593 | i.queued = i.queued[:n] | 594 | i.queued = i.queued[:n] | ||
| 595 | + | hasRestoreQueued = true | |||
| 594 | } | 596 | } | ||
| 595 | handoff, persistHandoff = i.resetCompactContinuationLocked() | 597 | handoff, persistHandoff = i.resetCompactContinuationLocked() | ||
| 596 | } | 598 | } | ||
| 597 | i.mu.Unlock() | 599 | i.mu.Unlock() | ||
| 598 | if busyCancel { | 600 | if busyCancel { | ||
| 599 | - | if restoreQueued != "" { | 601 | + | if hasRestoreQueued { |
| 600 | - | i.ed.SetValue(restoreQueued) | 602 | + | i.restoreQueuedMessageToEditor(restoreQueued) |
| 601 | - | i.inputHistoryIndex = -1 | |||
| 602 | } | 603 | } | ||
| 603 | i.updateActiveGoal(core.GoalPaused, "interrupted by user") | 604 | i.updateActiveGoal(core.GoalPaused, "interrupted by user") | ||
| 604 | cancelTurn() | 605 | cancelTurn() | ||
| @@ -647,20 +648,20 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 647 | // the keypress falls through to the normal scroll behavior. | 648 | // the keypress falls through to the normal scroll behavior. | ||
| 648 | if k.Alt { | 649 | if k.Alt { | ||
| 649 | i.mu.Lock() | 650 | i.mu.Lock() | ||
| 650 | - | var text string | 651 | + | var message core.QueuedMessage |
| 652 | + | var ok bool | |||
| 651 | if i.agent != nil { | 653 | if i.agent != nil { | ||
| 652 | - | text, _ = i.agent.PopQueuedMessage() | 654 | + | message, ok = i.agent.PopQueuedMessage() |
| 653 | } | 655 | } | ||
| 654 | - | if text == "" { | 656 | + | if !ok { |
| 655 | if n := len(i.queued); n > 0 { | 657 | if n := len(i.queued); n > 0 { | ||
| 656 | - | text = i.queued[n-1] | 658 | + | message = i.queued[n-1] |
| 657 | i.queued = i.queued[:n-1] | 659 | i.queued = i.queued[:n-1] | ||
| 660 | + | ok = true | |||
| 658 | } | 661 | } | ||
| 659 | } | 662 | } | ||
| 660 | i.mu.Unlock() | 663 | i.mu.Unlock() | ||
| 661 | - | if text != "" { | 664 | + | if ok && i.restoreQueuedMessageToEditor(message) { |
| 662 | - | i.ed.SetValue(text) | |||
| 663 | - | i.inputHistoryIndex = -1 | |||
| 664 | i.invalidate() | 665 | i.invalidate() | ||
| 665 | return false | 666 | return false | ||
| 666 | } | 667 | } | ||
| @@ -914,25 +915,18 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 914 | ag := i.agent | 915 | ag := i.agent | ||
| 915 | i.mu.Unlock() | 916 | i.mu.Unlock() | ||
| 916 | if busy { | 917 | if busy { | ||
| 917 | - | if len(images) > 0 { | |||
| 918 | - | i.mu.Lock() | |||
| 919 | - | i.statusErr = "can't queue clipboard images while a turn is running; wait for the current turn to finish" | |||
| 920 | - | i.mu.Unlock() | |||
| 921 | - | i.invalidate() | |||
| 922 | - | return false | |||
| 923 | - | } | |||
| 924 | clearSubmittedInput() | 918 | clearSubmittedInput() | ||
| 925 | var handoff json.RawMessage | 919 | var handoff json.RawMessage | ||
| 926 | var persistHandoff bool | 920 | var persistHandoff bool | ||
| 927 | if ag != nil && !compacting { | 921 | if ag != nil && !compacting { | ||
| 928 | i.mu.Lock() | 922 | i.mu.Lock() | ||
| 929 | handoff, persistHandoff = i.resetCompactContinuationLocked() | 923 | handoff, persistHandoff = i.resetCompactContinuationLocked() | ||
| 930 | - | ag.QueueMessage(text) | 924 | + | ag.QueueMessage(text, images) |
| 931 | i.mu.Unlock() | 925 | i.mu.Unlock() | ||
| 932 | } else { | 926 | } else { | ||
| 933 | i.mu.Lock() | 927 | i.mu.Lock() | ||
| 934 | handoff, persistHandoff = i.resetCompactContinuationLocked() | 928 | handoff, persistHandoff = i.resetCompactContinuationLocked() | ||
| 935 | - | i.queued = append(i.queued, text) | 929 | + | i.queued = append(i.queued, core.QueuedMessage{Text: text, Images: images}) |
| 936 | i.mu.Unlock() | 930 | i.mu.Unlock() | ||
| 937 | } | 931 | } | ||
| 938 | if persistHandoff { | 932 | if persistHandoff { | ||
M packages/agent/modes/interactive_queue_test.go +64 -4
| @@ -2,16 +2,76 @@ package modes | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "strings" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/zut/packages/core" | |
| 9 | + | "github.com/bnema/zut/packages/provider" | |
| 8 | 10 | "github.com/bnema/zut/packages/tui" | |
| 9 | 11 | ) | |
| 10 | 12 | ||
| 13 | + | func TestQueuedMessageSummaryKeepsImageIndicatorWhenTextIsTruncated(t *testing.T) { | |
| 14 | + | message := core.QueuedMessage{ | |
| 15 | + | Text: strings.Repeat("x", 100), | |
| 16 | + | Images: []provider.ImageBlock{{MimeType: "image/png", Data: []byte("png-1")}}, | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | summary := queuedMessageSummary(message, 24) | |
| 20 | + | ||
| 21 | + | if !strings.HasSuffix(summary, " [image]") { | |
| 22 | + | t.Fatalf("summary = %q, want visible image indicator", summary) | |
| 23 | + | } | |
| 24 | + | if len([]rune(summary)) > 24 { | |
| 25 | + | t.Fatalf("summary width = %d, want at most 24", len([]rune(summary))) | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestBusySubmitQueuesClipboardImagePrompt(t *testing.T) { | |
| 30 | + | agent := core.NewAgent(nil, "test-model", "", nil) | |
| 31 | + | i := NewInteractive(InteractiveConfig{Agent: agent}) | |
| 32 | + | i.mu.Lock() | |
| 33 | + | i.busy = true | |
| 34 | + | i.mu.Unlock() | |
| 35 | + | i.ed.SetValue("inspect [clipboard image #1]") | |
| 36 | + | i.clipboardImages = []clipboardImageAttachment{testClipboardImage("[clipboard image #1]", "png-1")} | |
| 37 | + | ||
| 38 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter}) | |
| 39 | + | ||
| 40 | + | queued := agent.PendingQueuedMessages() | |
| 41 | + | if len(queued) != 1 || queued[0].Text != "inspect" { | |
| 42 | + | t.Fatalf("queued messages = %#v, want one inspect prompt", queued) | |
| 43 | + | } | |
| 44 | + | if len(queued[0].Images) != 1 || string(queued[0].Images[0].Data) != "png-1" { | |
| 45 | + | t.Fatalf("queued images = %#v, want png-1", queued[0].Images) | |
| 46 | + | } | |
| 47 | + | if !i.ed.IsEmpty() || len(i.clipboardImages) != 0 { | |
| 48 | + | t.Fatal("submitted image prompt remained in the editor") | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestSlideBackRestoresQueuedImagesToEditor(t *testing.T) { | |
| 53 | + | agent := core.NewAgent(nil, "test-model", "", nil) | |
| 54 | + | image := testClipboardImage("unused", "png-1").Image | |
| 55 | + | agent.QueueMessage("inspect", []provider.ImageBlock{image}) | |
| 56 | + | i := NewInteractive(InteractiveConfig{Agent: agent}) | |
| 57 | + | i.mu.Lock() | |
| 58 | + | i.busy = true | |
| 59 | + | i.mu.Unlock() | |
| 60 | + | ||
| 61 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyUp, Alt: true}) | |
| 62 | + | ||
| 63 | + | if got := i.ed.Value(); got != "inspect [clipboard image #1]" { | |
| 64 | + | t.Fatalf("editor = %q, want restored image marker", got) | |
| 65 | + | } | |
| 66 | + | if len(i.clipboardImages) != 1 || string(i.clipboardImages[0].Image.Data) != "png-1" { | |
| 67 | + | t.Fatalf("clipboard images = %#v, want png-1", i.clipboardImages) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 11 | 71 | func TestEscapeRestoresMostRecentQueuedMessageToEditor(t *testing.T) { | |
| 12 | 72 | agent := core.NewAgent(nil, "test-model", "", nil) | |
| 13 | - | agent.QueueMessage("older follow-up") | |
| 14 | - | agent.QueueMessage("recover this draft") | |
| 73 | + | agent.QueueMessage("older follow-up", nil) | |
| 74 | + | agent.QueueMessage("recover this draft", nil) | |
| 15 | 75 | ||
| 16 | 76 | i := NewInteractive(InteractiveConfig{Agent: agent}) | |
| 17 | 77 | i.ed.SetValue("existing draft") | |
| @@ -30,7 +90,7 @@ func TestEscapeRestoresMostRecentQueuedMessageToEditor(t *testing.T) { | |||
| 30 | 90 | if got, want := i.ed.Value(), "recover this draft"; got != want { | |
| 31 | 91 | t.Errorf("editor = %q, want %q", got, want) | |
| 32 | 92 | } | |
| 33 | - | if got := agent.PendingQueuedMessages(); len(got) != 1 || got[0] != "older follow-up" { | |
| 93 | + | if got := agent.PendingQueuedMessages(); len(got) != 1 || got[0].Text != "older follow-up" { | |
| 34 | 94 | t.Errorf("remaining queued messages = %v, want [older follow-up]", got) | |
| 35 | 95 | } | |
| 36 | 96 | } | |
| @@ -41,7 +101,7 @@ func TestEscapeRestoresHostQueuedMessageToEditor(t *testing.T) { | |||
| 41 | 101 | i.mu.Lock() | |
| 42 | 102 | i.busy = true | |
| 43 | 103 | i.cancelTurn = func() { cancelled++ } | |
| 44 | - | i.queued = []string{"recover this draft"} | |
| 104 | + | i.queued = []core.QueuedMessage{{Text: "recover this draft"}} | |
| 45 | 105 | i.mu.Unlock() | |
| 46 | 106 | ||
| 47 | 107 | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}) | |
| @@ -2,16 +2,76 @@ package modes | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "strings" | |||
| 5 | "testing" | 6 | "testing" | ||
| 6 | 7 | ||||
| 7 | "github.com/bnema/zut/packages/core" | 8 | "github.com/bnema/zut/packages/core" | ||
| 9 | + | "github.com/bnema/zut/packages/provider" | |||
| 8 | "github.com/bnema/zut/packages/tui" | 10 | "github.com/bnema/zut/packages/tui" | ||
| 9 | ) | 11 | ) | ||
| 10 | 12 | ||||
| 13 | + | func TestQueuedMessageSummaryKeepsImageIndicatorWhenTextIsTruncated(t *testing.T) { | |||
| 14 | + | message := core.QueuedMessage{ | |||
| 15 | + | Text: strings.Repeat("x", 100), | |||
| 16 | + | Images: []provider.ImageBlock{{MimeType: "image/png", Data: []byte("png-1")}}, | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | summary := queuedMessageSummary(message, 24) | |||
| 20 | + | ||||
| 21 | + | if !strings.HasSuffix(summary, " [image]") { | |||
| 22 | + | t.Fatalf("summary = %q, want visible image indicator", summary) | |||
| 23 | + | } | |||
| 24 | + | if len([]rune(summary)) > 24 { | |||
| 25 | + | t.Fatalf("summary width = %d, want at most 24", len([]rune(summary))) | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func TestBusySubmitQueuesClipboardImagePrompt(t *testing.T) { | |||
| 30 | + | agent := core.NewAgent(nil, "test-model", "", nil) | |||
| 31 | + | i := NewInteractive(InteractiveConfig{Agent: agent}) | |||
| 32 | + | i.mu.Lock() | |||
| 33 | + | i.busy = true | |||
| 34 | + | i.mu.Unlock() | |||
| 35 | + | i.ed.SetValue("inspect [clipboard image #1]") | |||
| 36 | + | i.clipboardImages = []clipboardImageAttachment{testClipboardImage("[clipboard image #1]", "png-1")} | |||
| 37 | + | ||||
| 38 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter}) | |||
| 39 | + | ||||
| 40 | + | queued := agent.PendingQueuedMessages() | |||
| 41 | + | if len(queued) != 1 || queued[0].Text != "inspect" { | |||
| 42 | + | t.Fatalf("queued messages = %#v, want one inspect prompt", queued) | |||
| 43 | + | } | |||
| 44 | + | if len(queued[0].Images) != 1 || string(queued[0].Images[0].Data) != "png-1" { | |||
| 45 | + | t.Fatalf("queued images = %#v, want png-1", queued[0].Images) | |||
| 46 | + | } | |||
| 47 | + | if !i.ed.IsEmpty() || len(i.clipboardImages) != 0 { | |||
| 48 | + | t.Fatal("submitted image prompt remained in the editor") | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func TestSlideBackRestoresQueuedImagesToEditor(t *testing.T) { | |||
| 53 | + | agent := core.NewAgent(nil, "test-model", "", nil) | |||
| 54 | + | image := testClipboardImage("unused", "png-1").Image | |||
| 55 | + | agent.QueueMessage("inspect", []provider.ImageBlock{image}) | |||
| 56 | + | i := NewInteractive(InteractiveConfig{Agent: agent}) | |||
| 57 | + | i.mu.Lock() | |||
| 58 | + | i.busy = true | |||
| 59 | + | i.mu.Unlock() | |||
| 60 | + | ||||
| 61 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyUp, Alt: true}) | |||
| 62 | + | ||||
| 63 | + | if got := i.ed.Value(); got != "inspect [clipboard image #1]" { | |||
| 64 | + | t.Fatalf("editor = %q, want restored image marker", got) | |||
| 65 | + | } | |||
| 66 | + | if len(i.clipboardImages) != 1 || string(i.clipboardImages[0].Image.Data) != "png-1" { | |||
| 67 | + | t.Fatalf("clipboard images = %#v, want png-1", i.clipboardImages) | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | ||||
| 11 | func TestEscapeRestoresMostRecentQueuedMessageToEditor(t *testing.T) { | 71 | func TestEscapeRestoresMostRecentQueuedMessageToEditor(t *testing.T) { | ||
| 12 | agent := core.NewAgent(nil, "test-model", "", nil) | 72 | agent := core.NewAgent(nil, "test-model", "", nil) | ||
| 13 | - | agent.QueueMessage("older follow-up") | 73 | + | agent.QueueMessage("older follow-up", nil) |
| 14 | - | agent.QueueMessage("recover this draft") | 74 | + | agent.QueueMessage("recover this draft", nil) |
| 15 | 75 | ||||
| 16 | i := NewInteractive(InteractiveConfig{Agent: agent}) | 76 | i := NewInteractive(InteractiveConfig{Agent: agent}) | ||
| 17 | i.ed.SetValue("existing draft") | 77 | i.ed.SetValue("existing draft") | ||
| @@ -30,7 +90,7 @@ func TestEscapeRestoresMostRecentQueuedMessageToEditor(t *testing.T) { | |||||
| 30 | if got, want := i.ed.Value(), "recover this draft"; got != want { | 90 | if got, want := i.ed.Value(), "recover this draft"; got != want { | ||
| 31 | t.Errorf("editor = %q, want %q", got, want) | 91 | t.Errorf("editor = %q, want %q", got, want) | ||
| 32 | } | 92 | } | ||
| 33 | - | if got := agent.PendingQueuedMessages(); len(got) != 1 || got[0] != "older follow-up" { | 93 | + | if got := agent.PendingQueuedMessages(); len(got) != 1 || got[0].Text != "older follow-up" { |
| 34 | t.Errorf("remaining queued messages = %v, want [older follow-up]", got) | 94 | t.Errorf("remaining queued messages = %v, want [older follow-up]", got) | ||
| 35 | } | 95 | } | ||
| 36 | } | 96 | } | ||
| @@ -41,7 +101,7 @@ func TestEscapeRestoresHostQueuedMessageToEditor(t *testing.T) { | |||||
| 41 | i.mu.Lock() | 101 | i.mu.Lock() | ||
| 42 | i.busy = true | 102 | i.busy = true | ||
| 43 | i.cancelTurn = func() { cancelled++ } | 103 | i.cancelTurn = func() { cancelled++ } | ||
| 44 | - | i.queued = []string{"recover this draft"} | 104 | + | i.queued = []core.QueuedMessage{{Text: "recover this draft"}} |
| 45 | i.mu.Unlock() | 105 | i.mu.Unlock() | ||
| 46 | 106 | ||||
| 47 | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}) | 107 | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}) | ||
M packages/agent/modes/interactive_rendering.go +21 -2
| @@ -8,10 +8,29 @@ import ( | |||
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/zut/packages/agent/extproto" | |
| 10 | 10 | "github.com/bnema/zut/packages/agent/skills" | |
| 11 | + | "github.com/bnema/zut/packages/core" | |
| 11 | 12 | "github.com/bnema/zut/packages/provider" | |
| 12 | 13 | "github.com/bnema/zut/packages/tui" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| 16 | + | func queuedMessageSummary(message core.QueuedMessage, maxWidth int) string { | |
| 17 | + | if len(message.Images) == 0 { | |
| 18 | + | return truncateLine(message.Text, maxWidth) | |
| 19 | + | } | |
| 20 | + | imageLabel := "[image]" | |
| 21 | + | if len(message.Images) > 1 { | |
| 22 | + | imageLabel = fmt.Sprintf("[%d images]", len(message.Images)) | |
| 23 | + | } | |
| 24 | + | if message.Text == "" || maxWidth <= len(imageLabel) { | |
| 25 | + | return truncateLine(imageLabel, maxWidth) | |
| 26 | + | } | |
| 27 | + | text := truncateLine(message.Text, maxWidth-len(imageLabel)-1) | |
| 28 | + | if text == "" { | |
| 29 | + | return imageLabel | |
| 30 | + | } | |
| 31 | + | return text + " " + imageLabel | |
| 32 | + | } | |
| 33 | + | ||
| 15 | 34 | func (i *Interactive) cachedChatLocked(cols int) []string { | |
| 16 | 35 | // A busy frame contains mutable streaming/tool state. Keep the stable | |
| 17 | 36 | // transcript cache, but never return a full-frame cache entry that would | |
| @@ -749,7 +768,7 @@ func (i *Interactive) redraw() { | |||
| 749 | 768 | // in flight. Shown directly above the status bar so they're close | |
| 750 | 769 | // to the editor but don't push the chat around. | |
| 751 | 770 | var queue []string | |
| 752 | - | queued := append([]string(nil), i.queued...) | |
| 771 | + | queued := append([]core.QueuedMessage(nil), i.queued...) | |
| 753 | 772 | if i.agent != nil { | |
| 754 | 773 | queued = append(queued, i.agent.PendingQueuedMessages()...) | |
| 755 | 774 | } | |
| @@ -757,7 +776,7 @@ func (i *Interactive) redraw() { | |||
| 757 | 776 | queue = append(queue, "") | |
| 758 | 777 | for _, q := range queued { | |
| 759 | 778 | label := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, " sliding in: ") | |
| 760 | - | text := truncateLine(q, mainCols-17) | |
| 779 | + | text := queuedMessageSummary(q, mainCols-17) | |
| 761 | 780 | queue = append(queue, label+i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text)) | |
| 762 | 781 | } | |
| 763 | 782 | // Hint row, rendered in the same muted tone as the model | |
| @@ -8,10 +8,29 @@ import ( | |||||
| 8 | 8 | ||||
| 9 | "github.com/bnema/zut/packages/agent/extproto" | 9 | "github.com/bnema/zut/packages/agent/extproto" | ||
| 10 | "github.com/bnema/zut/packages/agent/skills" | 10 | "github.com/bnema/zut/packages/agent/skills" | ||
| 11 | + | "github.com/bnema/zut/packages/core" | |||
| 11 | "github.com/bnema/zut/packages/provider" | 12 | "github.com/bnema/zut/packages/provider" | ||
| 12 | "github.com/bnema/zut/packages/tui" | 13 | "github.com/bnema/zut/packages/tui" | ||
| 13 | ) | 14 | ) | ||
| 14 | 15 | ||||
| 16 | + | func queuedMessageSummary(message core.QueuedMessage, maxWidth int) string { | |||
| 17 | + | if len(message.Images) == 0 { | |||
| 18 | + | return truncateLine(message.Text, maxWidth) | |||
| 19 | + | } | |||
| 20 | + | imageLabel := "[image]" | |||
| 21 | + | if len(message.Images) > 1 { | |||
| 22 | + | imageLabel = fmt.Sprintf("[%d images]", len(message.Images)) | |||
| 23 | + | } | |||
| 24 | + | if message.Text == "" || maxWidth <= len(imageLabel) { | |||
| 25 | + | return truncateLine(imageLabel, maxWidth) | |||
| 26 | + | } | |||
| 27 | + | text := truncateLine(message.Text, maxWidth-len(imageLabel)-1) | |||
| 28 | + | if text == "" { | |||
| 29 | + | return imageLabel | |||
| 30 | + | } | |||
| 31 | + | return text + " " + imageLabel | |||
| 32 | + | } | |||
| 33 | + | ||||
| 15 | func (i *Interactive) cachedChatLocked(cols int) []string { | 34 | func (i *Interactive) cachedChatLocked(cols int) []string { | ||
| 16 | // A busy frame contains mutable streaming/tool state. Keep the stable | 35 | // A busy frame contains mutable streaming/tool state. Keep the stable | ||
| 17 | // transcript cache, but never return a full-frame cache entry that would | 36 | // transcript cache, but never return a full-frame cache entry that would | ||
| @@ -749,7 +768,7 @@ func (i *Interactive) redraw() { | |||||
| 749 | // in flight. Shown directly above the status bar so they're close | 768 | // in flight. Shown directly above the status bar so they're close | ||
| 750 | // to the editor but don't push the chat around. | 769 | // to the editor but don't push the chat around. | ||
| 751 | var queue []string | 770 | var queue []string | ||
| 752 | - | queued := append([]string(nil), i.queued...) | 771 | + | queued := append([]core.QueuedMessage(nil), i.queued...) |
| 753 | if i.agent != nil { | 772 | if i.agent != nil { | ||
| 754 | queued = append(queued, i.agent.PendingQueuedMessages()...) | 773 | queued = append(queued, i.agent.PendingQueuedMessages()...) | ||
| 755 | } | 774 | } | ||
| @@ -757,7 +776,7 @@ func (i *Interactive) redraw() { | |||||
| 757 | queue = append(queue, "") | 776 | queue = append(queue, "") | ||
| 758 | for _, q := range queued { | 777 | for _, q := range queued { | ||
| 759 | label := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, " sliding in: ") | 778 | label := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, " sliding in: ") | ||
| 760 | - | text := truncateLine(q, mainCols-17) | 779 | + | text := queuedMessageSummary(q, mainCols-17) |
| 761 | queue = append(queue, label+i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text)) | 780 | queue = append(queue, label+i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text)) | ||
| 762 | } | 781 | } | ||
| 763 | // Hint row, rendered in the same muted tone as the model | 782 | // Hint row, rendered in the same muted tone as the model | ||
M packages/agent/modes/interactive_resident_subagents.go +2 -2
| @@ -190,8 +190,8 @@ func (i *Interactive) executeCoordinatorActions(actions []orchestration.Action) | |||
| 190 | 190 | prompt = update | |
| 191 | 191 | } | |
| 192 | 192 | } | |
| 193 | - | if prompt != "" { | |
| 194 | - | i.submitOrQueue(prompt, nil, false) | |
| 193 | + | if prompt != "" || len(action.Images) != 0 { | |
| 194 | + | i.submitOrQueue(prompt, action.Images, false) | |
| 195 | 195 | } else if action.Reason == orchestration.WakeGoal { | |
| 196 | 196 | parent := i.runCtx | |
| 197 | 197 | if parent == nil { | |
| @@ -190,8 +190,8 @@ func (i *Interactive) executeCoordinatorActions(actions []orchestration.Action) | |||||
| 190 | prompt = update | 190 | prompt = update | ||
| 191 | } | 191 | } | ||
| 192 | } | 192 | } | ||
| 193 | - | if prompt != "" { | 193 | + | if prompt != "" || len(action.Images) != 0 { |
| 194 | - | i.submitOrQueue(prompt, nil, false) | 194 | + | i.submitOrQueue(prompt, action.Images, false) |
| 195 | } else if action.Reason == orchestration.WakeGoal { | 195 | } else if action.Reason == orchestration.WakeGoal { | ||
| 196 | parent := i.runCtx | 196 | parent := i.runCtx | ||
| 197 | if parent == nil { | 197 | if parent == nil { | ||
M packages/agent/modes/interactive_turn.go +2 -2
| @@ -331,7 +331,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 331 | 331 | // deciding whether this continuation may spend another rescue attempt. | |
| 332 | 332 | statusRescueActive := i.compactContinuation.reason == compactContinuationStatusRescue | |
| 333 | 333 | // Pop the next queued message, if any, and relaunch. | |
| 334 | - | var next string | |
| 334 | + | var next core.QueuedMessage | |
| 335 | 335 | var hasNext bool | |
| 336 | 336 | if !awaitingPre && len(i.queued) > 0 && ctx.Err() == nil && err == nil { | |
| 337 | 337 | next, i.queued = i.queued[0], i.queued[1:] | |
| @@ -409,7 +409,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 409 | 409 | } | |
| 410 | 410 | switch { | |
| 411 | 411 | case hasNext: | |
| 412 | - | i.startTurn(parent, next) | |
| 412 | + | i.startTurnWithImages(parent, next.Text, next.Images) | |
| 413 | 413 | case hasScheduled: | |
| 414 | 414 | // The scheduler holds the session transition read lock until this | |
| 415 | 415 | // acknowledgement, so accepting here binds the prompt to the agent | |
| @@ -331,7 +331,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 331 | // deciding whether this continuation may spend another rescue attempt. | 331 | // deciding whether this continuation may spend another rescue attempt. | ||
| 332 | statusRescueActive := i.compactContinuation.reason == compactContinuationStatusRescue | 332 | statusRescueActive := i.compactContinuation.reason == compactContinuationStatusRescue | ||
| 333 | // Pop the next queued message, if any, and relaunch. | 333 | // Pop the next queued message, if any, and relaunch. | ||
| 334 | - | var next string | 334 | + | var next core.QueuedMessage |
| 335 | var hasNext bool | 335 | var hasNext bool | ||
| 336 | if !awaitingPre && len(i.queued) > 0 && ctx.Err() == nil && err == nil { | 336 | if !awaitingPre && len(i.queued) > 0 && ctx.Err() == nil && err == nil { | ||
| 337 | next, i.queued = i.queued[0], i.queued[1:] | 337 | next, i.queued = i.queued[0], i.queued[1:] | ||
| @@ -409,7 +409,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 409 | } | 409 | } | ||
| 410 | switch { | 410 | switch { | ||
| 411 | case hasNext: | 411 | case hasNext: | ||
| 412 | - | i.startTurn(parent, next) | 412 | + | i.startTurnWithImages(parent, next.Text, next.Images) |
| 413 | case hasScheduled: | 413 | case hasScheduled: | ||
| 414 | // The scheduler holds the session transition read lock until this | 414 | // The scheduler holds the session transition read lock until this | ||
| 415 | // acknowledgement, so accepting here binds the prompt to the agent | 415 | // acknowledgement, so accepting here binds the prompt to the agent | ||
M packages/core/agent.go +46 -20
| @@ -24,8 +24,14 @@ const ( | |||
| 24 | 24 | DefaultStreamMaxRetries = 5 | |
| 25 | 25 | ) | |
| 26 | 26 | ||
| 27 | + | // QueuedMessage is a user prompt waiting for the next safe model-call boundary. | |
| 28 | + | type QueuedMessage struct { | |
| 29 | + | Text string | |
| 30 | + | Images []provider.ImageBlock | |
| 31 | + | } | |
| 32 | + | ||
| 27 | 33 | type queuedMessage struct { | |
| 28 | - | text string | |
| 34 | + | message QueuedMessage | |
| 29 | 35 | accepted time.Time | |
| 30 | 36 | } | |
| 31 | 37 | ||
| @@ -274,18 +280,18 @@ func (a *Agent) FastModeEnabled() bool { | |||
| 274 | 280 | return a.FastMode | |
| 275 | 281 | } | |
| 276 | 282 | ||
| 277 | - | // QueueMessage queues text to be injected as a user message at the | |
| 278 | - | // next safe boundary of the active agent loop. It is non-blocking in | |
| 279 | - | // the sense that it never waits for model/tool work; it only takes | |
| 280 | - | // the transcript mutex briefly. Empty/whitespace-only messages are | |
| 281 | - | // ignored. | |
| 282 | - | func (a *Agent) QueueMessage(text string) bool { | |
| 283 | + | // QueueMessage queues a text and image prompt to be injected as a user message | |
| 284 | + | // at the next safe boundary of the active agent loop. It is non-blocking in the | |
| 285 | + | // sense that it never waits for model/tool work; it only takes the transcript | |
| 286 | + | // mutex briefly. Prompts without text or images are ignored. | |
| 287 | + | func (a *Agent) QueueMessage(text string, images []provider.ImageBlock) bool { | |
| 283 | 288 | text = strings.TrimSpace(text) | |
| 284 | - | if text == "" { | |
| 289 | + | if text == "" && len(images) == 0 { | |
| 285 | 290 | return false | |
| 286 | 291 | } | |
| 292 | + | message := cloneQueuedMessage(QueuedMessage{Text: text, Images: images}) | |
| 287 | 293 | a.mu.Lock() | |
| 288 | - | a.queued = append(a.queued, queuedMessage{text: text, accepted: time.Now()}) | |
| 294 | + | a.queued = append(a.queued, queuedMessage{message: message, accepted: time.Now()}) | |
| 289 | 295 | a.mu.Unlock() | |
| 290 | 296 | return true | |
| 291 | 297 | } | |
| @@ -293,12 +299,12 @@ func (a *Agent) QueueMessage(text string) bool { | |||
| 293 | 299 | // PendingQueuedMessages returns a snapshot of user messages waiting | |
| 294 | 300 | // to be injected. Used by hosts to render the visible "sliding in" | |
| 295 | 301 | // chips without consuming them. | |
| 296 | - | func (a *Agent) PendingQueuedMessages() []string { | |
| 302 | + | func (a *Agent) PendingQueuedMessages() []QueuedMessage { | |
| 297 | 303 | a.mu.Lock() | |
| 298 | 304 | defer a.mu.Unlock() | |
| 299 | - | out := make([]string, len(a.queued)) | |
| 305 | + | out := make([]QueuedMessage, len(a.queued)) | |
| 300 | 306 | for i, queued := range a.queued { | |
| 301 | - | out[i] = queued.text | |
| 307 | + | out[i] = cloneQueuedMessage(queued.message) | |
| 302 | 308 | } | |
| 303 | 309 | return out | |
| 304 | 310 | } | |
| @@ -313,30 +319,43 @@ func (a *Agent) QueuedMessageCount() int { | |||
| 313 | 319 | ||
| 314 | 320 | // PopQueuedMessage removes and returns the most recently queued | |
| 315 | 321 | // message. Hosts use this for the slide-back keybinding. | |
| 316 | - | func (a *Agent) PopQueuedMessage() (string, bool) { | |
| 322 | + | func (a *Agent) PopQueuedMessage() (QueuedMessage, bool) { | |
| 317 | 323 | a.mu.Lock() | |
| 318 | 324 | defer a.mu.Unlock() | |
| 319 | 325 | n := len(a.queued) | |
| 320 | 326 | if n == 0 { | |
| 321 | - | return "", false | |
| 327 | + | return QueuedMessage{}, false | |
| 322 | 328 | } | |
| 323 | - | text := a.queued[n-1].text | |
| 329 | + | message := cloneQueuedMessage(a.queued[n-1].message) | |
| 324 | 330 | a.queued = a.queued[:n-1] | |
| 325 | - | return text, true | |
| 331 | + | return message, true | |
| 326 | 332 | } | |
| 327 | 333 | ||
| 328 | 334 | // DrainQueuedMessages discards and returns every queued message. | |
| 329 | 335 | // Hosts use this on explicit cancel/clear so stale follow-ups do | |
| 330 | 336 | // not run after the user aborted the turn. | |
| 331 | - | func (a *Agent) DrainQueuedMessages() []string { | |
| 337 | + | func (a *Agent) DrainQueuedMessages() []QueuedMessage { | |
| 332 | 338 | queued := a.drainQueuedMessages() | |
| 333 | - | out := make([]string, len(queued)) | |
| 339 | + | out := make([]QueuedMessage, len(queued)) | |
| 334 | 340 | for i, message := range queued { | |
| 335 | - | out[i] = message.text | |
| 341 | + | out[i] = cloneQueuedMessage(message.message) | |
| 336 | 342 | } | |
| 337 | 343 | return out | |
| 338 | 344 | } | |
| 339 | 345 | ||
| 346 | + | func cloneQueuedMessage(message QueuedMessage) QueuedMessage { | |
| 347 | + | clone := QueuedMessage{Text: message.Text} | |
| 348 | + | if len(message.Images) == 0 { | |
| 349 | + | return clone | |
| 350 | + | } | |
| 351 | + | clone.Images = make([]provider.ImageBlock, len(message.Images)) | |
| 352 | + | for i, image := range message.Images { | |
| 353 | + | clone.Images[i] = image | |
| 354 | + | clone.Images[i].Data = append([]byte(nil), image.Data...) | |
| 355 | + | } | |
| 356 | + | return clone | |
| 357 | + | } | |
| 358 | + | ||
| 340 | 359 | func (a *Agent) drainQueuedMessages() []queuedMessage { | |
| 341 | 360 | a.mu.Lock() | |
| 342 | 361 | defer a.mu.Unlock() | |
| @@ -351,9 +370,16 @@ func (a *Agent) appendQueuedAsUser(messages []queuedMessage, sink func(AgentEven | |||
| 351 | 370 | if accepted.IsZero() { | |
| 352 | 371 | accepted = time.Now() | |
| 353 | 372 | } | |
| 373 | + | content := make([]provider.Content, 0, 1+len(queued.message.Images)) | |
| 374 | + | if queued.message.Text != "" { | |
| 375 | + | content = append(content, provider.TextBlock{Text: queued.message.Text}) | |
| 376 | + | } | |
| 377 | + | for _, image := range queued.message.Images { | |
| 378 | + | content = append(content, image) | |
| 379 | + | } | |
| 354 | 380 | msg := provider.Message{ | |
| 355 | 381 | Role: provider.RoleUser, | |
| 356 | - | Content: []provider.Content{provider.TextBlock{Text: queued.text}}, | |
| 382 | + | Content: content, | |
| 357 | 383 | Time: accepted, | |
| 358 | 384 | } | |
| 359 | 385 | a.mu.Lock() | |
| @@ -24,8 +24,14 @@ const ( | |||||
| 24 | DefaultStreamMaxRetries = 5 | 24 | DefaultStreamMaxRetries = 5 | ||
| 25 | ) | 25 | ) | ||
| 26 | 26 | ||||
| 27 | + | // QueuedMessage is a user prompt waiting for the next safe model-call boundary. | |||
| 28 | + | type QueuedMessage struct { | |||
| 29 | + | Text string | |||
| 30 | + | Images []provider.ImageBlock | |||
| 31 | + | } | |||
| 32 | + | ||||
| 27 | type queuedMessage struct { | 33 | type queuedMessage struct { | ||
| 28 | - | text string | 34 | + | message QueuedMessage |
| 29 | accepted time.Time | 35 | accepted time.Time | ||
| 30 | } | 36 | } | ||
| 31 | 37 | ||||
| @@ -274,18 +280,18 @@ func (a *Agent) FastModeEnabled() bool { | |||||
| 274 | return a.FastMode | 280 | return a.FastMode | ||
| 275 | } | 281 | } | ||
| 276 | 282 | ||||
| 277 | - | // QueueMessage queues text to be injected as a user message at the | 283 | + | // QueueMessage queues a text and image prompt to be injected as a user message |
| 278 | - | // next safe boundary of the active agent loop. It is non-blocking in | 284 | + | // at the next safe boundary of the active agent loop. It is non-blocking in the |
| 279 | - | // the sense that it never waits for model/tool work; it only takes | 285 | + | // sense that it never waits for model/tool work; it only takes the transcript |
| 280 | - | // the transcript mutex briefly. Empty/whitespace-only messages are | 286 | + | // mutex briefly. Prompts without text or images are ignored. |
| 281 | - | // ignored. | 287 | + | func (a *Agent) QueueMessage(text string, images []provider.ImageBlock) bool { |
| 282 | - | func (a *Agent) QueueMessage(text string) bool { | |||
| 283 | text = strings.TrimSpace(text) | 288 | text = strings.TrimSpace(text) | ||
| 284 | - | if text == "" { | 289 | + | if text == "" && len(images) == 0 { |
| 285 | return false | 290 | return false | ||
| 286 | } | 291 | } | ||
| 292 | + | message := cloneQueuedMessage(QueuedMessage{Text: text, Images: images}) | |||
| 287 | a.mu.Lock() | 293 | a.mu.Lock() | ||
| 288 | - | a.queued = append(a.queued, queuedMessage{text: text, accepted: time.Now()}) | 294 | + | a.queued = append(a.queued, queuedMessage{message: message, accepted: time.Now()}) |
| 289 | a.mu.Unlock() | 295 | a.mu.Unlock() | ||
| 290 | return true | 296 | return true | ||
| 291 | } | 297 | } | ||
| @@ -293,12 +299,12 @@ func (a *Agent) QueueMessage(text string) bool { | |||||
| 293 | // PendingQueuedMessages returns a snapshot of user messages waiting | 299 | // PendingQueuedMessages returns a snapshot of user messages waiting | ||
| 294 | // to be injected. Used by hosts to render the visible "sliding in" | 300 | // to be injected. Used by hosts to render the visible "sliding in" | ||
| 295 | // chips without consuming them. | 301 | // chips without consuming them. | ||
| 296 | - | func (a *Agent) PendingQueuedMessages() []string { | 302 | + | func (a *Agent) PendingQueuedMessages() []QueuedMessage { |
| 297 | a.mu.Lock() | 303 | a.mu.Lock() | ||
| 298 | defer a.mu.Unlock() | 304 | defer a.mu.Unlock() | ||
| 299 | - | out := make([]string, len(a.queued)) | 305 | + | out := make([]QueuedMessage, len(a.queued)) |
| 300 | for i, queued := range a.queued { | 306 | for i, queued := range a.queued { | ||
| 301 | - | out[i] = queued.text | 307 | + | out[i] = cloneQueuedMessage(queued.message) |
| 302 | } | 308 | } | ||
| 303 | return out | 309 | return out | ||
| 304 | } | 310 | } | ||
| @@ -313,30 +319,43 @@ func (a *Agent) QueuedMessageCount() int { | |||||
| 313 | 319 | ||||
| 314 | // PopQueuedMessage removes and returns the most recently queued | 320 | // PopQueuedMessage removes and returns the most recently queued | ||
| 315 | // message. Hosts use this for the slide-back keybinding. | 321 | // message. Hosts use this for the slide-back keybinding. | ||
| 316 | - | func (a *Agent) PopQueuedMessage() (string, bool) { | 322 | + | func (a *Agent) PopQueuedMessage() (QueuedMessage, bool) { |
| 317 | a.mu.Lock() | 323 | a.mu.Lock() | ||
| 318 | defer a.mu.Unlock() | 324 | defer a.mu.Unlock() | ||
| 319 | n := len(a.queued) | 325 | n := len(a.queued) | ||
| 320 | if n == 0 { | 326 | if n == 0 { | ||
| 321 | - | return "", false | 327 | + | return QueuedMessage{}, false |
| 322 | } | 328 | } | ||
| 323 | - | text := a.queued[n-1].text | 329 | + | message := cloneQueuedMessage(a.queued[n-1].message) |
| 324 | a.queued = a.queued[:n-1] | 330 | a.queued = a.queued[:n-1] | ||
| 325 | - | return text, true | 331 | + | return message, true |
| 326 | } | 332 | } | ||
| 327 | 333 | ||||
| 328 | // DrainQueuedMessages discards and returns every queued message. | 334 | // DrainQueuedMessages discards and returns every queued message. | ||
| 329 | // Hosts use this on explicit cancel/clear so stale follow-ups do | 335 | // Hosts use this on explicit cancel/clear so stale follow-ups do | ||
| 330 | // not run after the user aborted the turn. | 336 | // not run after the user aborted the turn. | ||
| 331 | - | func (a *Agent) DrainQueuedMessages() []string { | 337 | + | func (a *Agent) DrainQueuedMessages() []QueuedMessage { |
| 332 | queued := a.drainQueuedMessages() | 338 | queued := a.drainQueuedMessages() | ||
| 333 | - | out := make([]string, len(queued)) | 339 | + | out := make([]QueuedMessage, len(queued)) |
| 334 | for i, message := range queued { | 340 | for i, message := range queued { | ||
| 335 | - | out[i] = message.text | 341 | + | out[i] = cloneQueuedMessage(message.message) |
| 336 | } | 342 | } | ||
| 337 | return out | 343 | return out | ||
| 338 | } | 344 | } | ||
| 339 | 345 | ||||
| 346 | + | func cloneQueuedMessage(message QueuedMessage) QueuedMessage { | |||
| 347 | + | clone := QueuedMessage{Text: message.Text} | |||
| 348 | + | if len(message.Images) == 0 { | |||
| 349 | + | return clone | |||
| 350 | + | } | |||
| 351 | + | clone.Images = make([]provider.ImageBlock, len(message.Images)) | |||
| 352 | + | for i, image := range message.Images { | |||
| 353 | + | clone.Images[i] = image | |||
| 354 | + | clone.Images[i].Data = append([]byte(nil), image.Data...) | |||
| 355 | + | } | |||
| 356 | + | return clone | |||
| 357 | + | } | |||
| 358 | + | ||||
| 340 | func (a *Agent) drainQueuedMessages() []queuedMessage { | 359 | func (a *Agent) drainQueuedMessages() []queuedMessage { | ||
| 341 | a.mu.Lock() | 360 | a.mu.Lock() | ||
| 342 | defer a.mu.Unlock() | 361 | defer a.mu.Unlock() | ||
| @@ -351,9 +370,16 @@ func (a *Agent) appendQueuedAsUser(messages []queuedMessage, sink func(AgentEven | |||||
| 351 | if accepted.IsZero() { | 370 | if accepted.IsZero() { | ||
| 352 | accepted = time.Now() | 371 | accepted = time.Now() | ||
| 353 | } | 372 | } | ||
| 373 | + | content := make([]provider.Content, 0, 1+len(queued.message.Images)) | |||
| 374 | + | if queued.message.Text != "" { | |||
| 375 | + | content = append(content, provider.TextBlock{Text: queued.message.Text}) | |||
| 376 | + | } | |||
| 377 | + | for _, image := range queued.message.Images { | |||
| 378 | + | content = append(content, image) | |||
| 379 | + | } | |||
| 354 | msg := provider.Message{ | 380 | msg := provider.Message{ | ||
| 355 | Role: provider.RoleUser, | 381 | Role: provider.RoleUser, | ||
| 356 | - | Content: []provider.Content{provider.TextBlock{Text: queued.text}}, | 382 | + | Content: content, |
| 357 | Time: accepted, | 383 | Time: accepted, | ||
| 358 | } | 384 | } | ||
| 359 | a.mu.Lock() | 385 | a.mu.Lock() | ||
M packages/core/queue_test.go +45 -9
| @@ -100,7 +100,7 @@ func TestQueuedMessageInjectedAfterToolBatchBeforeNextModelCall(t *testing.T) { | |||
| 100 | 100 | }() | |
| 101 | 101 | ||
| 102 | 102 | <-tool.started | |
| 103 | - | if !a.QueueMessage("also do Y") { | |
| 103 | + | if !a.QueueMessage("also do Y", nil) { | |
| 104 | 104 | t.Fatal("QueueMessage returned false") | |
| 105 | 105 | } | |
| 106 | 106 | close(tool.release) | |
| @@ -131,10 +131,46 @@ func queueTestContains(xs []string, want string) bool { | |||
| 131 | 131 | return false | |
| 132 | 132 | } | |
| 133 | 133 | ||
| 134 | + | func TestQueuedImageMessageInjectedAtNextSafeBoundary(t *testing.T) { | |
| 135 | + | client := &queueFakeClient{} | |
| 136 | + | tool := &blockingTool{started: make(chan struct{}), release: make(chan struct{})} | |
| 137 | + | a := NewAgent(client, "fake-model", "system", Registry{"echo": tool}) | |
| 138 | + | ||
| 139 | + | var queued provider.Message | |
| 140 | + | done := make(chan error, 1) | |
| 141 | + | go func() { | |
| 142 | + | done <- a.Prompt(context.Background(), "inspect later", nil, func(ev AgentEvent) { | |
| 143 | + | if user, ok := ev.(EvUserMessage); ok && len(user.Message.Content) > 0 { | |
| 144 | + | if _, isImage := user.Message.Content[0].(provider.ImageBlock); isImage { | |
| 145 | + | queued = user.Message | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | }) | |
| 149 | + | }() | |
| 150 | + | ||
| 151 | + | <-tool.started | |
| 152 | + | image := provider.ImageBlock{MimeType: "image/png", Data: []byte("png-bytes")} | |
| 153 | + | if !a.QueueMessage("", []provider.ImageBlock{image}) { | |
| 154 | + | t.Fatal("QueueMessage returned false for image-only prompt") | |
| 155 | + | } | |
| 156 | + | close(tool.release) | |
| 157 | + | ||
| 158 | + | if err := <-done; err != nil { | |
| 159 | + | t.Fatalf("Prompt returned %v", err) | |
| 160 | + | } | |
| 161 | + | if len(queued.Content) != 1 { | |
| 162 | + | t.Fatalf("queued content = %#v, want one image", queued.Content) | |
| 163 | + | } | |
| 164 | + | got, ok := queued.Content[0].(provider.ImageBlock) | |
| 165 | + | if !ok || got.MimeType != image.MimeType || string(got.Data) != string(image.Data) { | |
| 166 | + | t.Fatalf("queued image = %#v, want %#v", queued.Content[0], image) | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 134 | 170 | func TestQueuedMessageKeepsAcceptedTimeAcrossDrain(t *testing.T) { | |
| 135 | 171 | a := NewAgent(nil, "fake", "", Registry{}) | |
| 136 | 172 | before := time.Now() | |
| 137 | - | if !a.QueueMessage("queued") { | |
| 173 | + | if !a.QueueMessage("queued", nil) { | |
| 138 | 174 | t.Fatal("QueueMessage returned false") | |
| 139 | 175 | } | |
| 140 | 176 | after := time.Now() | |
| @@ -150,18 +186,18 @@ func TestQueuedMessageKeepsAcceptedTimeAcrossDrain(t *testing.T) { | |||
| 150 | 186 | ||
| 151 | 187 | func TestQueueMessageSnapshotPopAndDrain(t *testing.T) { | |
| 152 | 188 | a := NewAgent(nil, "fake", "", Registry{}) | |
| 153 | - | if a.QueueMessage(" ") { | |
| 189 | + | if a.QueueMessage(" ", nil) { | |
| 154 | 190 | t.Fatal("blank queue message accepted") | |
| 155 | 191 | } | |
| 156 | - | a.QueueMessage("one") | |
| 157 | - | a.QueueMessage("two") | |
| 158 | - | if got := a.PendingQueuedMessages(); len(got) != 2 || got[0] != "one" || got[1] != "two" { | |
| 192 | + | a.QueueMessage("one", nil) | |
| 193 | + | a.QueueMessage("two", nil) | |
| 194 | + | if got := a.PendingQueuedMessages(); len(got) != 2 || got[0].Text != "one" || got[1].Text != "two" { | |
| 159 | 195 | t.Fatalf("PendingQueuedMessages = %v; want [one two]", got) | |
| 160 | 196 | } | |
| 161 | - | if text, ok := a.PopQueuedMessage(); !ok || text != "two" { | |
| 162 | - | t.Fatalf("PopQueuedMessage = %q,%v; want two,true", text, ok) | |
| 197 | + | if message, ok := a.PopQueuedMessage(); !ok || message.Text != "two" { | |
| 198 | + | t.Fatalf("PopQueuedMessage = %#v,%v; want two,true", message, ok) | |
| 163 | 199 | } | |
| 164 | - | if got := a.DrainQueuedMessages(); len(got) != 1 || got[0] != "one" { | |
| 200 | + | if got := a.DrainQueuedMessages(); len(got) != 1 || got[0].Text != "one" { | |
| 165 | 201 | t.Fatalf("DrainQueuedMessages = %v; want [one]", got) | |
| 166 | 202 | } | |
| 167 | 203 | if got := a.QueuedMessageCount(); got != 0 { | |
| @@ -100,7 +100,7 @@ func TestQueuedMessageInjectedAfterToolBatchBeforeNextModelCall(t *testing.T) { | |||||
| 100 | }() | 100 | }() | ||
| 101 | 101 | ||||
| 102 | <-tool.started | 102 | <-tool.started | ||
| 103 | - | if !a.QueueMessage("also do Y") { | 103 | + | if !a.QueueMessage("also do Y", nil) { |
| 104 | t.Fatal("QueueMessage returned false") | 104 | t.Fatal("QueueMessage returned false") | ||
| 105 | } | 105 | } | ||
| 106 | close(tool.release) | 106 | close(tool.release) | ||
| @@ -131,10 +131,46 @@ func queueTestContains(xs []string, want string) bool { | |||||
| 131 | return false | 131 | return false | ||
| 132 | } | 132 | } | ||
| 133 | 133 | ||||
| 134 | + | func TestQueuedImageMessageInjectedAtNextSafeBoundary(t *testing.T) { | |||
| 135 | + | client := &queueFakeClient{} | |||
| 136 | + | tool := &blockingTool{started: make(chan struct{}), release: make(chan struct{})} | |||
| 137 | + | a := NewAgent(client, "fake-model", "system", Registry{"echo": tool}) | |||
| 138 | + | ||||
| 139 | + | var queued provider.Message | |||
| 140 | + | done := make(chan error, 1) | |||
| 141 | + | go func() { | |||
| 142 | + | done <- a.Prompt(context.Background(), "inspect later", nil, func(ev AgentEvent) { | |||
| 143 | + | if user, ok := ev.(EvUserMessage); ok && len(user.Message.Content) > 0 { | |||
| 144 | + | if _, isImage := user.Message.Content[0].(provider.ImageBlock); isImage { | |||
| 145 | + | queued = user.Message | |||
| 146 | + | } | |||
| 147 | + | } | |||
| 148 | + | }) | |||
| 149 | + | }() | |||
| 150 | + | ||||
| 151 | + | <-tool.started | |||
| 152 | + | image := provider.ImageBlock{MimeType: "image/png", Data: []byte("png-bytes")} | |||
| 153 | + | if !a.QueueMessage("", []provider.ImageBlock{image}) { | |||
| 154 | + | t.Fatal("QueueMessage returned false for image-only prompt") | |||
| 155 | + | } | |||
| 156 | + | close(tool.release) | |||
| 157 | + | ||||
| 158 | + | if err := <-done; err != nil { | |||
| 159 | + | t.Fatalf("Prompt returned %v", err) | |||
| 160 | + | } | |||
| 161 | + | if len(queued.Content) != 1 { | |||
| 162 | + | t.Fatalf("queued content = %#v, want one image", queued.Content) | |||
| 163 | + | } | |||
| 164 | + | got, ok := queued.Content[0].(provider.ImageBlock) | |||
| 165 | + | if !ok || got.MimeType != image.MimeType || string(got.Data) != string(image.Data) { | |||
| 166 | + | t.Fatalf("queued image = %#v, want %#v", queued.Content[0], image) | |||
| 167 | + | } | |||
| 168 | + | } | |||
| 169 | + | ||||
| 134 | func TestQueuedMessageKeepsAcceptedTimeAcrossDrain(t *testing.T) { | 170 | func TestQueuedMessageKeepsAcceptedTimeAcrossDrain(t *testing.T) { | ||
| 135 | a := NewAgent(nil, "fake", "", Registry{}) | 171 | a := NewAgent(nil, "fake", "", Registry{}) | ||
| 136 | before := time.Now() | 172 | before := time.Now() | ||
| 137 | - | if !a.QueueMessage("queued") { | 173 | + | if !a.QueueMessage("queued", nil) { |
| 138 | t.Fatal("QueueMessage returned false") | 174 | t.Fatal("QueueMessage returned false") | ||
| 139 | } | 175 | } | ||
| 140 | after := time.Now() | 176 | after := time.Now() | ||
| @@ -150,18 +186,18 @@ func TestQueuedMessageKeepsAcceptedTimeAcrossDrain(t *testing.T) { | |||||
| 150 | 186 | ||||
| 151 | func TestQueueMessageSnapshotPopAndDrain(t *testing.T) { | 187 | func TestQueueMessageSnapshotPopAndDrain(t *testing.T) { | ||
| 152 | a := NewAgent(nil, "fake", "", Registry{}) | 188 | a := NewAgent(nil, "fake", "", Registry{}) | ||
| 153 | - | if a.QueueMessage(" ") { | 189 | + | if a.QueueMessage(" ", nil) { |
| 154 | t.Fatal("blank queue message accepted") | 190 | t.Fatal("blank queue message accepted") | ||
| 155 | } | 191 | } | ||
| 156 | - | a.QueueMessage("one") | 192 | + | a.QueueMessage("one", nil) |
| 157 | - | a.QueueMessage("two") | 193 | + | a.QueueMessage("two", nil) |
| 158 | - | if got := a.PendingQueuedMessages(); len(got) != 2 || got[0] != "one" || got[1] != "two" { | 194 | + | if got := a.PendingQueuedMessages(); len(got) != 2 || got[0].Text != "one" || got[1].Text != "two" { |
| 159 | t.Fatalf("PendingQueuedMessages = %v; want [one two]", got) | 195 | t.Fatalf("PendingQueuedMessages = %v; want [one two]", got) | ||
| 160 | } | 196 | } | ||
| 161 | - | if text, ok := a.PopQueuedMessage(); !ok || text != "two" { | 197 | + | if message, ok := a.PopQueuedMessage(); !ok || message.Text != "two" { |
| 162 | - | t.Fatalf("PopQueuedMessage = %q,%v; want two,true", text, ok) | 198 | + | t.Fatalf("PopQueuedMessage = %#v,%v; want two,true", message, ok) |
| 163 | } | 199 | } | ||
| 164 | - | if got := a.DrainQueuedMessages(); len(got) != 1 || got[0] != "one" { | 200 | + | if got := a.DrainQueuedMessages(); len(got) != 1 || got[0].Text != "one" { |
| 165 | t.Fatalf("DrainQueuedMessages = %v; want [one]", got) | 201 | t.Fatalf("DrainQueuedMessages = %v; want [one]", got) | ||
| 166 | } | 202 | } | ||
| 167 | if got := a.QueuedMessageCount(); got != 0 { | 203 | if got := a.QueuedMessageCount(); got != 0 { | ||