Skip to content

Commit afd2cea

Browse files
committed
Merge remote-tracking branch 'origin/main'
# Conflicts: # internal/config/model.go
2 parents 30a8bad + b6e1599 commit afd2cea

8 files changed

Lines changed: 815 additions & 72 deletions

File tree

go.mod

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ require (
66
github.com/charmbracelet/bubbles v1.0.0
77
github.com/charmbracelet/bubbletea v1.3.10
88
github.com/charmbracelet/lipgloss v1.1.0
9+
github.com/joho/godotenv v1.5.1
10+
golang.org/x/net v0.50.0
911
gopkg.in/yaml.v3 v3.0.1
1012
)
1113

@@ -20,7 +22,6 @@ require (
2022
github.com/clipperhouse/stringish v0.1.1 // indirect
2123
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
2224
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
23-
github.com/joho/godotenv v1.5.1 // indirect
2425
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
2526
github.com/mattn/go-isatty v0.0.20 // indirect
2627
github.com/mattn/go-localereader v0.0.1 // indirect
@@ -31,6 +32,6 @@ require (
3132
github.com/rivo/uniseg v0.4.7 // indirect
3233
github.com/sahilm/fuzzy v0.1.1 // indirect
3334
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
34-
golang.org/x/sys v0.38.0 // indirect
35-
golang.org/x/text v0.3.8 // indirect
35+
golang.org/x/sys v0.41.0 // indirect
36+
golang.org/x/text v0.34.0 // indirect
3637
)

go.sum

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,18 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
5656
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
5757
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
5858
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
59+
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
60+
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
5961
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
6062
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
61-
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
62-
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
63-
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
64-
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
63+
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
64+
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
65+
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
66+
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
67+
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
68+
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
69+
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
70+
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
6571
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
6672
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
6773
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

internal/app/bootstrap.go

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,7 @@ func NewProgram(ctx context.Context) (*tea.Program, error) {
2525
return nil, err
2626
}
2727

28-
toolRegistry := tools.NewRegistry()
29-
toolRegistry.Register(filesystem.New(cfg.Workdir))
30-
toolRegistry.Register(filesystem.NewWrite(cfg.Workdir))
31-
toolRegistry.Register(filesystem.NewGrep(cfg.Workdir))
32-
toolRegistry.Register(filesystem.NewGlob(cfg.Workdir))
33-
toolRegistry.Register(filesystem.NewEdit(cfg.Workdir))
34-
toolRegistry.Register(bash.New(cfg.Workdir, cfg.Shell, time.Duration(cfg.ToolTimeoutSec)*time.Second))
35-
toolRegistry.Register(webfetch.New(time.Duration(cfg.ToolTimeoutSec) * time.Second))
28+
toolRegistry := buildToolRegistry(cfg)
3629

3730
providerRegistry, err := builtin.NewRegistry()
3831
if err != nil {
@@ -53,3 +46,19 @@ func NewProgram(ctx context.Context) (*tea.Program, error) {
5346
tea.WithMouseCellMotion(),
5447
), nil
5548
}
49+
50+
func buildToolRegistry(cfg config.Config) *tools.Registry {
51+
toolRegistry := tools.NewRegistry()
52+
toolRegistry.Register(filesystem.New(cfg.Workdir))
53+
toolRegistry.Register(filesystem.NewWrite(cfg.Workdir))
54+
toolRegistry.Register(filesystem.NewGrep(cfg.Workdir))
55+
toolRegistry.Register(filesystem.NewGlob(cfg.Workdir))
56+
toolRegistry.Register(filesystem.NewEdit(cfg.Workdir))
57+
toolRegistry.Register(bash.New(cfg.Workdir, cfg.Shell, time.Duration(cfg.ToolTimeoutSec)*time.Second))
58+
toolRegistry.Register(webfetch.New(webfetch.Config{
59+
Timeout: time.Duration(cfg.ToolTimeoutSec) * time.Second,
60+
MaxResponseBytes: cfg.Tools.WebFetch.MaxResponseBytes,
61+
SupportedContentTypes: cfg.Tools.WebFetch.SupportedContentTypes,
62+
}))
63+
return toolRegistry
64+
}

internal/app/bootstrap_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,15 @@ package app
22

33
import (
44
"context"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
58
"os"
69
"path/filepath"
710
"testing"
11+
12+
"github.com/dust/neo-code/internal/config"
13+
"github.com/dust/neo-code/internal/tools"
814
)
915

1016
func TestNewProgram(t *testing.T) {
@@ -25,3 +31,42 @@ func TestNewProgram(t *testing.T) {
2531
t.Fatalf("expected config file to be created at %q: %v", configPath, err)
2632
}
2733
}
34+
35+
func TestBuildToolRegistryUsesWebFetchConfig(t *testing.T) {
36+
t.Parallel()
37+
38+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
39+
w.Header().Set("Content-Type", "text/plain")
40+
_, _ = w.Write([]byte("1234567890"))
41+
}))
42+
defer server.Close()
43+
44+
cfg := config.Default().Clone()
45+
cfg.Workdir = t.TempDir()
46+
cfg.Tools.WebFetch.MaxResponseBytes = 4
47+
48+
registry := buildToolRegistry(cfg)
49+
tool, err := registry.Get("webfetch")
50+
if err != nil {
51+
t.Fatalf("registry.Get(webfetch) error = %v", err)
52+
}
53+
54+
args, err := json.Marshal(map[string]string{"url": server.URL})
55+
if err != nil {
56+
t.Fatalf("marshal args: %v", err)
57+
}
58+
59+
result, execErr := tool.Execute(context.Background(), tools.ToolCallInput{
60+
Name: "webfetch",
61+
Arguments: args,
62+
})
63+
if execErr != nil {
64+
t.Fatalf("webfetch execute error = %v", execErr)
65+
}
66+
if truncated, ok := result.Metadata["truncated"].(bool); !ok || !truncated {
67+
t.Fatalf("expected truncated metadata, got %+v", result.Metadata)
68+
}
69+
if result.Content == "" {
70+
t.Fatalf("expected formatted webfetch content")
71+
}
72+
}

internal/config/config_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,15 @@ selected_provider: openai
5050
current_model: gpt-5.4
5151
workdir: .
5252
shell: powershell
53+
5354
provider_overrides:
55+
tools:
56+
webfetch:
57+
max_response_bytes: 4096
58+
supported_content_types:
59+
- text/html
60+
- text/plain
61+
providers:
5462
- name: openai
5563
base_url: https://example.com/v1
5664
model: gpt-5.4
@@ -68,6 +76,12 @@ provider_overrides:
6876
if provider.BaseURL != "https://example.com/v1" {
6977
t.Fatalf("expected custom base url, got %q", provider.BaseURL)
7078
}
79+
if cfg.Tools.WebFetch.MaxResponseBytes != 4096 {
80+
t.Fatalf("expected custom max_response_bytes 4096, got %d", cfg.Tools.WebFetch.MaxResponseBytes)
81+
}
82+
if len(cfg.Tools.WebFetch.SupportedContentTypes) != 2 {
83+
t.Fatalf("expected 2 supported content types, got %+v", cfg.Tools.WebFetch.SupportedContentTypes)
84+
}
7185
},
7286
},
7387
{
@@ -126,6 +140,12 @@ providers:
126140
if provider.Model != "gpt-4o" {
127141
t.Fatalf("expected provider model gpt-4o, got %q", provider.Model)
128142
}
143+
if cfg.Tools.WebFetch.MaxResponseBytes != DefaultWebFetchMaxResponseBytes {
144+
t.Fatalf("expected default max_response_bytes %d, got %d", DefaultWebFetchMaxResponseBytes, cfg.Tools.WebFetch.MaxResponseBytes)
145+
}
146+
if len(cfg.Tools.WebFetch.SupportedContentTypes) != len(DefaultWebFetchSupportedContentTypes()) {
147+
t.Fatalf("expected default supported content types, got %+v", cfg.Tools.WebFetch.SupportedContentTypes)
148+
}
129149
},
130150
},
131151
}
@@ -388,6 +408,12 @@ func TestConfigApplyDefaultsFillsMissingFields(t *testing.T) {
388408
if !filepath.IsAbs(cfg.Workdir) {
389409
t.Fatalf("expected absolute workdir, got %q", cfg.Workdir)
390410
}
411+
if cfg.Tools.WebFetch.MaxResponseBytes != DefaultWebFetchMaxResponseBytes {
412+
t.Fatalf("expected default webfetch max_response_bytes %d, got %d", DefaultWebFetchMaxResponseBytes, cfg.Tools.WebFetch.MaxResponseBytes)
413+
}
414+
if len(cfg.Tools.WebFetch.SupportedContentTypes) != len(DefaultWebFetchSupportedContentTypes()) {
415+
t.Fatalf("expected default supported content types, got %+v", cfg.Tools.WebFetch.SupportedContentTypes)
416+
}
391417
}
392418

393419
func TestConfigValidateFailures(t *testing.T) {
@@ -449,6 +475,24 @@ func TestConfigValidateFailures(t *testing.T) {
449475
}(),
450476
expectErr: "model is empty",
451477
},
478+
{
479+
name: "invalid webfetch max response bytes",
480+
config: func() *Config {
481+
cfg := validConfig.Clone()
482+
cfg.Tools.WebFetch.MaxResponseBytes = 0
483+
return &cfg
484+
}(),
485+
expectErr: "max_response_bytes must be greater than 0",
486+
},
487+
{
488+
name: "invalid webfetch supported content types",
489+
config: func() *Config {
490+
cfg := validConfig.Clone()
491+
cfg.Tools.WebFetch.SupportedContentTypes = []string{""}
492+
return &cfg
493+
}(),
494+
expectErr: "supported_content_types[0] is empty",
495+
},
452496
}
453497

454498
for _, tt := range tests {
@@ -568,6 +612,8 @@ func TestLoaderLoadAndSaveRoundTrip(t *testing.T) {
568612
cfg.Providers[i].Model = "gpt-5.4"
569613
}
570614
}
615+
cfg.Tools.WebFetch.MaxResponseBytes = 1024
616+
cfg.Tools.WebFetch.SupportedContentTypes = []string{"text/html", "application/json"}
571617
if err := loader.Save(context.Background(), cfg); err != nil {
572618
t.Fatalf("Save() error = %v", err)
573619
}
@@ -591,6 +637,12 @@ func TestLoaderLoadAndSaveRoundTrip(t *testing.T) {
591637
if reloaded.CurrentModel != "gpt-5.4" {
592638
t.Fatalf("expected current model %q, got %q", "gpt-5.4", reloaded.CurrentModel)
593639
}
640+
if reloaded.Tools.WebFetch.MaxResponseBytes != 1024 {
641+
t.Fatalf("expected max_response_bytes %d, got %d", 1024, reloaded.Tools.WebFetch.MaxResponseBytes)
642+
}
643+
if len(reloaded.Tools.WebFetch.SupportedContentTypes) != 2 {
644+
t.Fatalf("expected persisted supported content types, got %+v", reloaded.Tools.WebFetch.SupportedContentTypes)
645+
}
594646
}
595647

596648
func TestLoaderUsesUpdatedBuiltinProviderWhenUserHasNoOverride(t *testing.T) {
@@ -732,9 +784,13 @@ func TestNormalizeWorkdirAndClone(t *testing.T) {
732784
cfg := testDefaultConfig()
733785
cloned := cfg.Clone()
734786
cloned.CurrentModel = "modified"
787+
cloned.Tools.WebFetch.SupportedContentTypes[0] = "application/json"
735788
if cfg.CurrentModel == cloned.CurrentModel {
736789
t.Fatalf("expected clone to be independent from source")
737790
}
791+
if cfg.Tools.WebFetch.SupportedContentTypes[0] == cloned.Tools.WebFetch.SupportedContentTypes[0] {
792+
t.Fatalf("expected webfetch supported content types to be cloned")
793+
}
738794
}
739795

740796
func TestManagerHelperMethodsAndReloads(t *testing.T) {

internal/tools/webfetch/html.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package webfetch
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"strings"
7+
8+
"golang.org/x/net/html"
9+
)
10+
11+
var skippedHTMLTags = map[string]struct{}{
12+
"canvas": {},
13+
"head": {},
14+
"noscript": {},
15+
"script": {},
16+
"style": {},
17+
"svg": {},
18+
"template": {},
19+
}
20+
21+
func extractHTMLContent(body []byte) (string, string, error) {
22+
doc, err := html.Parse(bytes.NewReader(body))
23+
if err != nil {
24+
return "", "", fmt.Errorf("parse document: %w", err)
25+
}
26+
27+
root := findElement(doc, "body")
28+
if root == nil {
29+
root = doc
30+
}
31+
32+
text := normalizeWhitespace(collectVisibleText(root))
33+
title := normalizeWhitespace(textContent(findElement(doc, "title")))
34+
return text, title, nil
35+
}
36+
37+
func findElement(node *html.Node, tag string) *html.Node {
38+
if node == nil {
39+
return nil
40+
}
41+
if node.Type == html.ElementNode && strings.EqualFold(node.Data, tag) {
42+
return node
43+
}
44+
for child := node.FirstChild; child != nil; child = child.NextSibling {
45+
if found := findElement(child, tag); found != nil {
46+
return found
47+
}
48+
}
49+
return nil
50+
}
51+
52+
func collectVisibleText(node *html.Node) string {
53+
var builder strings.Builder
54+
appendVisibleText(&builder, node)
55+
return builder.String()
56+
}
57+
58+
func appendVisibleText(builder *strings.Builder, node *html.Node) {
59+
if node == nil {
60+
return
61+
}
62+
if shouldSkipNode(node) {
63+
return
64+
}
65+
66+
if node.Type == html.TextNode {
67+
text := strings.TrimSpace(node.Data)
68+
if text != "" {
69+
builder.WriteString(text)
70+
builder.WriteByte('\n')
71+
}
72+
}
73+
74+
for child := node.FirstChild; child != nil; child = child.NextSibling {
75+
appendVisibleText(builder, child)
76+
}
77+
}
78+
79+
func shouldSkipNode(node *html.Node) bool {
80+
if node == nil || node.Type != html.ElementNode {
81+
return false
82+
}
83+
_, skip := skippedHTMLTags[strings.ToLower(node.Data)]
84+
return skip
85+
}
86+
87+
func textContent(node *html.Node) string {
88+
if node == nil {
89+
return ""
90+
}
91+
92+
var builder strings.Builder
93+
var visit func(*html.Node)
94+
visit = func(current *html.Node) {
95+
if current == nil {
96+
return
97+
}
98+
if current.Type == html.TextNode {
99+
builder.WriteString(current.Data)
100+
builder.WriteByte(' ')
101+
}
102+
for child := current.FirstChild; child != nil; child = child.NextSibling {
103+
visit(child)
104+
}
105+
}
106+
visit(node)
107+
return builder.String()
108+
}
109+
110+
func normalizeWhitespace(value string) string {
111+
return strings.Join(strings.Fields(value), " ")
112+
}

0 commit comments

Comments
 (0)