Files
coder/agent/agentcontext/resolve_test.go
T
Kyle Carberry cd3692c0c2 feat: add agent-side workspace context sources and Agent API v2.10 PushContextState (#25983)
Adds the agent half of the workspace context sources RFC. The agent now
resolves instruction files, skills, and MCP configs into a typed
`Snapshot`, watches the relevant paths recursively, exposes the source
list over a workspace-agent HTTP API, and pushes each `Snapshot` to
coderd over a new `PushContextState` RPC on Agent API v2.10.

The coderd-side handler is a stub returning `Unimplemented` for now.
Real persistence to `workspace_agent_context`, chatd hydration on dirty
events, and the `KindMCPServer` MCP provider are tracked by
[CODAGT-569](https://linear.app/codercom/issue/CODAGT-569/enable-agent-api-v210-pushcontextstate-bump-currentminor-wire-coderd).
This matches the pattern used for v2.7 `ReportBoundaryLogs` in
[#21293](https://github.com/coder/coder/pull/21293), which bumped the
version and shipped a stub server so the wire and client could iterate
before the persistence layer landed.

## What ships

### agent/agentcontext (new package)

- `Source`, `Resource` (kinds `instruction_file`, `skill`, `mcp_config`,
`mcp_server` plus reserved `plugin`/`hook`/`subagent`/`command`),
`ResourceStatus`, `Snapshot`, `ComputeAggregateHash`.
- `Manager` owns the in-memory source list, performs the initial resolve
synchronously in `NewManager`, runs a re-resolve/watcher loop in `Run`,
exposes
`AddSource`/`RemoveSource`/`Sources`/`HasSource`/`Snapshot`/`SubscribeChanges`/`Resync`/`SeedSources`/`Close`.
- `Resolver` walks scan roots, classifies recognized files, enforces 64
KiB per-resource, 2 MiB aggregate, and 500-resource caps with
`StatusOversize`/`StatusExcluded`/`StatusUnreadable`/`StatusInvalid`
outcomes, skips `node_modules`/`vendor`/etc., validates symlink targets
stay inside the scan root, stamps `SourcePath` on user-derived
resources, and optionally pulls MCP server tool lists via an
`MCPProvider` interface. MCP config resources ship metadata only (size,
hash) so secrets in env blocks never leave the agent.
- `Watcher` is a recursive `fsnotify` wrapper with a 250 ms debounce,
dynamic arming of newly created directories, and an ENOSPC-tolerant
degraded mode that no-ops further syncs until the manager resyncs
explicitly.
- HTTP API for `GET/POST /sources`, `GET/DELETE /sources/{path}`, `POST
/resync` mounted at `/api/v0/context`.
- `Pusher` interface plus `RunPush` goroutine with exponential backoff
capped at 30 s. `DRPCPusher` adapts the generated `DRPCAgentClient210`
to `Pusher` and translates `drpcerr.Unimplemented` to
`ErrPushUnimplemented` so the push loop exits cleanly when talking to
coderd deployments that have not enabled the real handler.

### agent/proto (v2.10)

- New messages `ContextResource`, `PushContextStateRequest`,
`PushContextStateResponse` and the `PushContextState` RPC on `service
Agent`.
- Generated `DRPCAgentClient210` interface and
`codersdk/agentsdk.Client.ConnectRPC210` / `ConnectRPC210WithRole`.
- `tailnet/proto.CurrentMinor` bumped from `9` to `10`.

### Agent wiring

- `agent.Options.Client` declares both v2.9 and v2.10 connectors;
`run()` dials with `ConnectRPC210WithRole`.
- `apiConnRoutineManager` holds a `DRPCAgentClient210`. Existing v2.8
routines keep their narrower `DRPCAgentClient28` signature thanks to
interface embedding.
- `startAgentAPI210` is the v2.10 counterpart to `startAgentAPI` for
routines that need the new client. The push context state routine uses
it.
- A `contextManager` is constructed in `agent.init()`, seeded from the
existing `CODER_AGENT_EXP_*_DIRS` env vars, started in its own goroutine
under `gracefulCtx`, and closed in `agent.Close`.
- `handleManifest` calls `Manager.SeedSources` for sources rooted at the
manifest directory, then `Resync` after `manifest.Swap`, so the snapshot
reflects the workspace working directory immediately instead of waiting
for the next filesystem event.
- HTTP routes mounted at `/api/v0/context` when the manager is up.

### Coderd stub

`coderd/agentapi/context.go` returns `drpcerr.Unimplemented` for
`PushContextState`. The real handler that persists
`workspace_agent_context` rows, hydrates chats, and emits dirty events
lives in CODAGT-569.

## Tests

24 tests across `agent/agentcontext` cover types, paths, resolver
behavior with file caps, skill containers, MCP secret omission, symlink
target validation, the recursive watcher firing on real fsnotify events,
manager source CRUD / `Resync` / `SeedSources` / `Run` lifetime, the
HTTP API, the DRPC adapter, and the push retry / initial-flag /
unimplemented paths. Passes `go test -race -count=2`.

`TestAgent_ContextStatePushed` boots a full agent against
`agenttest.FakeAgentAPI` (which now records `PushContextState` traffic)
and asserts the seeded `AGENTS.md` appears in a snapshot push with
`schema_version = 1`.

<details>
<summary>Notes for reviewers</summary>

- Source CRUD is workspace-agent-token only; coderd is not in the path
for source mutation.
- Per-resource cap 64 KiB, aggregate 2 MiB, count cap 500; resources
past the cap ship with `StatusExcluded` and an empty payload so the
aggregate hash still detects content edits. MCP-emitted resources
enforce both a per-provider count cap and the aggregate byte cap.
- Symlinks inside the scan root are followed; symlinks pointing outside
(or broken) are rejected with `StatusExcluded` so credentials reachable
via a stray symlink stay off the wire.
- The initial push gates `lifecycle = ready` in the eventual full
design. For this PR the `SeedSources` plus `handleManifest`-driven
`Resync` keeps the snapshot fresh; the live push loop ships now and
DRPCPusher translates the coderd `Unimplemented` stub into a clean exit.
- The `PLUGIN`/`HOOK`/`SUBAGENT`/`COMMAND` kinds are reserved in proto
and Go enums but unused; the Claude Code plugin resolver ships in a
follow-up that does not need a schema migration.
- Two follow-ups remain, both tracked by
[CODAGT-569](https://linear.app/codercom/issue/CODAGT-569/enable-agent-api-v210-pushcontextstate-bump-currentminor-wire-coderd):
(1) the chatd-side handler that persists snapshots and dirties chats;
(2) the `coder exp chat context` CLI command set for
`list`/`show`/`add`/`remove`/`refresh`.

</details>

_This PR was authored by Coder Agents on Kyle Carberry's behalf._
2026-06-08 12:08:40 -07:00

555 lines
18 KiB
Go

package agentcontext_test
import (
"crypto/sha256"
"os"
"path/filepath"
"runtime"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/agent/agentcontext"
)
func mustWriteFile(t *testing.T, path, content string) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
}
func mustWriteSkill(t *testing.T, dir, name, description string) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Join(dir, name), 0o755))
mustWriteFile(t, filepath.Join(dir, name, "SKILL.md"),
"---\nname: "+name+"\ndescription: "+description+"\n---\nSkill body for "+name)
}
func findResource(t *testing.T, resources []agentcontext.Resource, kind agentcontext.ResourceKind, source string) agentcontext.Resource {
t.Helper()
for _, r := range resources {
if r.Kind == kind && r.Source == source {
return r
}
}
t.Fatalf("resource not found: kind=%s source=%s", kind, source)
return agentcontext.Resource{}
}
func TestResolver_ProjectAGENTSFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
mustWriteFile(t, filepath.Join(dir, "AGENTS.md"), "# Project rules\n\nDo the thing.")
r := &agentcontext.Resolver{}
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
require.Len(t, snap.Resources, 1)
got := snap.Resources[0]
require.Equal(t, agentcontext.KindInstructionFile, got.Kind)
require.Equal(t, agentcontext.StatusOK, got.Status)
require.Equal(t, filepath.Join(dir, "AGENTS.md"), got.Source)
require.Contains(t, string(got.Payload), "Do the thing.")
require.Equal(t, "Project rules", got.Description)
require.NotEqual(t, [32]byte{}, got.ContentHash)
}
func TestResolver_CaseInsensitiveInstructionNames(t *testing.T) {
t.Parallel()
dir := t.TempDir()
mustWriteFile(t, filepath.Join(dir, "agents.md"), "lower\n")
mustWriteFile(t, filepath.Join(dir, "CLAUDE.md"), "claude\n")
r := &agentcontext.Resolver{}
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
require.Len(t, snap.Resources, 2)
}
func TestResolver_SkillsContainerEmitsEachSubdir(t *testing.T) {
t.Parallel()
dir := t.TempDir()
mustWriteSkill(t, filepath.Join(dir, ".agents", "skills"), "make-coffee", "Coffee skill")
mustWriteSkill(t, filepath.Join(dir, ".agents", "skills"), "fold-laundry", "Laundry skill")
r := &agentcontext.Resolver{}
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
var kinds []string
for _, res := range snap.Resources {
kinds = append(kinds, res.Kind.String()+":"+filepath.Base(res.Source))
}
require.ElementsMatch(t, []string{
"skill:make-coffee",
"skill:fold-laundry",
}, kinds)
}
func TestResolver_SkillNameMismatchInvalid(t *testing.T) {
t.Parallel()
dir := t.TempDir()
skillsDir := filepath.Join(dir, ".agents", "skills", "make-coffee")
require.NoError(t, os.MkdirAll(skillsDir, 0o755))
mustWriteFile(t, filepath.Join(skillsDir, "SKILL.md"),
"---\nname: drink-tea\ndescription: oops\n---\nBody")
r := &agentcontext.Resolver{}
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
require.Len(t, snap.Resources, 1)
got := snap.Resources[0]
require.Equal(t, agentcontext.KindSkill, got.Kind)
require.Equal(t, agentcontext.StatusInvalid, got.Status)
require.Contains(t, got.Error, "does not match directory")
}
// TestResolver_SkillNameNonKebabInvalid exercises the kebab-case
// validation branch in readSkillMeta. The skill name matches the
// parent directory (so the mismatch check passes) but contains
// characters that SkillNamePattern rejects. Without this test
// the kebab branch could be deleted and the suite would still
// pass.
func TestResolver_SkillNameNonKebabInvalid(t *testing.T) {
t.Parallel()
dir := t.TempDir()
skillDir := filepath.Join(dir, ".agents", "skills", "Make_Coffee")
require.NoError(t, os.MkdirAll(skillDir, 0o755))
mustWriteFile(t, filepath.Join(skillDir, "SKILL.md"),
"---\nname: Make_Coffee\ndescription: oops\n---\nBody")
r := &agentcontext.Resolver{}
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
require.Len(t, snap.Resources, 1)
got := snap.Resources[0]
require.Equal(t, agentcontext.KindSkill, got.Kind)
require.Equal(t, agentcontext.StatusInvalid, got.Status)
require.Contains(t, got.Error, "kebab-case")
}
func TestResolver_MCPConfigEmitted(t *testing.T) {
t.Parallel()
dir := t.TempDir()
contents := `{"mcpServers": {"github": {"env": {"GITHUB_TOKEN": "secret-token"}}}}`
mustWriteFile(t, filepath.Join(dir, ".mcp.json"), contents)
r := &agentcontext.Resolver{}
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
require.Len(t, snap.Resources, 1)
got := snap.Resources[0]
require.Equal(t, agentcontext.KindMCPConfig, got.Kind)
require.Equal(t, agentcontext.StatusOK, got.Status)
// The .mcp.json payload is intentionally not shipped:
// the file can contain secret-bearing Env/Headers values.
// Only the path + ContentHash are exposed, so consumers
// can detect changes without ever seeing the bytes.
require.Empty(t, got.Payload, "readMCPConfig must not include the file payload")
require.NotEqual(t, [32]byte{}, got.ContentHash, "readMCPConfig must populate ContentHash for change detection")
require.Equal(t, uint64(len(contents)), got.SizeBytes)
}
// TestResolver_SymlinkInsideScanRootAllowed exercises the
// monorepo case where AGENTS.md is symlinked to shared content
// inside the same workspace tree. The target lives under the
// scan root, so the resolver follows the symlink and emits the
// target bytes as if the symlink were a regular file.
func TestResolver_SymlinkInsideScanRootAllowed(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlinks require admin privileges on Windows runners")
}
t.Parallel()
dir := t.TempDir()
target := filepath.Join(dir, "docs", "AGENTS.md")
require.NoError(t, os.MkdirAll(filepath.Dir(target), 0o755))
mustWriteFile(t, target, "shared monorepo guidance")
link := filepath.Join(dir, "AGENTS.md")
require.NoError(t, os.Symlink(target, link))
r := &agentcontext.Resolver{}
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
require.Len(t, snap.Resources, 2)
var linked agentcontext.Resource
for _, res := range snap.Resources {
if res.Source == link {
linked = res
}
}
require.Equal(t, agentcontext.StatusOK, linked.Status)
require.Equal(t, "shared monorepo guidance", string(linked.Payload))
}
// TestResolver_SymlinkOutsideScanRootRejected guards the
// security boundary. A malicious workspace cannot ship a
// snapshot containing ~/.ssh/id_rsa or /etc/passwd by placing a
// symlink with that target at AGENTS.md, .mcp.json, or
// SKILL.md inside the scan root.
func TestResolver_SymlinkOutsideScanRootRejected(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlinks require admin privileges on Windows runners")
}
t.Parallel()
dir := t.TempDir()
secretDir := t.TempDir()
secret := filepath.Join(secretDir, "id_rsa")
mustWriteFile(t, secret, "-----BEGIN OPENSSH PRIVATE KEY-----")
link := filepath.Join(dir, "AGENTS.md")
require.NoError(t, os.Symlink(secret, link))
r := &agentcontext.Resolver{}
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
require.Len(t, snap.Resources, 1)
got := snap.Resources[0]
require.Equal(t, agentcontext.StatusInvalid, got.Status)
require.Empty(t, got.Payload, "escaping symlink target must not be shipped")
require.Contains(t, got.Error, "escapes scan root")
}
// TestResolver_BrokenSymlink emits Unreadable for a dangling
// link rather than crashing the walk.
func TestResolver_BrokenSymlink(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlinks require admin privileges on Windows runners")
}
t.Parallel()
dir := t.TempDir()
link := filepath.Join(dir, "AGENTS.md")
require.NoError(t, os.Symlink(filepath.Join(dir, "does-not-exist"), link))
r := &agentcontext.Resolver{}
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
require.Len(t, snap.Resources, 1)
require.Equal(t, agentcontext.StatusUnreadable, snap.Resources[0].Status)
}
func TestResolver_OversizeInstructionFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
// Write a file larger than the per-resource cap.
big := make([]byte, 200)
for i := range big {
big[i] = 'a'
}
mustWriteFile(t, filepath.Join(dir, "AGENTS.md"), string(big))
r := &agentcontext.Resolver{MaxResourceBytes: 100}
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
require.Len(t, snap.Resources, 1)
got := snap.Resources[0]
require.Equal(t, agentcontext.StatusOversize, got.Status)
require.Empty(t, got.Payload)
require.Equal(t, uint64(200), got.SizeBytes)
// Hash over capped slice is still populated so callers
// can detect "still oversize but content changed".
require.NotEqual(t, [32]byte{}, got.ContentHash)
}
func TestResolver_AggregateCapExcludes(t *testing.T) {
t.Parallel()
dir := t.TempDir()
mustWriteFile(t, filepath.Join(dir, "AGENTS.md"), "small")
subA := filepath.Join(dir, "a")
subB := filepath.Join(dir, "b")
mustWriteFile(t, filepath.Join(subA, "AGENTS.md"), "AAAA")
mustWriteFile(t, filepath.Join(subB, "AGENTS.md"), "BBBB")
// Aggregate cap of 9 bytes lets the first two through but
// excludes the third regardless of which order they
// appear.
r := &agentcontext.Resolver{MaxSnapshotBytes: 9}
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
var excluded int
for _, res := range snap.Resources {
if res.Status == agentcontext.StatusExcluded {
excluded++
}
}
require.Equal(t, 1, excluded)
}
func TestResolver_CountCapExcludes(t *testing.T) {
t.Parallel()
dir := t.TempDir()
for i := 0; i < 5; i++ {
sub := filepath.Join(dir, "dir", string('a'+rune(i)))
mustWriteFile(t, filepath.Join(sub, "AGENTS.md"), "x")
}
r := &agentcontext.Resolver{MaxResources: 3}
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
require.Len(t, snap.Resources, 5)
var excluded int
for _, res := range snap.Resources {
if res.Status == agentcontext.StatusExcluded {
excluded++
}
}
require.Equal(t, 2, excluded)
}
func TestResolver_SkipsVendorAndNodeModules(t *testing.T) {
t.Parallel()
dir := t.TempDir()
mustWriteFile(t, filepath.Join(dir, "AGENTS.md"), "root")
mustWriteFile(t, filepath.Join(dir, "node_modules", "deep", "AGENTS.md"), "should not appear")
mustWriteFile(t, filepath.Join(dir, "vendor", "AGENTS.md"), "should not appear either")
r := &agentcontext.Resolver{}
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
require.Len(t, snap.Resources, 1)
require.Equal(t, filepath.Join(dir, "AGENTS.md"), snap.Resources[0].Source)
}
func TestResolver_UserSourceAttribution(t *testing.T) {
t.Parallel()
dir := t.TempDir()
mustWriteFile(t, filepath.Join(dir, "AGENTS.md"), "user-added")
r := &agentcontext.Resolver{}
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir, UserSource: dir}})
require.Len(t, snap.Resources, 1)
require.Equal(t, dir, snap.Resources[0].SourcePath)
}
func TestResolver_MissingRootSilentlyIgnored(t *testing.T) {
t.Parallel()
r := &agentcontext.Resolver{}
snap := r.Resolve([]agentcontext.ScanRoot{{Path: "/nonexistent/path"}})
require.Empty(t, snap.Resources)
require.Empty(t, snap.SnapshotError)
}
func TestResolver_SingleFileRootClassified(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "AGENTS.md")
mustWriteFile(t, path, "x")
r := &agentcontext.Resolver{}
snap := r.Resolve([]agentcontext.ScanRoot{{Path: path}})
require.Len(t, snap.Resources, 1)
require.Equal(t, agentcontext.KindInstructionFile, snap.Resources[0].Kind)
}
func TestResolver_DuplicateRootsDeduplicated(t *testing.T) {
t.Parallel()
dir := t.TempDir()
mustWriteFile(t, filepath.Join(dir, "AGENTS.md"), "x")
r := &agentcontext.Resolver{}
snap := r.Resolve([]agentcontext.ScanRoot{
{Path: dir},
{Path: dir},
{Path: dir},
})
require.Len(t, snap.Resources, 1)
}
func TestResolver_MCPProviderResources(t *testing.T) {
t.Parallel()
dir := t.TempDir()
mcpRes := agentcontext.Resource{
ID: "mcp_server:github",
Kind: agentcontext.KindMCPServer,
Source: "github",
Status: agentcontext.StatusOK,
Payload: []byte("tool-list-json"),
ContentHash: sha256.Sum256([]byte("tool-list-json")),
Description: "GitHub MCP server",
}
r := &agentcontext.Resolver{
MCP: &fakeMCPProvider{resources: []agentcontext.Resource{mcpRes}},
}
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
got := findResource(t, snap.Resources, agentcontext.KindMCPServer, "github")
require.Equal(t, agentcontext.StatusOK, got.Status)
require.Equal(t, "GitHub MCP server", got.Description)
}
// TestResolver_MCPProviderRespectsAggregateByteCap guards the
// contract that a single oversized MCP payload cannot blow past
// MaxSnapshotBytes with StatusOK.
func TestResolver_MCPProviderRespectsAggregateByteCap(t *testing.T) {
t.Parallel()
dir := t.TempDir()
big := make([]byte, 1024)
for i := range big {
big[i] = 'x'
}
mcpRes := agentcontext.Resource{
ID: "mcp_server:big",
Kind: agentcontext.KindMCPServer,
Source: "big",
Status: agentcontext.StatusOK,
Payload: big,
ContentHash: sha256.Sum256(big),
}
r := &agentcontext.Resolver{
MaxSnapshotBytes: 512,
MCP: &fakeMCPProvider{resources: []agentcontext.Resource{mcpRes}},
}
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
got := findResource(t, snap.Resources, agentcontext.KindMCPServer, "big")
require.Equal(t, agentcontext.StatusExcluded, got.Status,
"MCP payload exceeding MaxSnapshotBytes must be excluded")
require.Empty(t, got.Payload)
require.NotEmpty(t, snap.SnapshotError, "snapshot must surface the cap breach")
}
type fakeMCPProvider struct {
resources []agentcontext.Resource
}
func (f *fakeMCPProvider) MCPResources() []agentcontext.Resource {
return f.resources
}
// TestResolver_UnreadableInstructionFile verifies the
// permission-denied walk path produces a StatusUnreadable
// resource classified with the correct kind, matching the
// classification the resolver would emit on a successful read.
func TestResolver_UnreadableInstructionFile(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("file mode 0o000 does not deny reads on Windows")
}
if os.Geteuid() == 0 {
t.Skip("root bypasses file mode permissions")
}
dir := t.TempDir()
path := filepath.Join(dir, "AGENTS.md")
mustWriteFile(t, path, "hello")
require.NoError(t, os.Chmod(path, 0o000))
t.Cleanup(func() { _ = os.Chmod(path, 0o600) })
r := &agentcontext.Resolver{}
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
require.Len(t, snap.Resources, 1)
got := snap.Resources[0]
require.Equal(t, agentcontext.KindInstructionFile, got.Kind)
require.Equal(t, agentcontext.StatusUnreadable, got.Status)
require.NotEmpty(t, got.Error)
}
// TestResolver_UnreadableMCPConfig confirms the walk-error path
// uses the file's real kind, not a hardcoded fallback. Without
// this, a permission flip on .mcp.json would produce a phantom
// resource ID swap when the permission is later restored.
func TestResolver_UnreadableMCPConfig(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("file mode 0o000 does not deny reads on Windows")
}
if os.Geteuid() == 0 {
t.Skip("root bypasses file mode permissions")
}
dir := t.TempDir()
path := filepath.Join(dir, ".mcp.json")
mustWriteFile(t, path, `{"mcpServers": {}}`)
require.NoError(t, os.Chmod(path, 0o000))
t.Cleanup(func() { _ = os.Chmod(path, 0o600) })
r := &agentcontext.Resolver{}
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
require.Len(t, snap.Resources, 1)
got := snap.Resources[0]
require.Equal(t, agentcontext.KindMCPConfig, got.Kind)
require.Equal(t, agentcontext.StatusUnreadable, got.Status)
require.NotEmpty(t, got.Error)
}
func TestResourceKindString(t *testing.T) {
t.Parallel()
tests := []struct {
kind agentcontext.ResourceKind
want string
}{
{agentcontext.KindUnspecified, "unknown"},
{agentcontext.KindInstructionFile, "instruction_file"},
{agentcontext.KindSkill, "skill"},
{agentcontext.KindMCPConfig, "mcp_config"},
{agentcontext.KindMCPServer, "mcp_server"},
{agentcontext.KindPlugin, "plugin"},
{agentcontext.KindHook, "hook"},
{agentcontext.KindSubagent, "subagent"},
{agentcontext.KindCommand, "command"},
{agentcontext.ResourceKind(999), "unknown"},
}
for _, tt := range tests {
require.Equal(t, tt.want, tt.kind.String())
}
}
func TestResourceStatusString(t *testing.T) {
t.Parallel()
tests := []struct {
status agentcontext.ResourceStatus
want string
}{
{agentcontext.StatusOK, "ok"},
{agentcontext.StatusOversize, "oversize"},
{agentcontext.StatusUnreadable, "unreadable"},
{agentcontext.StatusInvalid, "invalid"},
{agentcontext.StatusExcluded, "excluded"},
{agentcontext.ResourceStatus(999), "unknown"},
}
for _, tt := range tests {
require.Equal(t, tt.want, tt.status.String())
}
}
func TestComputeAggregateHash_DeterministicAcrossOrder(t *testing.T) {
t.Parallel()
a := agentcontext.Resource{
ID: "instruction_file:/a/AGENTS.md",
Kind: agentcontext.KindInstructionFile,
Source: "/a/AGENTS.md",
Status: agentcontext.StatusOK,
}
b := agentcontext.Resource{
ID: "instruction_file:/b/AGENTS.md",
Kind: agentcontext.KindInstructionFile,
Source: "/b/AGENTS.md",
Status: agentcontext.StatusOK,
}
got1 := agentcontext.ComputeAggregateHash([]agentcontext.Resource{a, b})
got2 := agentcontext.ComputeAggregateHash([]agentcontext.Resource{b, a})
require.Equal(t, got1, got2)
}
func TestComputeAggregateHash_ChangesOnContent(t *testing.T) {
t.Parallel()
base := agentcontext.Resource{
ID: "instruction_file:/a/AGENTS.md",
Kind: agentcontext.KindInstructionFile,
Source: "/a/AGENTS.md",
Status: agentcontext.StatusOK,
}
hash1 := agentcontext.ComputeAggregateHash([]agentcontext.Resource{base})
withContent := base
withContent.ContentHash = [32]byte{0x01}
hash2 := agentcontext.ComputeAggregateHash([]agentcontext.Resource{withContent})
require.NotEqual(t, hash1, hash2)
withStatus := base
withStatus.Status = agentcontext.StatusOversize
hash3 := agentcontext.ComputeAggregateHash([]agentcontext.Resource{withStatus})
require.NotEqual(t, hash1, hash3)
}