Files
coder/agent/agentsocket/context_test.go
T
Kyle Carberry 992b1ffed1 feat(agent): serve context sources over the agent socket (#26526)
## Overview

Split from #26466, scoped to **agent-only** changes. This PR exposes the
agent's context sources and snapshots over the existing agent socket.
There
are no changes outside `agent/`.

## What's included

- **agentsocket**: context source CRUD (`ContextSources`,
`GetContextSource`,
`AddContextSource`, `RemoveContextSource`) plus `GetContextSnapshot` and
`ResyncContext` RPCs, with matching client methods and proto. The server
receives the context `Manager` via `WithContextManager` and returns a
clean
  error when it is absent.
- **agentcontext**: the resync JSON response now carries the
per-resource
  `Name`, keeping the HTTP resync payload in sync with the drpc
  `PushContextState` path in `agentsocket`.
- **agent**: passes the context `Manager` to the socket server via
  `WithContextManager`.

## What's intentionally NOT here

- No MCP wiring. There are no MCP additions in `agent.go` or
`agentcontext`.
MCP ownership will land later in `agentcontext`; this PR does not build
on
  `agent/x/agentmcp`.
- No changes to `agent/x/agentmcp` or the `agentcontext` resolver. The
socket
  serves whatever context resources the `Manager` already resolves.

<details>
<summary>Context for reviewers</summary>

This is one of several PRs split out of #26466. Earlier revisions also
wired
live MCP servers through the socket; that scope was removed so this PR
stays
purely socket + context plumbing inside `agent/`. The agentcontext
resolver,
`agent/x/agentmcp`, and `agent.go` MCP startup behavior are unchanged
from
`main`.
</details>

---
_Created by Coder Agents on behalf of @kylecarbs._
2026-06-18 13:00:28 -07:00

195 lines
5.6 KiB
Go

package agentsocket_test
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent/agentcontext"
"github.com/coder/coder/v2/agent/agentsocket"
"github.com/coder/coder/v2/testutil"
)
// fakeContextManager is an in-memory agentsocket.ContextManager for tests.
type fakeContextManager struct {
sources []agentcontext.Source
snapshot agentcontext.Snapshot
resyncErr error
resynced bool
}
func (f *fakeContextManager) Sources() []agentcontext.Source { return f.sources }
func (f *fakeContextManager) HasSource(path string) (string, bool) {
for _, s := range f.sources {
if s.Path == path {
return s.Path, true
}
}
return "", false
}
func (f *fakeContextManager) AddSource(s agentcontext.Source) (agentcontext.Source, error) {
for _, existing := range f.sources {
if existing.Path == s.Path {
return existing, nil
}
}
f.sources = append(f.sources, s)
return s, nil
}
func (f *fakeContextManager) RemoveSource(path string) error {
for i, s := range f.sources {
if s.Path == path {
f.sources = append(f.sources[:i], f.sources[i+1:]...)
return nil
}
}
return agentcontext.ErrSourceNotFound
}
func (f *fakeContextManager) Snapshot() agentcontext.Snapshot { return f.snapshot }
func (f *fakeContextManager) Resync(_ context.Context) (agentcontext.Snapshot, error) {
if f.resyncErr != nil {
return agentcontext.Snapshot{}, f.resyncErr
}
f.resynced = true
return f.snapshot, nil
}
func TestDRPCAgentSocketService_Context(t *testing.T) {
t.Parallel()
t.Run("SourceCRUDAndSnapshot", func(t *testing.T) {
t.Parallel()
const sourcePath = "/home/coder/project"
cm := &fakeContextManager{
snapshot: agentcontext.Snapshot{
Version: 7,
Resources: []agentcontext.Resource{{
ID: "instruction_file:" + sourcePath + "/AGENTS.md",
Kind: agentcontext.KindInstructionFile,
Source: sourcePath + "/AGENTS.md",
SourcePath: sourcePath,
SizeBytes: 42,
Status: agentcontext.StatusOK,
Description: "be concise",
}, {
// A built-in resource (no source path) the show filter must skip.
ID: "instruction_file:/home/coder/.coder/AGENTS.md",
Kind: agentcontext.KindInstructionFile,
Source: "/home/coder/.coder/AGENTS.md",
Status: agentcontext.StatusOK,
}},
},
}
socketPath := testutil.AgentSocketPath(t)
ctx := testutil.Context(t, testutil.WaitShort)
server, err := agentsocket.NewServer(
slog.Make().Leveled(slog.LevelDebug),
agentsocket.WithPath(socketPath),
agentsocket.WithContextManager(cm),
)
require.NoError(t, err)
defer server.Close()
client := newSocketClient(ctx, t, socketPath)
// Add a source.
src, err := client.AddContextSource(ctx, sourcePath)
require.NoError(t, err)
require.Equal(t, sourcePath, src.Path)
// It shows up in the list.
sources, err := client.ContextSources(ctx)
require.NoError(t, err)
require.Len(t, sources, 1)
require.Equal(t, sourcePath, sources[0].Path)
// Get the registered source.
got, err := client.GetContextSource(ctx, sourcePath)
require.NoError(t, err)
require.Equal(t, sourcePath, got.Path)
// Getting an unregistered source errors.
_, err = client.GetContextSource(ctx, "/nope")
require.Error(t, err)
// Snapshot carries resources with their source path stamped.
snap, err := client.GetContextSnapshot(ctx)
require.NoError(t, err)
require.EqualValues(t, 7, snap.Version)
require.Len(t, snap.Resources, 2)
require.Equal(t, agentcontext.KindInstructionFile.String(), snap.Resources[0].Kind)
require.Equal(t, sourcePath, snap.Resources[0].SourcePath)
require.EqualValues(t, 42, snap.Resources[0].SizeBytes)
// Remove the source; removing again reports not found.
require.NoError(t, client.RemoveContextSource(ctx, sourcePath))
err = client.RemoveContextSource(ctx, sourcePath)
require.Error(t, err)
require.Contains(t, err.Error(), "not found")
})
t.Run("Resync", func(t *testing.T) {
t.Parallel()
cm := &fakeContextManager{snapshot: agentcontext.Snapshot{Version: 3}}
socketPath := testutil.AgentSocketPath(t)
ctx := testutil.Context(t, testutil.WaitShort)
server, err := agentsocket.NewServer(
slog.Make().Leveled(slog.LevelDebug),
agentsocket.WithPath(socketPath),
agentsocket.WithContextManager(cm),
)
require.NoError(t, err)
defer server.Close()
client := newSocketClient(ctx, t, socketPath)
snap, err := client.ResyncContext(ctx)
require.NoError(t, err)
require.EqualValues(t, 3, snap.Version)
require.True(t, cm.resynced)
})
t.Run("NoManagerErrors", func(t *testing.T) {
t.Parallel()
socketPath := testutil.AgentSocketPath(t)
ctx := testutil.Context(t, testutil.WaitShort)
// No WithContextManager: the context RPCs must fail cleanly.
server, err := agentsocket.NewServer(
slog.Make().Leveled(slog.LevelDebug),
agentsocket.WithPath(socketPath),
)
require.NoError(t, err)
defer server.Close()
client := newSocketClient(ctx, t, socketPath)
// Every context RPC independently guards a nil context manager;
// exercise all of them so dropping a guard surfaces as a test
// failure rather than an agent-killing nil dereference in a DRPC
// handler.
_, err = client.ContextSources(ctx)
require.Error(t, err)
_, err = client.GetContextSource(ctx, "/tmp/x")
require.Error(t, err)
_, err = client.AddContextSource(ctx, "/tmp/x")
require.Error(t, err)
err = client.RemoveContextSource(ctx, "/tmp/x")
require.Error(t, err)
_, err = client.GetContextSnapshot(ctx)
require.Error(t, err)
_, err = client.ResyncContext(ctx)
require.Error(t, err)
})
}