Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,12 @@ jobs:
task test
task test-binary

# Plan storage relies on OS-specific file locking and path semantics, so its
# tests must actually run on Windows, not only cross-compile.
windows-plan-tests:
# Native Windows tests. Plan storage relies on OS-specific file locking and
# path semantics, so the full Go test suite runs natively on Windows in a
# single blocking `task test` step, mirroring the Linux job.
windows-tests:
Comment thread
aheritier marked this conversation as resolved.
runs-on: windows-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
Expand All @@ -87,8 +89,16 @@ jobs:
go-version-file: go.mod
cache-dependency-path: go.sum

- name: Run plan tests
run: go test ./pkg/tools/builtin/plan/ ./pkg/plans/
- name: Install Task
uses: arduino/setup-task@b91d5d2c96a56797b48ac1e0e89220bf64044611 # v2.0.0
with:
version: 3.51.1

- name: Go environment
run: go env GOOS GOARCH CGO_ENABLED CC

- name: Run tests
run: task test

license-check:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -161,7 +171,7 @@ jobs:

build-and-push-image:
if: github.event_name != 'pull_request' && !github.event.repository.fork
needs: [ lint, build-and-test, windows-plan-tests, license-check ]
needs: [ lint, build-and-test, windows-tests, license-check ]
# Build each platform on its own native runner in parallel, push by digest,
# then assemble the multi-arch manifest in the merge job below.
strategy:
Expand Down
5 changes: 3 additions & 2 deletions cmd/root/agent_picker.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/docker/docker-agent/pkg/config"
"github.com/docker/docker-agent/pkg/environment"
"github.com/docker/docker-agent/pkg/path"
"github.com/docker/docker-agent/pkg/paths"
"github.com/docker/docker-agent/pkg/tui/components/scrollbar"
"github.com/docker/docker-agent/pkg/tui/components/toolcommon"
"github.com/docker/docker-agent/pkg/tui/dialog"
Expand All @@ -36,8 +37,8 @@ const agentPickerDefaultsSpec = "defaults"
// agents plus any agent config files found in ~/.agents.
func defaultAgentPickerRefs() []string {
refs := []string{"default", "coder"}
home, err := os.UserHomeDir()
if err != nil {
home := paths.GetHomeDir()
if home == "" {
return refs
}
return append(refs, agentRefsInDir(filepath.Join(home, ".agents"))...)
Expand Down
6 changes: 3 additions & 3 deletions cmd/root/completion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ func TestCompleteAgentFilename(t *testing.T) {
require.NoError(t, os.Mkdir(filepath.Join(dir, "subdir"), 0o755))
},
toComplete: "./sub",
wantCompletions: []string{"./subdir/"},
wantCompletions: []string{"./subdir" + string(filepath.Separator)},
wantNoSpace: true, // directory completion should NOT add space
wantNoFileComp: true,
useRelativePrefix: true,
Expand All @@ -70,7 +70,7 @@ func TestCompleteAgentFilename(t *testing.T) {
require.NoError(t, os.Mkdir(filepath.Join(dir, "mydir"), 0o755))
},
toComplete: "./my",
wantCompletions: []string{"./myagent.yaml", "./mydir/"},
wantCompletions: []string{"./myagent.yaml", "./mydir" + string(filepath.Separator)},
wantNoSpace: false, // multiple completions, no NoSpace
wantNoFileComp: true,
useRelativePrefix: true,
Expand All @@ -82,7 +82,7 @@ func TestCompleteAgentFilename(t *testing.T) {
require.NoError(t, os.Mkdir(filepath.Join(dir, "onlydir"), 0o755))
},
toComplete: "./only",
wantCompletions: []string{"./onlydir/"},
wantCompletions: []string{"./onlydir" + string(filepath.Separator)},
wantNoSpace: true,
wantNoFileComp: true,
useRelativePrefix: true,
Expand Down
4 changes: 3 additions & 1 deletion cmd/root/sandbox_cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import (
// to end against a temp HOME, verifying that allow / list / deny
// round-trip through the user config.
func TestSandboxAllowDenyList(t *testing.T) {
t.Setenv("HOME", t.TempDir())
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)

root := NewRootCmd()
root.SetContext(t.Context())
Expand Down
19 changes: 18 additions & 1 deletion e2e/a2a_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"net/http"
"path/filepath"
"testing"
"time"

"github.com/a2aproject/a2a-go/a2a"
"github.com/a2aproject/a2a-go/a2asrv"
Expand Down Expand Up @@ -173,9 +174,25 @@ func startA2AServer(t *testing.T, agentFile string, runConfig *config.RuntimeCon
ln, err := lc.Listen(t.Context(), "tcp", ":0")
require.NoError(t, err)

sessionDB := filepath.Join(t.TempDir(), "session.db")

done := make(chan struct{})
go func() {
_ = a2aserver.Run(t.Context(), agentFile, "root", filepath.Join(t.TempDir(), "session.db"), runConfig, ln)
defer close(done)
_ = a2aserver.Run(t.Context(), agentFile, "root", sessionDB, runConfig, ln)
}()
// Run stops when t.Context() is canceled (just before cleanups run);
// closing the listener also covers the window before Serve starts. Wait
// for Run to return so session.db is closed before t.TempDir cleanup
// removes it — Windows refuses to delete open files.
t.Cleanup(func() {
_ = ln.Close()
select {
case <-done:
case <-time.After(30 * time.Second):
t.Fatal("A2A server did not stop during cleanup")
}
})

port := ln.Addr().(*net.TCPAddr).Port
serverURL := fmt.Sprintf("http://localhost:%d", port)
Expand Down
29 changes: 21 additions & 8 deletions e2e/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -39,13 +40,13 @@ func TestCagentAPI_ListSessions(t *testing.T) {
t.Run(tc.db, func(t *testing.T) {
socketPath := startCagentAPI(t, filepath.Join("testdata", "db", tc.db))

client := &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", socketPath)
},
transport := &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", socketPath)
},
}
client := &http.Client{Transport: transport}
t.Cleanup(transport.CloseIdleConnections)

req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://localhost/api/sessions", http.NoBody)
require.NoError(t, err)
Expand Down Expand Up @@ -81,19 +82,31 @@ func startCagentAPI(t *testing.T, db string) string {

ln, err := server.Listen(t.Context(), "unix://cagent.sock")
require.NoError(t, err)
t.Cleanup(func() {
_ = ln.Close()
})

sessionStore, err := sqlitestore.New(t.Context(), dbCopy)
require.NoError(t, err)
t.Cleanup(func() {
_ = sessionStore.Close()
})

srv, err := server.New(t.Context(), sessionStore, &config.RuntimeConfig{}, 0, nil, "")
require.NoError(t, err)

done := make(chan struct{})
go func() {
defer close(done)
_ = srv.Serve(t.Context(), ln)
}()
// Stop the server and wait for it before the store is closed and the
// temp dir removed — Windows refuses to delete files with open handles.
t.Cleanup(func() {
_ = ln.Close()
select {
case <-done:
case <-time.After(30 * time.Second):
t.Fatal("API server did not stop during cleanup")
}
})

return "cagent.sock"
}
Expand Down
12 changes: 12 additions & 0 deletions pkg/a2a/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ func Run(ctx context.Context, agentFilename, agentName, sessionDB string, runCon
if err != nil {
return fmt.Errorf("failed to open session store: %w", err)
}
defer func() {
if err := sessStore.Close(); err != nil {
slog.ErrorContext(ctx, "Failed to close session store", "error", err)
}
}()

adkAgent, err := newDockerAgentAdapter(t, agentName, sessStore)
if err != nil {
Expand Down Expand Up @@ -138,6 +143,13 @@ func Run(ctx context.Context, agentFilename, agentName, sessionDB string, runCon
e.GET(a2asrv.WellKnownAgentCardPath, echo.WrapHandler(cardHandler))
e.POST(agentPath, echo.WrapHandler(jsonrpcHandler))

// Stop serving when ctx is canceled so Run returns and the deferred
// cleanups (session store, tool sets) release their resources.
stop := context.AfterFunc(ctx, func() {
_ = e.Server.Close()
})
defer stop()

if err := e.Server.Serve(ln); err != nil && ctx.Err() == nil {
slog.ErrorContext(ctx, "Failed to start server", "error", err)
return err
Expand Down
55 changes: 55 additions & 0 deletions pkg/a2a/server_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
package a2a

import (
"context"
"net"
"net/http"
"path/filepath"
"testing"
"time"

"github.com/a2aproject/a2a-go/a2asrv"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/docker/docker-agent/pkg/config"
)

func TestRoutableAddr(t *testing.T) {
Expand Down Expand Up @@ -31,3 +40,49 @@ func TestRoutableAddr(t *testing.T) {
})
}
}

// TestRun_StopsOnContextCancel: canceling the context must make Run stop
// serving and return, releasing the session store's file handles (otherwise
// t.TempDir cleanup fails on Windows with an open session.db).
func TestRun_StopsOnContextCancel(t *testing.T) {
t.Setenv("OPENAI_API_KEY", "DUMMY")

ctx, cancel := context.WithCancel(t.Context())
defer cancel()

var lc net.ListenConfig
ln, err := lc.Listen(ctx, "tcp", "127.0.0.1:0")
require.NoError(t, err)

sessionDB := filepath.Join(t.TempDir(), "session.db")

done := make(chan error, 1)
go func() {
done <- Run(ctx, "testdata/basic.yaml", "root", sessionDB, &config.RuntimeConfig{}, ln)
}()

// Cancel only once the server actually serves, so the test exercises a
// mid-serve shutdown rather than a pre-start one.
cardURL := "http://" + ln.Addr().String() + a2asrv.WellKnownAgentCardPath
require.Eventually(t, func() bool {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, cardURL, http.NoBody)
if err != nil {
return false
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode == http.StatusOK
}, 30*time.Second, 20*time.Millisecond, "A2A server never started serving")

cancel()

select {
case err := <-done:
require.NoError(t, err, "Run must return nil on context cancellation")
case <-time.After(30 * time.Second):
t.Fatal("Run did not return after context cancellation")
}
}
28 changes: 17 additions & 11 deletions pkg/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,9 @@ func TestApp_InjectUserMessage(t *testing.T) {

func TestApp_DropAttachedFile(t *testing.T) {
t.Parallel()
abs := t.TempDir()
foo := filepath.Join(abs, "foo.go")
bar := filepath.Join(abs, "bar.go")

newAppWithAttachments := func(store session.Store, paths ...string) (*App, *session.Session) {
sess := session.New(session.WithAttachedFiles(paths))
Expand All @@ -812,31 +815,34 @@ func TestApp_DropAttachedFile(t *testing.T) {
t.Run("drops by exact path and syncs the store", func(t *testing.T) {
t.Parallel()
store := session.NewInMemorySessionStore()
app, sess := newAppWithAttachments(store, "/abs/foo.go", "/abs/bar.go")
app, sess := newAppWithAttachments(store, foo, bar)

dropped, err := app.DropAttachedFile(t.Context(), "/abs/foo.go")
dropped, err := app.DropAttachedFile(t.Context(), foo)
require.NoError(t, err)
assert.Equal(t, "/abs/foo.go", dropped)
assert.Equal(t, []string{"/abs/bar.go"}, sess.AttachedFilesSnapshot())
assert.Equal(t, foo, dropped)
assert.Equal(t, []string{bar}, sess.AttachedFilesSnapshot())

stored, err := store.GetSession(t.Context(), sess.ID)
require.NoError(t, err)
assert.Equal(t, []string{"/abs/bar.go"}, stored.AttachedFilesSnapshot())
assert.Equal(t, []string{bar}, stored.AttachedFilesSnapshot())
})

t.Run("drops by unique base name", func(t *testing.T) {
t.Parallel()
app, sess := newAppWithAttachments(nil, "/abs/dir/foo.go", "/abs/dir/bar.go")
dir := filepath.Join(abs, "dir")
foo := filepath.Join(dir, "foo.go")
bar := filepath.Join(dir, "bar.go")
app, sess := newAppWithAttachments(nil, foo, bar)

dropped, err := app.DropAttachedFile(t.Context(), "foo.go")
require.NoError(t, err)
assert.Equal(t, "/abs/dir/foo.go", dropped)
assert.Equal(t, []string{"/abs/dir/bar.go"}, sess.AttachedFilesSnapshot())
assert.Equal(t, foo, dropped)
assert.Equal(t, []string{bar}, sess.AttachedFilesSnapshot())
})

t.Run("rejects ambiguous base names", func(t *testing.T) {
t.Parallel()
app, sess := newAppWithAttachments(nil, "/abs/a/foo.go", "/abs/b/foo.go")
app, sess := newAppWithAttachments(nil, filepath.Join(abs, "a", "foo.go"), filepath.Join(abs, "b", "foo.go"))

_, err := app.DropAttachedFile(t.Context(), "foo.go")
require.ErrorContains(t, err, "matches 2 attached files")
Expand All @@ -845,9 +851,9 @@ func TestApp_DropAttachedFile(t *testing.T) {

t.Run("rejects unknown files and blank input", func(t *testing.T) {
t.Parallel()
app, _ := newAppWithAttachments(nil, "/abs/foo.go")
app, _ := newAppWithAttachments(nil, foo)

_, err := app.DropAttachedFile(t.Context(), "/abs/other.go")
_, err := app.DropAttachedFile(t.Context(), filepath.Join(abs, "other.go"))
require.ErrorContains(t, err, "not attached")

_, err = app.DropAttachedFile(t.Context(), " ")
Expand Down
5 changes: 3 additions & 2 deletions pkg/board/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ import (
)

func TestNormalizeProjectPath(t *testing.T) {
abs, err := normalizeProjectPath("/some/repo")
input := filepath.Join(t.TempDir(), "repo")
abs, err := normalizeProjectPath(input)
require.NoError(t, err)
assert.Equal(t, "/some/repo", abs)
assert.Equal(t, input, abs)

// Empty and blank paths are rejected: they would silently validate
// against the board's working directory.
Expand Down
2 changes: 1 addition & 1 deletion pkg/board/controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ func waitForStatus(t *testing.T, store *Store, want CardStatus) {
assert.Eventually(t, func() bool {
card, err := store.GetCard("c1")
return err == nil && card.Status == want
}, 3*time.Second, 10*time.Millisecond, "expected status %s", want)
}, 10*time.Second, 10*time.Millisecond, "expected status %s", want)
}

func TestControllerRunningThenWaiting(t *testing.T) {
Expand Down
Loading
Loading