fix(modes): preserve queued image context
- commit
- 125f053bce8610ff724e5d6e9914831872de4f6e
- author
- brice · · 2026-08-18 15:41 UTC
- parent
- 4b33cf8
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +103 -21
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 +6 -1
| @@ -37,7 +37,12 @@ func (i *Interactive) restoreQueuedMessageToEditor(message core.QueuedMessage) b | |||
| 37 | 37 | text := message.Text | |
| 38 | 38 | attachments := make([]clipboardImageAttachment, 0, len(message.Images)) | |
| 39 | 39 | for index, image := range message.Images { | |
| 40 | - | marker := fmt.Sprintf("[clipboard image #%d]", index+1) | |
| 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 | + | } | |
| 41 | 46 | if text != "" { | |
| 42 | 47 | text += " " | |
| 43 | 48 | } | |
| @@ -37,7 +37,12 @@ func (i *Interactive) restoreQueuedMessageToEditor(message core.QueuedMessage) b | |||||
| 37 | text := message.Text | 37 | text := message.Text | ||
| 38 | attachments := make([]clipboardImageAttachment, 0, len(message.Images)) | 38 | attachments := make([]clipboardImageAttachment, 0, len(message.Images)) | ||
| 39 | for index, image := range message.Images { | 39 | for index, image := range message.Images { | ||
| 40 | - | marker := fmt.Sprintf("[clipboard image #%d]", index+1) | 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 | + | } | |||
| 41 | if text != "" { | 46 | if text != "" { | ||
| 42 | text += " " | 47 | text += " " | ||
| 43 | } | 48 | } | ||
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/interactive_host.go +1 -1
| @@ -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; | |
| @@ -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; | ||
M packages/agent/modes/interactive_host_test.go +5 -1
| @@ -29,7 +29,8 @@ func TestSubmitOrQueuePreservesSealedWorkerWaveWithPendingWorker(t *testing.T) { | |||
| 29 | 29 | i.applyCoordinator(orchestration.Event{Kind: orchestration.EventWorkerRegistered, WorkerID: "worker#1"}) | |
| 30 | 30 | i.applyCoordinator(orchestration.Event{Kind: orchestration.EventManagerFinished}) | |
| 31 | 31 | ||
| 32 | - | 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) | |
| 33 | 34 | ||
| 34 | 35 | actions := i.applyCoordinator(orchestration.Event{ | |
| 35 | 36 | Kind: orchestration.EventWorkerFinished, | |
| @@ -45,4 +46,7 @@ func TestSubmitOrQueuePreservesSealedWorkerWaveWithPendingWorker(t *testing.T) { | |||
| 45 | 46 | if got := actions[0].Text; got != "review the result" { | |
| 46 | 47 | t.Fatalf("completion action text = %q, want queued user input", got) | |
| 47 | 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 | + | } | |
| 48 | 52 | } | |
| @@ -29,7 +29,8 @@ func TestSubmitOrQueuePreservesSealedWorkerWaveWithPendingWorker(t *testing.T) { | |||||
| 29 | i.applyCoordinator(orchestration.Event{Kind: orchestration.EventWorkerRegistered, WorkerID: "worker#1"}) | 29 | i.applyCoordinator(orchestration.Event{Kind: orchestration.EventWorkerRegistered, WorkerID: "worker#1"}) | ||
| 30 | i.applyCoordinator(orchestration.Event{Kind: orchestration.EventManagerFinished}) | 30 | i.applyCoordinator(orchestration.Event{Kind: orchestration.EventManagerFinished}) | ||
| 31 | 31 | ||||
| 32 | - | 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) | |||
| 33 | 34 | ||||
| 34 | actions := i.applyCoordinator(orchestration.Event{ | 35 | actions := i.applyCoordinator(orchestration.Event{ | ||
| 35 | Kind: orchestration.EventWorkerFinished, | 36 | Kind: orchestration.EventWorkerFinished, | ||
| @@ -45,4 +46,7 @@ func TestSubmitOrQueuePreservesSealedWorkerWaveWithPendingWorker(t *testing.T) { | |||||
| 45 | if got := actions[0].Text; got != "review the result" { | 46 | if got := actions[0].Text; got != "review the result" { | ||
| 46 | t.Fatalf("completion action text = %q, want queued user input", got) | 47 | t.Fatalf("completion action text = %q, want queued user input", got) | ||
| 47 | } | 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 | + | } | |||
| 48 | } | 52 | } | ||
M packages/agent/modes/interactive_queue_test.go +17 -0
| @@ -2,6 +2,7 @@ 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,6 +10,22 @@ import ( | |||
| 9 | 10 | "github.com/bnema/zut/packages/tui" | |
| 10 | 11 | ) | |
| 11 | 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 | + | ||
| 12 | 29 | func TestBusySubmitQueuesClipboardImagePrompt(t *testing.T) { | |
| 13 | 30 | agent := core.NewAgent(nil, "test-model", "", nil) | |
| 14 | 31 | i := NewInteractive(InteractiveConfig{Agent: agent}) | |
| @@ -2,6 +2,7 @@ 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,6 +10,22 @@ import ( | |||||
| 9 | "github.com/bnema/zut/packages/tui" | 10 | "github.com/bnema/zut/packages/tui" | ||
| 10 | ) | 11 | ) | ||
| 11 | 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 | + | ||||
| 12 | func TestBusySubmitQueuesClipboardImagePrompt(t *testing.T) { | 29 | func TestBusySubmitQueuesClipboardImagePrompt(t *testing.T) { | ||
| 13 | agent := core.NewAgent(nil, "test-model", "", nil) | 30 | agent := core.NewAgent(nil, "test-model", "", nil) | ||
| 14 | i := NewInteractive(InteractiveConfig{Agent: agent}) | 31 | i := NewInteractive(InteractiveConfig{Agent: agent}) | ||
M packages/agent/modes/interactive_rendering.go +7 -4
| @@ -13,15 +13,18 @@ import ( | |||
| 13 | 13 | "github.com/bnema/zut/packages/tui" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | - | func queuedMessageSummary(message core.QueuedMessage) string { | |
| 17 | - | text := message.Text | |
| 16 | + | func queuedMessageSummary(message core.QueuedMessage, maxWidth int) string { | |
| 18 | 17 | if len(message.Images) == 0 { | |
| 19 | - | return text | |
| 18 | + | return truncateLine(message.Text, maxWidth) | |
| 20 | 19 | } | |
| 21 | 20 | imageLabel := "[image]" | |
| 22 | 21 | if len(message.Images) > 1 { | |
| 23 | 22 | imageLabel = fmt.Sprintf("[%d images]", len(message.Images)) | |
| 24 | 23 | } | |
| 24 | + | if message.Text == "" || maxWidth <= len(imageLabel) { | |
| 25 | + | return truncateLine(imageLabel, maxWidth) | |
| 26 | + | } | |
| 27 | + | text := truncateLine(message.Text, maxWidth-len(imageLabel)-1) | |
| 25 | 28 | if text == "" { | |
| 26 | 29 | return imageLabel | |
| 27 | 30 | } | |
| @@ -773,7 +776,7 @@ func (i *Interactive) redraw() { | |||
| 773 | 776 | queue = append(queue, "") | |
| 774 | 777 | for _, q := range queued { | |
| 775 | 778 | label := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, " sliding in: ") | |
| 776 | - | text := truncateLine(queuedMessageSummary(q), mainCols-17) | |
| 779 | + | text := queuedMessageSummary(q, mainCols-17) | |
| 777 | 780 | queue = append(queue, label+i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text)) | |
| 778 | 781 | } | |
| 779 | 782 | // Hint row, rendered in the same muted tone as the model | |
| @@ -13,15 +13,18 @@ import ( | |||||
| 13 | "github.com/bnema/zut/packages/tui" | 13 | "github.com/bnema/zut/packages/tui" | ||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | - | func queuedMessageSummary(message core.QueuedMessage) string { | 16 | + | func queuedMessageSummary(message core.QueuedMessage, maxWidth int) string { |
| 17 | - | text := message.Text | |||
| 18 | if len(message.Images) == 0 { | 17 | if len(message.Images) == 0 { | ||
| 19 | - | return text | 18 | + | return truncateLine(message.Text, maxWidth) |
| 20 | } | 19 | } | ||
| 21 | imageLabel := "[image]" | 20 | imageLabel := "[image]" | ||
| 22 | if len(message.Images) > 1 { | 21 | if len(message.Images) > 1 { | ||
| 23 | imageLabel = fmt.Sprintf("[%d images]", len(message.Images)) | 22 | imageLabel = fmt.Sprintf("[%d images]", len(message.Images)) | ||
| 24 | } | 23 | } | ||
| 24 | + | if message.Text == "" || maxWidth <= len(imageLabel) { | |||
| 25 | + | return truncateLine(imageLabel, maxWidth) | |||
| 26 | + | } | |||
| 27 | + | text := truncateLine(message.Text, maxWidth-len(imageLabel)-1) | |||
| 25 | if text == "" { | 28 | if text == "" { | ||
| 26 | return imageLabel | 29 | return imageLabel | ||
| 27 | } | 30 | } | ||
| @@ -773,7 +776,7 @@ func (i *Interactive) redraw() { | |||||
| 773 | queue = append(queue, "") | 776 | queue = append(queue, "") | ||
| 774 | for _, q := range queued { | 777 | for _, q := range queued { | ||
| 775 | label := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, " sliding in: ") | 778 | label := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, " sliding in: ") | ||
| 776 | - | text := truncateLine(queuedMessageSummary(q), mainCols-17) | 779 | + | text := queuedMessageSummary(q, mainCols-17) |
| 777 | 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)) | ||
| 778 | } | 781 | } | ||
| 779 | // 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 { | ||