mirror of
https://github.com/coder/coder.git
synced 2026-06-04 21:48:22 +00:00
919dc299fc
Piggybacks on #23878. Moves instruction file reading and skill discovery from `chatd` (server-side, via multiple `LS`/`ReadFile` round-trips through the agent connection) to the agent itself (local filesystem access). This intentionally drops backward compatibility with older agents that don't support the context-config endpoint. Agents and server are deployed together; there is no rolling-update contract to maintain here. ## What changed The agent's `GET /api/v0/context-config` response now returns `[]ChatMessagePart` directly — the same types chatd persists. This eliminates intermediate type conversions and makes the protocol extensible. | Field | Type | Description | |---|---|---| | `parts` | `[]ChatMessagePart` | Context-file and skill parts, ready to persist | | `working_dir` | `string` | Agent's resolved working directory | Removed from the response: `instructions_dirs`, `instructions_file`, `skills_dirs`, `skill_meta_file`, `mcp_config_files` — the agent reads files locally and returns their content as parts. Removed from chatd: all legacy `LS`/`ReadFile` fallback code (`readHomeInstructionFile`, `readInstructionDirFile`, `DiscoverSkills` via LS, etc). ## Why The previous architecture had the agent resolve paths, serve them over HTTP, then `chatd` make N+1 round-trips back through the agent connection to read files. The agent has direct filesystem access and should just read the files. ## Key design decisions - **Agent returns `ChatMessagePart` directly** — same types chatd persists. No intermediate `InstructionFileEntry`/`SkillEntry` types needed. - **`SkillMeta.MetaFile`** — persisted via `ContextFileSkillMetaFile` on the skill part, so custom meta file names (`CODER_AGENT_EXP_SKILL_META_FILE`) survive across chat turns. - **No pre-read body** — `read_skill` always dials the workspace to fetch the skill body on demand. Simpler than caching the body in the response. - **MCP config paths kept agent-internal** — `MCPConfigFiles()` getter, not sent over the wire. - **No backward compat fallback** — old agents that don't support context-config get no instruction files. This is acceptable since agent and server deploy together.
97 lines
3.3 KiB
Go
97 lines
3.3 KiB
Go
package agent
|
|
|
|
import (
|
|
"path/filepath"
|
|
"runtime"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"cdr.dev/slog/v3/sloggers/slogtest"
|
|
"github.com/coder/coder/v2/agent/agentcontextconfig"
|
|
"github.com/coder/coder/v2/agent/proto"
|
|
agentsdk "github.com/coder/coder/v2/codersdk/agentsdk"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
// platformAbsPath constructs an absolute path that is valid
|
|
// on the current platform. On Windows, paths must include a
|
|
// drive letter to be considered absolute.
|
|
func platformAbsPath(parts ...string) string {
|
|
if runtime.GOOS == "windows" {
|
|
return `C:\` + filepath.Join(parts...)
|
|
}
|
|
return "/" + filepath.Join(parts...)
|
|
}
|
|
|
|
// TestReportConnectionEmpty tests that reportConnection() doesn't choke if given an empty IP string, which is what we
|
|
// send if we cannot get the remote address.
|
|
func TestReportConnectionEmpty(t *testing.T) {
|
|
t.Parallel()
|
|
connID := uuid.UUID{1}
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
uut := &agent{
|
|
hardCtx: ctx,
|
|
logger: logger,
|
|
}
|
|
disconnected := uut.reportConnection(connID, proto.Connection_TYPE_UNSPECIFIED, "")
|
|
|
|
require.Len(t, uut.reportConnections, 1)
|
|
req0 := uut.reportConnections[0]
|
|
require.Equal(t, proto.Connection_TYPE_UNSPECIFIED, req0.GetConnection().GetType())
|
|
require.Equal(t, "", req0.GetConnection().Ip)
|
|
require.Equal(t, connID[:], req0.GetConnection().GetId())
|
|
require.Equal(t, proto.Connection_CONNECT, req0.GetConnection().GetAction())
|
|
|
|
disconnected(0, "because")
|
|
require.Len(t, uut.reportConnections, 2)
|
|
req1 := uut.reportConnections[1]
|
|
require.Equal(t, proto.Connection_TYPE_UNSPECIFIED, req1.GetConnection().GetType())
|
|
require.Equal(t, "", req1.GetConnection().Ip)
|
|
require.Equal(t, connID[:], req1.GetConnection().GetId())
|
|
require.Equal(t, proto.Connection_DISCONNECT, req1.GetConnection().GetAction())
|
|
require.Equal(t, "because", req1.GetConnection().GetReason())
|
|
}
|
|
|
|
func TestContextConfigAPI_InitOnce(t *testing.T) {
|
|
// Not parallel: uses t.Setenv to clear env vars.
|
|
|
|
// Clear env vars so defaults are used and the test is
|
|
// hermetic regardless of the surrounding environment.
|
|
t.Setenv(agentcontextconfig.EnvInstructionsDirs, "")
|
|
t.Setenv(agentcontextconfig.EnvInstructionsFile, "")
|
|
t.Setenv(agentcontextconfig.EnvSkillsDirs, "")
|
|
t.Setenv(agentcontextconfig.EnvSkillMetaFile, "")
|
|
t.Setenv(agentcontextconfig.EnvMCPConfigFiles, "")
|
|
|
|
// After the fix, contextConfigAPI is set once in init() and
|
|
// never reassigned. Config() evaluates lazily via the
|
|
// manifest, so there is no concurrent write to race with.
|
|
dir1 := platformAbsPath("dir1")
|
|
dir2 := platformAbsPath("dir2")
|
|
|
|
a := &agent{}
|
|
a.manifest.Store(&agentsdk.Manifest{Directory: dir1})
|
|
a.contextConfigAPI = agentcontextconfig.NewAPI(func() string {
|
|
if m := a.manifest.Load(); m != nil {
|
|
return m.Directory
|
|
}
|
|
return ""
|
|
})
|
|
|
|
mcpFiles1 := a.contextConfigAPI.MCPConfigFiles()
|
|
require.NotEmpty(t, mcpFiles1)
|
|
require.Contains(t, mcpFiles1[0], dir1)
|
|
|
|
// Simulate manifest update on reconnection -- no field
|
|
// reassignment needed, the lazy closure picks it up.
|
|
a.manifest.Store(&agentsdk.Manifest{Directory: dir2})
|
|
mcpFiles2 := a.contextConfigAPI.MCPConfigFiles()
|
|
require.NotEmpty(t, mcpFiles2)
|
|
require.Contains(t, mcpFiles2[0], dir2)
|
|
}
|