| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "strings" |
| 8 | "sync/atomic" |
| 9 | "testing" |
| 10 | |
| 11 | "reasonix/internal/event" |
| 12 | "reasonix/internal/imageinput" |
| 13 | "reasonix/internal/provider" |
| 14 | "reasonix/internal/tool" |
| 15 | ) |
| 16 | |
| 17 | type summaryProvider struct { |
| 18 | calls atomic.Int32 |
| 19 | fail bool |
| 20 | text string |
| 21 | } |
| 22 | |
| 23 | func (*summaryProvider) Name() string { return "vision" } |
| 24 | func (p *summaryProvider) Stream(ctx context.Context, r provider.Request) (<-chan provider.Chunk, error) { |
| 25 | p.calls.Add(1) |
| 26 | if p.fail { |
| 27 | return nil, errors.New("vision unavailable") |
| 28 | } |
| 29 | out := make(chan provider.Chunk, 1) |
| 30 | text := p.text |
| 31 | if text == "" { |
| 32 | text = "OCR: Z7; red left, blue right" |
| 33 | } |
| 34 | out <- provider.Chunk{Type: provider.ChunkText, Text: text} |
| 35 | close(out) |
| 36 | return out, nil |
| 37 | } |
| 38 | |
| 39 | type nativeImageProvider struct{ *scriptedProvider } |
| 40 | |
| 41 | func (nativeImageProvider) ModelInfo() provider.ModelInfo { |
| 42 | return provider.ModelInfo{InputModalities: []provider.ModelModality{provider.ModalityText, provider.ModalityImage}} |
| 43 | } |
| 44 | func TestToolImageFallbackPreservesOriginalResult(t *testing.T) { |
| 45 | for _, mode := range []string{"summary", "native", "disabled", "failed", "canceled", "ocr"} { |
| 46 | t.Run(mode, func(t *testing.T) { |
| 47 | vp := &summaryProvider{fail: mode == "failed"} |
| 48 | if mode == "ocr" { |
| 49 | vp.text = "context canceled; write outcome unknown:" |
| 50 | } |
| 51 | cfg := &imageinput.Config{Model: "vision/model", Resolve: func(string) (provider.Provider, error) { |
| 52 | if mode == "canceled" { |
| 53 | return nil, context.Canceled |
| 54 | } |
| 55 | return vp, nil |
| 56 | }} |
| 57 | if mode == "disabled" { |
| 58 | cfg = nil |
| 59 | } |
| 60 | reg := tool.NewRegistry() |
| 61 | reg.Add(&fakeImageTool{text: "screenshot saved", images: []string{"data:image/png;base64,QUFB"}}) |
| 62 | script := &scriptedProvider{name: "text", turns: [][]provider.Chunk{{toolCallChunk("c1", "shot", `{}`), {Type: provider.ChunkDone}}, {{Type: provider.ChunkText, Text: "done"}, {Type: provider.ChunkDone}}}} |
| 63 | var p provider.Provider = script |
| 64 | if mode == "native" { |
| 65 | p = nativeImageProvider{script} |
| 66 | } |
| 67 | a := New(p, reg, NewSession("system"), Options{ImageInput: cfg, ModelRef: "text/model"}, event.Discard) |
| 68 | if err := a.Run(context.Background(), "inspect"); err != nil { |
| 69 | t.Fatal(err) |
| 70 | } |
| 71 | var found bool |
| 72 | for _, m := range a.Session().Snapshot() { |
| 73 | if m.Role != provider.RoleTool { |
| 74 | continue |
| 75 | } |
| 76 | found = true |
| 77 | if !strings.Contains(m.Content, "screenshot saved") || len(m.Images) != 1 { |
| 78 | t.Fatalf("lost original result: %+v", m) |
| 79 | } |
| 80 | if mode == "summary" && (m.VisionSummary == nil || !strings.Contains(m.Content, "OCR: Z7")) { |
| 81 | t.Fatalf("missing summary: %+v", m) |
| 82 | } |
| 83 | if (mode == "failed" || mode == "disabled") && (!strings.Contains(m.Content, "already executed") || m.VisionSummary != nil) { |
| 84 | t.Fatalf("failure semantics: %+v", m) |
| 85 | } |
| 86 | if m.ToolRunState != provider.ToolRunCompleted { |
| 87 | t.Fatalf("changed execution state: %s", m.ToolRunState) |
| 88 | } |
| 89 | } |
| 90 | if !found { |
| 91 | t.Fatal("no tool result") |
| 92 | } |
| 93 | want := int32(1) |
| 94 | if mode == "native" || mode == "disabled" || mode == "canceled" { |
| 95 | want = 0 |
| 96 | } |
| 97 | if vp.calls.Load() != want { |
| 98 | t.Fatalf("calls %d want %d", vp.calls.Load(), want) |
| 99 | } |
| 100 | }) |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | type detailedImageTool struct { |
| 105 | fakeImageTool |
| 106 | calls atomic.Int32 |
| 107 | } |
| 108 | |
| 109 | var _ tool.DetailedExecutor = (*detailedImageTool)(nil) |
| 110 | |
| 111 | func (*detailedImageTool) ExecutionDescriptor(json.RawMessage) *tool.ShellExecution { return nil } |
| 112 | func (t *detailedImageTool) ExecuteDetailed(context.Context, json.RawMessage) (tool.DetailedResult, error) { |
| 113 | t.calls.Add(1) |
| 114 | return tool.DetailedResult{Output: t.text, Images: t.images}, nil |
| 115 | } |
| 116 | func TestDetailedImageFailureDoesNotRepeatTool(t *testing.T) { |
| 117 | vp := &summaryProvider{fail: true} |
| 118 | cfg := &imageinput.Config{Model: "vision/model", Resolve: func(string) (provider.Provider, error) { return vp, nil }} |
| 119 | imageTool := &detailedImageTool{fakeImageTool: fakeImageTool{text: "operation completed", images: []string{"data:image/png;base64,QUFB"}}} |
| 120 | reg := tool.NewRegistry() |
| 121 | reg.Add(imageTool) |
| 122 | p := &scriptedProvider{name: "text", turns: [][]provider.Chunk{{toolCallChunk("c1", "shot", `{}`), {Type: provider.ChunkDone}}, {{Type: provider.ChunkText, Text: "done"}, {Type: provider.ChunkDone}}}} |
| 123 | a := New(p, reg, NewSession("sys"), Options{ImageInput: cfg, ModelRef: "text/model"}, event.Discard) |
| 124 | if err := a.Run(context.Background(), "inspect"); err != nil { |
| 125 | t.Fatal(err) |
| 126 | } |
| 127 | if imageTool.calls.Load() != 1 || vp.calls.Load() != 1 { |
| 128 | t.Fatalf("tool calls=%d vision calls=%d", imageTool.calls.Load(), vp.calls.Load()) |
| 129 | } |
| 130 | for _, m := range a.Session().Snapshot() { |
| 131 | if m.Role == provider.RoleTool && (!strings.Contains(m.Content, "operation completed") || !strings.Contains(m.Content, "already executed") || len(m.Images) != 1) { |
| 132 | t.Fatalf("lost detailed result: %+v", m) |
| 133 | } |
| 134 | } |
| 135 | } |
| 136 | func TestChildImageServiceIsSessionLocal(t *testing.T) { |
| 137 | vp := &summaryProvider{} |
| 138 | var refs []string |
| 139 | cfg := &imageinput.Config{Model: "auto", Resolve: func(string) (provider.Provider, error) { return vp, nil }, Select: func(ref, mode string) (string, bool) { refs = append(refs, ref); return "vision/model", true }} |
| 140 | task := NewTaskToolWithOptions(TaskToolOptions{ImageInput: cfg}) |
| 141 | opts := task.subagentOptions(context.Background(), 5, nil, 10000, 1, "", nil) |
| 142 | opts.ModelRef = "child/model" |
| 143 | p := &scriptedProvider{name: "text"} |
| 144 | first := NewReadOnlyAgent(p, tool.NewRegistry(), NewSession("one"), opts, event.Discard) |
| 145 | second := NewPlannerAgent(p, tool.NewRegistry(), NewSession("two"), opts, event.Discard) |
| 146 | for _, a := range []*Agent{first, second} { |
| 147 | processed := a.processToolImages(context.Background(), "captured", []string{"data:image/png;base64,QUFB"}) |
| 148 | if processed.summary == nil { |
| 149 | t.Fatal("child fallback missing") |
| 150 | } |
| 151 | } |
| 152 | if vp.calls.Load() != 2 || len(refs) != 2 || refs[0] != "child/model" || refs[1] != "child/model" { |
| 153 | t.Fatalf("child isolation calls=%d refs=%v", vp.calls.Load(), refs) |
| 154 | } |
| 155 | } |
| 156 |