mirror of
https://github.com/coder/coder.git
synced 2026-10-29 17:44:33 +08:00
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._
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user