mirror of
https://github.com/coder/coder.git
synced 2026-09-22 21:22:17 +08:00
## Problem
Workspace context surfaced in chat (Coder Agents) is incomplete and racy
on a fresh boot:
- The context panel is missing personal skills (only repo-level skills
under `.claude/skills` show up).
- The MCP section lists `.mcp.json` files but no MCP servers are
registered.
- The Issues panel reports instruction files as unreadable, e.g.
`CLAUDE.md (file: unreadable)` and `.cursorrules (file: unreadable)`
with `symlink resolve: lstat .../AGENTS.md: no such file or directory`.
## Root cause
`agentcontext.Manager` collected and pushed context too eagerly:
- `NewManager` ran an eager resolve at agent `init()`.
- `RunPush` starts as a normal connection routine (`startAgentAPI210`)
with no lifecycle gating, so the first snapshot was pushed
(`Initial=true`) as soon as the agent API connected.
Both happened **before startup scripts finish** and before the lifecycle
reaches `ready`. At that point:
- `CLAUDE.md` / `.cursorrules` symlinks to `AGENTS.md` don't resolve
yet, so `EvalSymlinks` fails and the resolver emits `StatusUnreadable`
"symlink resolve" issues.
- Personal skills haven't synced yet, so they're missing.
- MCP servers connect via `mcpManager.Reload(...)` only **after**
`ready`, so only `.mcp.json` configs appear, with no servers.
That partial, error-laden snapshot is persisted by coderd and can
hydrate a chat.
## Fix
Gate `agentcontext.Manager` until the agent is ready, unconditionally:
- The Manager always starts gated. `NewManager` leaves the zero-value
(version 0) snapshot in place and never walks the filesystem; `RunPush`
withholds version-0 snapshots, so nothing reaches coderd.
- The agent calls `Manager.SetReady()` from the lifecycle transition in
`handleManifest`, right after startup scripts finish (`ready`, or
terminal `start_error` / `start_timeout` so a failed startup still
surfaces whatever context exists).
- On `SetReady`, the Manager performs the first real resolve (version 1)
and broadcasts it; `RunPush` ships it with `Initial=true`. Later changes
(MCP connect, skill edits) re-resolve and push as before.
Eager resolution before `ready` was the bug, not a mode worth
preserving, so the gate is always on rather than an opt-in option. This
aligns the agent-side push with chatd, which already waits for agent
readiness before loading context. No proto/coderd/DB changes: coderd
simply never receives a pre-ready snapshot.
<details>
<summary>Design notes & decisions</summary>
- **Unconditional, not opt-in.** An earlier iteration added the gate as
an opt-in `ManagerOptions.GateUntilReady`. Since the eager
resolve-on-construct was the defect, the option, the eager first
resolve, and the now-dead `resolveLocked` helper were all removed; the
Manager is always gated until `SetReady`.
- **Version 0 is the pre-ready sentinel.** The gated placeholder is just
the zero-value snapshot (version 0); the first real resolve is version
1, so the push loop withholds anything at version 0. An earlier revision
carried a dedicated `Snapshot.Initializing` bool plus an HTTP `/resync`
field, but the push loop was the only consumer and nothing read the HTTP
field, so both were dropped.
- **Defer, don't retry symlinks.** Transient "unreadable" symlinks are
an artifact of collecting before checkout. Deferring until `ready` fixes
all three symptom classes at once and avoids masking genuine post-ready
errors (a broken symlink at `ready` is still reported).
- **Release on terminal startup states too** (`start_error`,
`start_timeout`), so a failed startup still surfaces whatever context
exists instead of gating forever. On reconnect the Manager instance is
reused and stays ready.
</details>
## Tests
- `agentcontext.TestManager_WithholdsCollectionUntilReady` simulates
collection running before startup finishes (broken `CLAUDE.md` /
`.cursorrules` -> `AGENTS.md` symlinks): asserts the gated snapshot is
the empty version-0 placeholder with no resources and no `unreadable`
issues, and that after `SetReady` (target now present) the inventory
resolves cleanly to a single instruction file with no spurious issues.
- `agentcontext.TestRunPush_WaitsForReady` asserts the push loop ships
nothing while gated even when content exists, then ships the full
inventory with `Initial=true` after `SetReady`.
- `agentcontext.TestManager_SetReadyIsIdempotent` covers the version-0
placeholder before ready, the single resolve to version 1 on `SetReady`,
and idempotency across repeated calls.
- Updated `agent.TestAgent_ContextStatePushed`: the first push now
already contains `AGENTS.md` with `Initial=true` and no `UNREADABLE`
resources (no pre-startup empty/partial push).
Validated on the changed packages: `go test -race
./agent/agentcontext/...`, `go test ./agent/ -run
TestAgent_ContextStatePushed`, `golangci-lint run`, `go vet`, `gofmt`
(all clean).
---
🤖 Generated by Coder Agents on behalf of @kylecarbs.
597 lines
18 KiB
Go
597 lines
18 KiB
Go
package agentcontext_test
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/coder/coder/v2/agent/agentcontext"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
// TestMain points the test binary's HOME (and USERPROFILE on
|
|
// Windows) at a fresh empty directory before any test runs.
|
|
// The package's built-in scan roots (~/.coder,
|
|
// ~/.coder/skills, ~/.claude/plugins/cache) canonicalize
|
|
// against this directory, so they resolve to non-existent
|
|
// paths and the resolver silently skips them. Without this,
|
|
// running the tests on a developer host pulls real Coder and
|
|
// Claude config files into snapshots and breaks every
|
|
// Len(Resources, N) assertion.
|
|
func TestMain(m *testing.M) {
|
|
home, err := os.MkdirTemp("", "agentcontext-test-home-")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
if err := os.Setenv("HOME", home); err != nil {
|
|
panic(err)
|
|
}
|
|
if runtime.GOOS == "windows" {
|
|
if err := os.Setenv("USERPROFILE", home); err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
code := m.Run()
|
|
_ = os.RemoveAll(home)
|
|
os.Exit(code)
|
|
}
|
|
|
|
func newTestManager(t *testing.T, opts agentcontext.ManagerOptions) *agentcontext.Manager {
|
|
t.Helper()
|
|
m := newPendingTestManager(t, opts)
|
|
// Most tests want the ready behavior; release the gate so the first
|
|
// snapshot is the resolved inventory at version 1. Gate tests use
|
|
// newPendingTestManager directly.
|
|
m.SetReady()
|
|
return m
|
|
}
|
|
|
|
// newPendingTestManager builds a gated Manager (first snapshot is the
|
|
// empty version-0 placeholder) for tests that drive SetReady
|
|
// themselves.
|
|
func newPendingTestManager(t *testing.T, opts agentcontext.ManagerOptions) *agentcontext.Manager {
|
|
t.Helper()
|
|
opts.Logger = testutil.Logger(t).Named("agentcontext-test")
|
|
m := agentcontext.NewManager(opts)
|
|
t.Cleanup(func() { _ = m.Close() })
|
|
return m
|
|
}
|
|
|
|
func TestManager_InitialSnapshotIsPopulated(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
mustWriteFile(t, filepath.Join(dir, "AGENTS.md"), "boot snapshot")
|
|
|
|
m := newTestManager(t, agentcontext.ManagerOptions{
|
|
WorkingDir: func() string { return dir },
|
|
})
|
|
|
|
snap := m.Snapshot()
|
|
require.Equal(t, uint64(1), snap.Version)
|
|
require.Len(t, snap.Resources, 1)
|
|
}
|
|
|
|
func TestManager_AddSourceTriggersResolve(t *testing.T) {
|
|
t.Parallel()
|
|
wd := testutil.TempDirResolved(t)
|
|
src := testutil.TempDirResolved(t)
|
|
mustWriteFile(t, filepath.Join(src, "AGENTS.md"), "from source")
|
|
|
|
m := newTestManager(t, agentcontext.ManagerOptions{
|
|
WorkingDir: func() string { return wd },
|
|
AllowedRoots: []string{wd, src},
|
|
})
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
go func() { _ = m.Run(ctx) }()
|
|
|
|
t.Cleanup(func() { _ = m.Close() })
|
|
|
|
// Subscribe before mutating so we observe the broadcast.
|
|
ch, unsub := m.SubscribeChanges()
|
|
defer unsub()
|
|
|
|
added, err := m.AddSource(agentcontext.Source{Path: src})
|
|
require.NoError(t, err)
|
|
require.Equal(t, src, added.Path)
|
|
|
|
select {
|
|
case <-ch:
|
|
case <-time.After(testutil.WaitShort):
|
|
t.Fatalf("expected a change broadcast after AddSource")
|
|
}
|
|
|
|
snap := m.Snapshot()
|
|
require.Greater(t, snap.Version, uint64(1))
|
|
|
|
found := false
|
|
for _, r := range snap.Resources {
|
|
if r.Kind == agentcontext.KindInstructionFile && r.SourcePath == src {
|
|
found = true
|
|
}
|
|
}
|
|
require.True(t, found, "expected AGENTS.md attributed to the user source")
|
|
}
|
|
|
|
func TestManager_AddSourceRejectsOutsideAllowedRoots(t *testing.T) {
|
|
t.Parallel()
|
|
wd := t.TempDir()
|
|
outside := t.TempDir()
|
|
|
|
m := newTestManager(t, agentcontext.ManagerOptions{
|
|
WorkingDir: func() string { return wd },
|
|
AllowedRoots: []string{wd},
|
|
})
|
|
|
|
_, err := m.AddSource(agentcontext.Source{Path: outside})
|
|
require.Error(t, err)
|
|
}
|
|
|
|
// TestManager_AddSourceAcceptsLateWorkingDir mirrors the agent's
|
|
// real boot order: AllowedRoots is configured before the
|
|
// manifest provides the workspace working directory. The Manager
|
|
// must consult WorkingDir on every check so paths under the
|
|
// resolved working dir validate once the manifest lands.
|
|
func TestManager_AddSourceAcceptsLateWorkingDir(t *testing.T) {
|
|
t.Parallel()
|
|
wd := t.TempDir()
|
|
var resolved atomic.Pointer[string]
|
|
m := newTestManager(t, agentcontext.ManagerOptions{
|
|
WorkingDir: func() string {
|
|
if p := resolved.Load(); p != nil {
|
|
return *p
|
|
}
|
|
return ""
|
|
},
|
|
AllowedRoots: []string{"/never-used-home"},
|
|
})
|
|
|
|
// Before the manifest "loads", workingDir is empty; sources
|
|
// under wd must be rejected.
|
|
_, err := m.AddSource(agentcontext.Source{Path: wd})
|
|
require.Error(t, err)
|
|
|
|
// After the manifest "loads", workingDir resolves and the
|
|
// same path validates without restarting the Manager.
|
|
resolved.Store(&wd)
|
|
_, err = m.AddSource(agentcontext.Source{Path: wd})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func TestManager_AddSourceIsIdempotent(t *testing.T) {
|
|
t.Parallel()
|
|
wd := t.TempDir()
|
|
src := t.TempDir()
|
|
|
|
m := newTestManager(t, agentcontext.ManagerOptions{
|
|
WorkingDir: func() string { return wd },
|
|
AllowedRoots: []string{wd, src},
|
|
})
|
|
|
|
added1, err := m.AddSource(agentcontext.Source{Path: src})
|
|
require.NoError(t, err)
|
|
added2, err := m.AddSource(agentcontext.Source{Path: src})
|
|
require.NoError(t, err)
|
|
require.Equal(t, added1.Path, added2.Path)
|
|
|
|
sources := m.Sources()
|
|
require.Len(t, sources, 1)
|
|
}
|
|
|
|
// TestManager_SourceIdentityIsLexicalAndStable verifies the same configured
|
|
// source added before and after its symlink target exists collapses to one
|
|
// source keyed by the lexical (configured) path, not the resolved target.
|
|
func TestManager_SourceIdentityIsLexicalAndStable(t *testing.T) {
|
|
t.Parallel()
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("symlinks require admin privileges on Windows runners")
|
|
}
|
|
root := testutil.TempDirResolved(t)
|
|
target := filepath.Join(root, "target")
|
|
link := filepath.Join(root, "link")
|
|
require.NoError(t, os.Symlink(target, link))
|
|
|
|
m := newTestManager(t, agentcontext.ManagerOptions{
|
|
WorkingDir: func() string { return root },
|
|
AllowedRoots: []string{root},
|
|
})
|
|
|
|
// Target missing: identity is the lexical link path.
|
|
added1, err := m.AddSource(agentcontext.Source{Path: link})
|
|
require.NoError(t, err)
|
|
require.Equal(t, link, added1.Path)
|
|
|
|
// Once the target exists the link resolves, but the same configured
|
|
// path must still dedupe to one source.
|
|
require.NoError(t, os.MkdirAll(target, 0o755))
|
|
added2, err := m.AddSource(agentcontext.Source{Path: link})
|
|
require.NoError(t, err)
|
|
require.Equal(t, link, added2.Path,
|
|
"expected the source identity to stay the lexical link path")
|
|
|
|
require.Len(t, m.Sources(), 1)
|
|
}
|
|
|
|
func TestManager_RemoveSource(t *testing.T) {
|
|
t.Parallel()
|
|
wd := t.TempDir()
|
|
src := t.TempDir()
|
|
|
|
m := newTestManager(t, agentcontext.ManagerOptions{
|
|
WorkingDir: func() string { return wd },
|
|
AllowedRoots: []string{wd, src},
|
|
})
|
|
|
|
_, err := m.AddSource(agentcontext.Source{Path: src})
|
|
require.NoError(t, err)
|
|
require.NoError(t, m.RemoveSource(src))
|
|
require.Empty(t, m.Sources())
|
|
|
|
err = m.RemoveSource(src)
|
|
require.ErrorIs(t, err, agentcontext.ErrSourceNotFound)
|
|
}
|
|
|
|
func TestManager_HasSource(t *testing.T) {
|
|
t.Parallel()
|
|
wd := t.TempDir()
|
|
src := testutil.TempDirResolved(t)
|
|
|
|
m := newTestManager(t, agentcontext.ManagerOptions{
|
|
WorkingDir: func() string { return wd },
|
|
AllowedRoots: []string{wd, src},
|
|
})
|
|
|
|
canonical, ok := m.HasSource(src)
|
|
require.False(t, ok)
|
|
require.Equal(t, src, canonical)
|
|
|
|
_, err := m.AddSource(agentcontext.Source{Path: src})
|
|
require.NoError(t, err)
|
|
|
|
canonical, ok = m.HasSource(src)
|
|
require.True(t, ok)
|
|
require.Equal(t, src, canonical)
|
|
}
|
|
|
|
func TestManager_ResyncReturnsLatestSnapshot(t *testing.T) {
|
|
t.Parallel()
|
|
wd := t.TempDir()
|
|
mustWriteFile(t, filepath.Join(wd, "AGENTS.md"), "first")
|
|
|
|
m := newTestManager(t, agentcontext.ManagerOptions{
|
|
WorkingDir: func() string { return wd },
|
|
})
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
runDone := make(chan struct{})
|
|
go func() {
|
|
defer close(runDone)
|
|
_ = m.Run(ctx)
|
|
}()
|
|
t.Cleanup(func() {
|
|
_ = m.Close()
|
|
<-runDone
|
|
})
|
|
|
|
// Mutate AGENTS.md and call Resync. The returned
|
|
// snapshot must reflect the new content.
|
|
require.NoError(t, os.WriteFile(filepath.Join(wd, "AGENTS.md"), []byte("second content edit"), 0o600))
|
|
|
|
snap, err := m.Resync(ctx)
|
|
require.NoError(t, err)
|
|
|
|
require.Len(t, snap.Resources, 1)
|
|
require.Equal(t, "second content edit", string(snap.Resources[0].Payload))
|
|
}
|
|
|
|
// TestManager_ResyncCanceledKeepsLiveSnapshot guards CRF-44:
|
|
// a context cancellation mid-walk must not replace the live
|
|
// Snapshot with an empty one. Resync returns the existing
|
|
// Snapshot and ctx.Err() instead of publishing a stub.
|
|
func TestManager_ResyncCanceledKeepsLiveSnapshot(t *testing.T) {
|
|
t.Parallel()
|
|
wd := t.TempDir()
|
|
mustWriteFile(t, filepath.Join(wd, "AGENTS.md"), "live content")
|
|
|
|
m := newTestManager(t, agentcontext.ManagerOptions{
|
|
WorkingDir: func() string { return wd },
|
|
})
|
|
|
|
// Capture the live snapshot the Manager populated at
|
|
// construction time.
|
|
live := m.Snapshot()
|
|
require.Len(t, live.Resources, 1)
|
|
require.Equal(t, "live content", string(live.Resources[0].Payload))
|
|
|
|
// Cancel the context before calling Resync so
|
|
// ResolveContext observes the cancellation.
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
snap, err := m.Resync(ctx)
|
|
require.ErrorIs(t, err, context.Canceled)
|
|
// The returned snapshot must still expose the live
|
|
// resources, not an empty result from the canceled walk.
|
|
require.Len(t, snap.Resources, 1)
|
|
require.Equal(t, "live content", string(snap.Resources[0].Payload))
|
|
|
|
// The next Snapshot call must also return live content;
|
|
// no stub was published.
|
|
after := m.Snapshot()
|
|
require.Equal(t, live.Version, after.Version)
|
|
require.Len(t, after.Resources, 1)
|
|
}
|
|
|
|
func TestManager_InitialSourcesSeeded(t *testing.T) {
|
|
t.Parallel()
|
|
wd := t.TempDir()
|
|
src := testutil.TempDirResolved(t)
|
|
mustWriteFile(t, filepath.Join(src, "AGENTS.md"), "from initial")
|
|
|
|
m := newTestManager(t, agentcontext.ManagerOptions{
|
|
WorkingDir: func() string { return wd },
|
|
AllowedRoots: []string{wd, src},
|
|
InitialSources: []agentcontext.Source{{Path: src}},
|
|
})
|
|
|
|
sources := m.Sources()
|
|
require.Len(t, sources, 1)
|
|
require.Equal(t, src, sources[0].Path)
|
|
|
|
snap := m.Snapshot()
|
|
require.Len(t, snap.Resources, 1)
|
|
require.Equal(t, src, snap.Resources[0].SourcePath)
|
|
}
|
|
|
|
// TestManager_SeedSourcesLateBindsAfterManifest models the
|
|
// agent's behavior when CODER_AGENT_EXP_*_DIRS contains a
|
|
// relative path that cannot resolve until the manifest's
|
|
// working directory lands. SeedSources must adopt the
|
|
// previously-unresolvable path, bypass AllowedRoots
|
|
// validation, and trigger a re-resolve.
|
|
func TestManager_SeedSourcesLateBindsAfterManifest(t *testing.T) {
|
|
t.Parallel()
|
|
wd := t.TempDir()
|
|
late := testutil.TempDirResolved(t)
|
|
mustWriteFile(t, filepath.Join(late, "AGENTS.md"), "late binding")
|
|
|
|
// AllowedRoots intentionally omits `late` so AddSource
|
|
// would reject it. SeedSources must accept it anyway,
|
|
// since the path comes from the trusted template config.
|
|
m := newTestManager(t, agentcontext.ManagerOptions{
|
|
WorkingDir: func() string { return wd },
|
|
AllowedRoots: []string{wd},
|
|
})
|
|
|
|
require.Empty(t, m.Sources())
|
|
|
|
m.SeedSources([]agentcontext.Source{{Path: late}})
|
|
|
|
sources := m.Sources()
|
|
require.Len(t, sources, 1)
|
|
require.Equal(t, late, sources[0].Path)
|
|
|
|
snap, err := m.Resync(testutil.Context(t, testutil.WaitShort))
|
|
require.NoError(t, err)
|
|
require.Len(t, snap.Resources, 1)
|
|
require.Equal(t, late, snap.Resources[0].SourcePath)
|
|
}
|
|
|
|
// TestManager_WithholdsCollectionUntilReady reproduces the boot-time
|
|
// race: collecting before startup finishes sees instruction-file
|
|
// symlinks (CLAUDE.md / .cursorrules -> AGENTS.md) with no target yet.
|
|
// The gated snapshot is the version-0 placeholder with no resources or
|
|
// errors; after SetReady the symlinks resolve cleanly.
|
|
func TestManager_WithholdsCollectionUntilReady(t *testing.T) {
|
|
t.Parallel()
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("symlinks require admin privileges on Windows runners")
|
|
}
|
|
dir := testutil.TempDirResolved(t)
|
|
// CLAUDE.md and .cursorrules symlink to an AGENTS.md that does not
|
|
// exist yet, so an eager resolve would report them unreadable.
|
|
require.NoError(t, os.Symlink(filepath.Join(dir, "AGENTS.md"), filepath.Join(dir, "CLAUDE.md")))
|
|
require.NoError(t, os.Symlink(filepath.Join(dir, "AGENTS.md"), filepath.Join(dir, ".cursorrules")))
|
|
|
|
m := newPendingTestManager(t, agentcontext.ManagerOptions{
|
|
WorkingDir: func() string { return dir },
|
|
})
|
|
|
|
// Before SetReady the snapshot is the version-0 placeholder: no
|
|
// resources and no errors. The broken symlinks are NOT reported.
|
|
snap := m.Snapshot()
|
|
require.Zero(t, snap.Version, "gated snapshot must be the version-0 placeholder")
|
|
require.Empty(t, snap.Resources, "gated snapshot must not collect a partial inventory")
|
|
require.Empty(t, snap.SnapshotError, "gated snapshot must not surface transient errors")
|
|
|
|
// Resync stays gated too, so callers see the version-0 placeholder
|
|
// instead of a partial result.
|
|
rs, err := m.Resync(testutil.Context(t, testutil.WaitShort))
|
|
require.NoError(t, err)
|
|
require.Zero(t, rs.Version)
|
|
require.Empty(t, rs.Resources)
|
|
|
|
// Startup finishes: AGENTS.md now exists, so the symlinks resolve.
|
|
mustWriteFile(t, filepath.Join(dir, "AGENTS.md"), "# rules\n")
|
|
|
|
// Release the gate. SetReady performs the first real resolve.
|
|
m.SetReady()
|
|
|
|
snap = m.Snapshot()
|
|
require.NotZero(t, snap.Version, "post-ready snapshot must be a real resolve")
|
|
require.NotEmpty(t, snap.Resources, "post-ready snapshot must include resolved files")
|
|
require.Empty(t, snap.SnapshotError)
|
|
var instr int
|
|
for _, r := range snap.Resources {
|
|
require.NotEqualf(t, agentcontext.StatusUnreadable, r.Status,
|
|
"resolved snapshot must not contain spurious unreadable issues: %s", r.Source)
|
|
if r.Kind == agentcontext.KindInstructionFile {
|
|
instr++
|
|
}
|
|
}
|
|
// AGENTS.md and the two symlinks that resolve to it collapse to a
|
|
// single instruction-file resource.
|
|
require.Equal(t, 1, instr)
|
|
}
|
|
|
|
// TestManager_SetReadyIsIdempotent verifies the first SetReady resolves
|
|
// once (version 1) and repeated calls neither panic nor re-resolve.
|
|
func TestManager_SetReadyIsIdempotent(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
mustWriteFile(t, filepath.Join(dir, "AGENTS.md"), "rules")
|
|
|
|
// Gated: first snapshot is the version-0 placeholder.
|
|
m := newPendingTestManager(t, agentcontext.ManagerOptions{
|
|
WorkingDir: func() string { return dir },
|
|
})
|
|
snap := m.Snapshot()
|
|
require.Zero(t, snap.Version)
|
|
require.Empty(t, snap.Resources)
|
|
|
|
// First SetReady resolves once: version 1 with the inventory.
|
|
m.SetReady()
|
|
snap = m.Snapshot()
|
|
require.Equal(t, uint64(1), snap.Version)
|
|
require.Len(t, snap.Resources, 1)
|
|
|
|
// Further calls are no-ops: version and inventory unchanged.
|
|
m.SetReady()
|
|
m.SetReady()
|
|
snap = m.Snapshot()
|
|
require.Equal(t, uint64(1), snap.Version)
|
|
require.Len(t, snap.Resources, 1)
|
|
}
|
|
|
|
func TestManager_CloseIsIdempotent(t *testing.T) {
|
|
t.Parallel()
|
|
m := newTestManager(t, agentcontext.ManagerOptions{
|
|
WorkingDir: func() string { return t.TempDir() },
|
|
})
|
|
require.NoError(t, m.Close())
|
|
require.NoError(t, m.Close())
|
|
}
|
|
|
|
func TestManager_RunOnce(t *testing.T) {
|
|
t.Parallel()
|
|
m := newTestManager(t, agentcontext.ManagerOptions{
|
|
WorkingDir: func() string { return t.TempDir() },
|
|
})
|
|
ctx, cancel := context.WithCancel(testutil.Context(t, testutil.WaitShort))
|
|
defer cancel()
|
|
go func() { _ = m.Run(ctx) }()
|
|
|
|
// Wait for Run to claim the running flag, then verify the
|
|
// second call rejects with a deterministic error rather than
|
|
// racing the scheduler.
|
|
select {
|
|
case <-agentcontext.ManagerStarted(m):
|
|
case <-ctx.Done():
|
|
t.Fatalf("manager never started: %v", ctx.Err())
|
|
}
|
|
|
|
err := m.Run(ctx)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "more than once")
|
|
cancel()
|
|
_ = m.Close()
|
|
}
|
|
|
|
func TestManager_SubscribeBroadcastOnChange(t *testing.T) {
|
|
t.Parallel()
|
|
wd := t.TempDir()
|
|
src := t.TempDir()
|
|
|
|
m := newTestManager(t, agentcontext.ManagerOptions{
|
|
WorkingDir: func() string { return wd },
|
|
AllowedRoots: []string{wd, src},
|
|
})
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
go func() { _ = m.Run(ctx) }()
|
|
|
|
ch, unsub := m.SubscribeChanges()
|
|
defer unsub()
|
|
|
|
_, err := m.AddSource(agentcontext.Source{Path: src})
|
|
require.NoError(t, err)
|
|
|
|
select {
|
|
case <-ch:
|
|
case <-time.After(testutil.WaitShort):
|
|
t.Fatal("expected subscriber to be notified")
|
|
}
|
|
}
|
|
|
|
// TestManager_MCPResourcesAppliesToSnapshot verifies that MCP resources
|
|
// supplied via the resolver contribute KindMCPServer resources (with
|
|
// their tools) to the resolved snapshot.
|
|
func TestManager_MCPResourcesAppliesToSnapshot(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
|
|
m := newTestManager(t, agentcontext.ManagerOptions{
|
|
WorkingDir: func() string { return dir },
|
|
Resolver: &agentcontext.Resolver{
|
|
MCPResources: func() []agentcontext.Resource {
|
|
return []agentcontext.Resource{{
|
|
ID: "mcp_server:fs",
|
|
Kind: agentcontext.KindMCPServer,
|
|
Source: "fs",
|
|
Name: "fs",
|
|
Status: agentcontext.StatusOK,
|
|
Tools: []agentcontext.MCPTool{{Name: "read", Description: "Read"}},
|
|
}}
|
|
},
|
|
},
|
|
})
|
|
|
|
snap := m.Snapshot()
|
|
var found bool
|
|
for _, r := range snap.Resources {
|
|
if r.Kind == agentcontext.KindMCPServer && r.Source == "fs" {
|
|
found = true
|
|
require.Len(t, r.Tools, 1)
|
|
require.Equal(t, "read", r.Tools[0].Name)
|
|
}
|
|
}
|
|
require.True(t, found, "expected MCP server resource in snapshot")
|
|
}
|
|
|
|
// TestManager_WorkingDirScannedShallow confirms the working
|
|
// directory is a single scan root: its top-level instruction files
|
|
// are read, but the resolver neither climbs to an ancestor (no
|
|
// walk-up to a .git project root) nor descends into subdirectories.
|
|
func TestManager_WorkingDirScannedShallow(t *testing.T) {
|
|
t.Parallel()
|
|
root := testutil.TempDirResolved(t)
|
|
require.NoError(t, os.MkdirAll(filepath.Join(root, ".git"), 0o755))
|
|
mustWriteFile(t, filepath.Join(root, "AGENTS.md"), "root rules")
|
|
cwd := filepath.Join(root, "service")
|
|
require.NoError(t, os.MkdirAll(cwd, 0o755))
|
|
mustWriteFile(t, filepath.Join(cwd, "AGENTS.md"), "service rules")
|
|
// A subdirectory below the working dir must not be descended.
|
|
mustWriteFile(t, filepath.Join(cwd, "nested", "AGENTS.md"), "nested rules")
|
|
|
|
m := newTestManager(t, agentcontext.ManagerOptions{
|
|
WorkingDir: func() string { return cwd },
|
|
})
|
|
|
|
snap := m.Snapshot()
|
|
var sources []string
|
|
for _, r := range snap.Resources {
|
|
if r.Kind == agentcontext.KindInstructionFile {
|
|
sources = append(sources, r.Source)
|
|
}
|
|
}
|
|
// Only the working directory's own AGENTS.md is present: the
|
|
// ancestor root and the nested subdirectory are both excluded.
|
|
require.Equal(t, []string{filepath.Join(cwd, "AGENTS.md")}, sources)
|
|
}
|