diff --git a/agent/agent.go b/agent/agent.go index c8a62fd2da54c..f0196b933ba7e 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -513,7 +513,16 @@ func (a *agent) init() { Clock: a.clock, WorkingDir: workingDirFn, InitialSources: initialContextSources(a.contextConfig, workingDirFn), + // Surface live MCP servers (their tools, and any that + // failed to connect) in the snapshot by reading the MCP + // manager's per-server health on every resolve. + MCP: mcpContextProvider{cachedServers: a.mcpManager.CachedServers}, }) + // Re-resolve the context snapshot whenever the MCP tool set + // changes (e.g. a .mcp.json edit reconnects servers) so MCP + // server resources track the live tools. Wired after both + // managers exist; the MCP manager fires this outside its lock. + a.mcpManager.SetOnToolsChanged(a.contextManager.Trigger) a.contextAPI = agentcontext.NewAPI(a.contextManager) a.reconnectingPTYServer = reconnectingpty.NewServer( a.logger.Named("reconnecting-pty"), @@ -554,6 +563,7 @@ func (a *agent) initSocketServer() { server, err := agentsocket.NewServer( a.logger.Named("socket"), agentsocket.WithPath(a.socketPath), + agentsocket.WithContextManager(a.contextManager), ) if err != nil { a.logger.Error(a.hardCtx, "failed to create socket server", slog.Error(err), slog.F("path", a.socketPath)) @@ -1545,8 +1555,18 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context, // This runs inside the tracked goroutine so it // is properly awaited on shutdown. a.mcpManager.MarkStartupSettled() - if mcpErr := a.mcpManager.Reload(a.gracefulCtx, a.contextConfigAPI.MCPConfigFiles()); mcpErr != nil { - a.logger.Warn(ctx, "failed to reload workspace MCP servers", slog.Error(mcpErr)) + // Keep the MCP manager's connected servers in sync + // with the .mcp.json files the context resolver + // discovers: the manifest working directory plus any + // context sources added at runtime. A .mcp.json that + // lives in an added source (not the working dir) + // would otherwise never be connected. Sources can be + // added after startup, so this watches for the agent's + // lifetime in its own tracked goroutine. + if mcpErr := a.trackGoroutine(func() { + a.syncMCPServersFromContext(a.gracefulCtx) + }); mcpErr != nil { + a.logger.Warn(ctx, "failed to start workspace MCP context sync", slog.Error(mcpErr)) } }) if err != nil { @@ -1557,6 +1577,76 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context, } } +// syncMCPServersFromContext keeps the MCP manager's connected servers +// in sync with the .mcp.json files the context resolver discovers. The +// resolver scans the manifest working directory, built-in roots, and any +// context sources added at runtime, so a .mcp.json contributed by an +// added source (rather than living in the working directory) is +// connected too. It reloads on every context-snapshot change, deduping +// by the discovered path set so unrelated snapshot churn (e.g. MCP tool +// updates, which also bump the snapshot) does not cause reload churn. +func (a *agent) syncMCPServersFromContext(ctx context.Context) { + changes, unsubscribe := a.contextManager.SubscribeChanges() + defer unsubscribe() + + // The statically configured MCP paths (manifest working directory + // plus CODER_AGENT_EXP_MCP_CONFIG_FILES) are stable for the agent's + // lifetime once startup has settled, so resolve them once. + configured := a.contextConfigAPI.MCPConfigFiles() + + var last []string + reload := func() { + paths := mcpConfigPaths(configured, a.contextManager.Snapshot()) + if slices.Equal(paths, last) { + return + } + last = paths + if err := a.mcpManager.Reload(ctx, paths); err != nil { + a.logger.Warn(ctx, "failed to reload workspace MCP servers", slog.Error(err)) + } + } + + // Pick up any sources discovered before we subscribed. + reload() + for { + select { + case <-ctx.Done(): + return + case <-changes: + reload() + } + } +} + +// mcpConfigPaths unions the statically configured MCP config files with +// the .mcp.json files the resolver discovered in the snapshot (working +// directory, built-in roots, and runtime-managed context sources) and +// returns a sorted, deduplicated list. The union keeps legacy +// manifest-directory discovery working while also connecting servers +// declared in .mcp.json files contributed by added context sources. +func mcpConfigPaths(configured []string, snap agentcontext.Snapshot) []string { + seen := make(map[string]struct{}, len(configured)+len(snap.Resources)) + add := func(p string) { + if p != "" { + seen[p] = struct{}{} + } + } + for _, p := range configured { + add(p) + } + for _, r := range snap.Resources { + if r.Kind == agentcontext.KindMCPConfig { + add(r.Source) + } + } + paths := make([]string, 0, len(seen)) + for p := range seen { + paths = append(paths, p) + } + slices.Sort(paths) + return paths +} + func (a *agent) createDevcontainer( ctx context.Context, aAPI proto.DRPCAgentClient28, diff --git a/agent/agentcontext/manager.go b/agent/agentcontext/manager.go index decefdd1c4e93..51c3d79181a47 100644 --- a/agent/agentcontext/manager.go +++ b/agent/agentcontext/manager.go @@ -38,6 +38,12 @@ type ManagerOptions struct { // Tests use this to inject MCP providers and tighten // caps. Resolver *Resolver + // MCP, when non-nil, supplies live MCP server resources to + // the resolver. It is applied to the resolver (default or + // injected via Resolver) so MCP servers participate in + // every snapshot. An MCP set directly on an injected + // Resolver takes precedence. + MCP MCPProvider // Debounce overrides the watcher's debounce window. Debounce time.Duration } @@ -118,6 +124,11 @@ func NewManager(opts ManagerOptions) *Manager { if resolver == nil { resolver = &Resolver{} } + // Apply the MCP provider to whichever resolver is used, unless + // an injected resolver already set one (which takes precedence). + if opts.MCP != nil && resolver.MCP == nil { + resolver.MCP = opts.MCP + } m := &Manager{ logger: opts.Logger, diff --git a/agent/agentcontext/manager_test.go b/agent/agentcontext/manager_test.go index ce96490a56bf2..ed921ba1954ed 100644 --- a/agent/agentcontext/manager_test.go +++ b/agent/agentcontext/manager_test.go @@ -395,3 +395,34 @@ func TestManager_SubscribeBroadcastOnChange(t *testing.T) { t.Fatal("expected subscriber to be notified") } } + +// TestManager_MCPProviderOptionAppliesToSnapshot verifies that an MCP +// provider supplied via ManagerOptions.MCP contributes KindMCPServer +// resources (with their tools) to the resolved snapshot. +func TestManager_MCPProviderOptionAppliesToSnapshot(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + m := newTestManager(t, agentcontext.ManagerOptions{ + WorkingDir: func() string { return dir }, + MCP: &fakeMCPProvider{resources: []agentcontext.Resource{{ + ID: "mcp_server:fs", + Kind: agentcontext.KindMCPServer, + Source: "fs", + Name: "fs", + Status: agentcontext.StatusOK, + Tools: []agentcontext.MCPTool{{Name: "fs__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, "fs__read", r.Tools[0].Name) + } + } + require.True(t, found, "expected MCP server resource in snapshot") +} diff --git a/agent/agentcontext/resolve.go b/agent/agentcontext/resolve.go index d55680b8dd204..96b1b76da1f86 100644 --- a/agent/agentcontext/resolve.go +++ b/agent/agentcontext/resolve.go @@ -1,8 +1,10 @@ package agentcontext import ( + "bytes" "context" "crypto/sha256" + "encoding/json" "errors" "fmt" "io" @@ -15,6 +17,8 @@ import ( "strconv" "strings" + "golang.org/x/xerrors" + "github.com/coder/coder/v2/codersdk/workspacesdk" ) @@ -164,7 +168,10 @@ func (r *Resolver) ResolveContext(ctx context.Context, roots []ScanRoot) Snapsho payloadBytes += uint64(len(r.Payload)) } - hash := ComputeAggregateHash(resources) + // The drift hash covers only pinned prompt content; MCP resources are + // excluded (see driftResources). Snapshot.Resources still carries the + // full set so MCP servers stay visible in the chat-context UI. + hash := ComputeAggregateHash(driftResources(resources)) snap := Snapshot{ Resources: resources, @@ -472,9 +479,46 @@ func (r *Resolver) readMCPConfig(scanRoot, path string, info fs.FileInfo, userSo return res } res.ContentHash = sha256.Sum256(data) + // A .mcp.json with broken JSON yields no MCP servers at all; the + // agentmcp manager logs and skips it, so the failure is otherwise + // invisible. Flag structural problems here as StatusInvalid so the + // chat context surfaces them as an issue rather than silently + // dropping every server in the file. + if err := validateMCPConfig(data); err != nil { + res.Status = StatusInvalid + res.Error = err.Error() + } return res } +// validateMCPConfig performs lightweight structural validation of a +// .mcp.json document so syntactically broken files surface as +// StatusInvalid instead of silently producing no MCP servers. It is +// deliberately self-contained and does not import the agentmcp +// package: it only checks that the document is valid JSON shaped like +// {"mcpServers": {: {...}}}. Individual server fields +// (command/url/env/...) are not validated here; the MCP manager owns +// that when it connects. An absent or empty mcpServers map is valid. +func validateMCPConfig(data []byte) error { + var shape struct { + MCPServers map[string]json.RawMessage `json:"mcpServers"` + } + if err := json.Unmarshal(data, &shape); err != nil { + return err + } + // Each server entry must be a JSON object; a scalar or array + // entry is a structural error the MCP manager would reject. + // The top-level Unmarshal above already rejects malformed JSON, + // so a well-formed value starting with '{' is a complete object. + for name, raw := range shape.MCPServers { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || trimmed[0] != '{' { + return xerrors.Errorf("server %q must be a JSON object", name) + } + } + return nil +} + // readFileResource is the shared plumbing for kinds whose only // difference is the enum stamped on the Resource: build the // Resource header, enforce the per-resource size cap, read the @@ -938,8 +982,12 @@ type Snapshot struct { Version uint64 // AggregateHash is sha256 over a canonical encoding of // (ID, Kind, Source, ContentHash, Status) for every - // resource. Identical inputs always produce identical - // hashes; see ComputeAggregateHash. + // drift-relevant resource. MCP resources (KindMCPConfig and + // KindMCPServer) are excluded because they describe live, + // agent-global runtime capabilities discovered at turn time, + // not pinned prompt content; see driftResources. Identical + // inputs always produce identical hashes; see + // ComputeAggregateHash. AggregateHash [32]byte // Resources is sorted by ID for deterministic encoding. Resources []Resource @@ -952,6 +1000,28 @@ type Snapshot struct { SnapshotError string } +// driftResources returns the subset of resources that participate in +// chat-context drift detection. MCP resources (the .mcp.json config and +// connected MCP servers) are deliberately excluded: an agent connects to +// its MCP servers asynchronously after startup, and the chat model +// discovers their tools live at turn time (coderd's ListMCPTools path), +// not from pinned prompt content. Hashing them would dirty an +// already-hydrated chat the moment a server finished connecting, even +// though nothing the user pinned changed. Instruction files and skills, +// whose content is pinned into the chat, stay drift-relevant. +func driftResources(resources []Resource) []Resource { + out := make([]Resource, 0, len(resources)) + for _, r := range resources { + switch r.Kind { + case KindMCPConfig, KindMCPServer: + continue + default: + out = append(out, r) + } + } + return out +} + // ComputeAggregateHash produces the deterministic snapshot // aggregate hash for the supplied resources. The caller does // not need to pre-sort; the function sorts a copy of the slice diff --git a/agent/agentcontext/resolve_test.go b/agent/agentcontext/resolve_test.go index aa7d6090a72c1..67d055be52d95 100644 --- a/agent/agentcontext/resolve_test.go +++ b/agent/agentcontext/resolve_test.go @@ -149,6 +149,57 @@ func TestResolver_MCPConfigEmitted(t *testing.T) { require.Equal(t, uint64(len(contents)), got.SizeBytes) } +// TestResolver_MCPConfigValidation confirms that a structurally +// broken .mcp.json surfaces as StatusInvalid (so the chat context +// shows it as an issue) while well-formed configs stay StatusOK. +// The resolver intentionally validates only JSON shape, not +// individual server fields, which the MCP manager owns. +func TestResolver_MCPConfigValidation(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + contents string + wantOK bool + }{ + {name: "ValidObjectEntry", contents: `{"mcpServers":{"github":{"command":"gh"}}}`, wantOK: true}, + {name: "EmptyBraces", contents: `{}`, wantOK: true}, + {name: "EmptyServers", contents: `{"mcpServers":{}}`, wantOK: true}, + {name: "UnknownTopLevelKeysIgnored", contents: `{"other":1,"mcpServers":{"a":{"url":"http://x"}}}`, wantOK: true}, + {name: "TrailingComma", contents: `{"mcpServers":{"a":{"command":"x"},}}`, wantOK: false}, + {name: "Truncated", contents: `{"mcpServers":`, wantOK: false}, + {name: "Empty", contents: ``, wantOK: false}, + {name: "ScalarEntry", contents: `{"mcpServers":{"github":"nope"}}`, wantOK: false}, + {name: "ArrayEntry", contents: `{"mcpServers":{"github":[]}}`, wantOK: false}, + {name: "ServersNotObject", contents: `{"mcpServers":[]}`, wantOK: false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, ".mcp.json"), tc.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) + // The hash is populated regardless of validity so a fix is + // detectable as a change. + require.NotEqual(t, [32]byte{}, got.ContentHash) + + if tc.wantOK { + require.Equal(t, agentcontext.StatusOK, got.Status) + require.Empty(t, got.Error) + } else { + require.Equal(t, agentcontext.StatusInvalid, got.Status) + require.NotEmpty(t, got.Error, "invalid config must carry an error message") + } + }) + } +} + // TestResolver_SymlinkInsideScanRootAllowed exercises the // monorepo case where AGENTS.md is symlinked to shared content // inside the same workspace tree. The target lives under the @@ -377,6 +428,61 @@ func TestResolver_MCPProviderResources(t *testing.T) { require.Equal(t, "GitHub MCP server", got.Description) } +// TestResolver_MCPExcludedFromAggregateHash verifies MCP resources are +// surfaced in the snapshot but do not contribute to the drift hash. MCP +// servers connect asynchronously after agent startup and their tools are +// discovered live at turn time, so a server connecting (or changing its +// tools) must not dirty an already-hydrated chat. Pinned content +// (instruction files, skills) still drives the hash. +func TestResolver_MCPExcludedFromAggregateHash(t *testing.T) { + t.Parallel() + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, "AGENTS.md"), "# Project rules\n\nDo the thing.") + roots := []agentcontext.ScanRoot{{Path: dir}} + + mcpRes := func(payload string) agentcontext.Resource { + return agentcontext.Resource{ + ID: "mcp_server:github", + Kind: agentcontext.KindMCPServer, + Source: "github", + Status: agentcontext.StatusOK, + Payload: []byte(payload), + ContentHash: sha256.Sum256([]byte(payload)), + Description: "GitHub MCP server", + } + } + + // No MCP servers connected yet (the moment right after startup). + noMCP := (&agentcontext.Resolver{}).Resolve(roots) + // A server has connected and contributes a resource. + withMCP := (&agentcontext.Resolver{ + MCP: &fakeMCPProvider{resources: []agentcontext.Resource{mcpRes("tools-v1")}}, + }).Resolve(roots) + // The same server later re-resolves with a different tool list. + withMCPChanged := (&agentcontext.Resolver{ + MCP: &fakeMCPProvider{resources: []agentcontext.Resource{mcpRes("tools-v2-added-a-tool")}}, + }).Resolve(roots) + + // The MCP resource is still surfaced for display. + gotMCP := findResource(t, withMCP.Resources, agentcontext.KindMCPServer, "github") + require.Equal(t, "GitHub MCP server", gotMCP.Description) + + // A server connecting (and later changing its tools) must not change the + // drift hash: it is identical with no MCP, with MCP, and with different + // MCP tools. + require.Equal(t, noMCP.AggregateHash, withMCP.AggregateHash, + "MCP server connecting must not change the drift hash") + require.Equal(t, withMCP.AggregateHash, withMCPChanged.AggregateHash, + "MCP tool changes must not change the drift hash") + + // Sanity: instruction file content still drives the hash (same path, + // different content). + mustWriteFile(t, filepath.Join(dir, "AGENTS.md"), "# Different rules\n") + changed := (&agentcontext.Resolver{}).Resolve(roots) + require.NotEqual(t, noMCP.AggregateHash, changed.AggregateHash, + "instruction file content must still drive the drift hash") +} + // TestResolver_MCPProviderRespectsAggregateByteCap guards the // contract that a single oversized MCP payload cannot blow past // MaxSnapshotBytes with StatusOK. diff --git a/agent/agentsocket/client.go b/agent/agentsocket/client.go index d4a3a41f4cafb..c038edf1fd21c 100644 --- a/agent/agentsocket/client.go +++ b/agent/agentsocket/client.go @@ -16,7 +16,8 @@ import ( type Option func(*options) type options struct { - path string + path string + contextManager ContextManager } // WithPath sets the socket path. If not provided or empty, the client will @@ -30,6 +31,14 @@ func WithPath(path string) Option { } } +// WithContextManager supplies the workspace-context Manager the server uses to +// serve context source CRUD. Server-only; ignored by the client. +func WithContextManager(cm ContextManager) Option { + return func(opts *options) { + opts.contextManager = cm + } +} + // Client provides a client for communicating with the workspace agentsocket API. type Client struct { client proto.DRPCAgentSocketClient @@ -157,6 +166,92 @@ func (c *Client) UpdateAppStatus(ctx context.Context, req *agentproto.UpdateAppS return c.client.UpdateAppStatus(ctx, req) } +// ContextSources lists the workspace-context sources registered on the agent. +func (c *Client) ContextSources(ctx context.Context) ([]ContextSource, error) { + resp, err := c.client.ContextSources(ctx, &proto.ContextSourcesRequest{}) + if err != nil { + return nil, err + } + sources := make([]ContextSource, 0, len(resp.Sources)) + for _, s := range resp.Sources { + sources = append(sources, ContextSource{Path: s.GetPath()}) + } + return sources, nil +} + +// GetContextSource returns a single registered source. The path is +// canonicalized by the agent before matching. +func (c *Client) GetContextSource(ctx context.Context, path string) (ContextSource, error) { + resp, err := c.client.GetContextSource(ctx, &proto.GetContextSourceRequest{Path: path}) + if err != nil { + return ContextSource{}, err + } + return ContextSource{Path: resp.GetSource().GetPath()}, nil +} + +// AddContextSource registers a new scan root on the agent. +func (c *Client) AddContextSource(ctx context.Context, path string) (ContextSource, error) { + resp, err := c.client.AddContextSource(ctx, &proto.AddContextSourceRequest{Path: path}) + if err != nil { + return ContextSource{}, err + } + return ContextSource{Path: resp.GetSource().GetPath()}, nil +} + +// RemoveContextSource removes a previously-registered scan root. +func (c *Client) RemoveContextSource(ctx context.Context, path string) error { + _, err := c.client.RemoveContextSource(ctx, &proto.RemoveContextSourceRequest{Path: path}) + return err +} + +// GetContextSnapshot returns the agent's current resolved snapshot without +// forcing a re-walk. +func (c *Client) GetContextSnapshot(ctx context.Context) (ContextSnapshot, error) { + resp, err := c.client.GetContextSnapshot(ctx, &proto.ContextSnapshotRequest{}) + if err != nil { + return ContextSnapshot{}, err + } + return contextSnapshotFromProto(resp.GetSnapshot()), nil +} + +// ResyncContext forces a re-walk and synchronous push, returning the resulting +// snapshot. Use it as a barrier before fanning out a refresh. +func (c *Client) ResyncContext(ctx context.Context) (ContextSnapshot, error) { + resp, err := c.client.ResyncContext(ctx, &proto.ResyncContextRequest{}) + if err != nil { + return ContextSnapshot{}, err + } + return contextSnapshotFromProto(resp.GetSnapshot()), nil +} + +func contextSnapshotFromProto(s *proto.ContextSnapshot) ContextSnapshot { + if s == nil { + return ContextSnapshot{} + } + out := ContextSnapshot{ + Version: s.GetVersion(), + AggregateHash: s.GetAggregateHash(), + Resources: make([]ContextResource, 0, len(s.GetResources())), + PayloadBytes: s.GetPayloadBytes(), + SnapshotError: s.GetSnapshotError(), + } + for _, r := range s.GetResources() { + out.Resources = append(out.Resources, ContextResource{ + ID: r.GetId(), + Kind: r.GetKind(), + Source: r.GetSource(), + SourcePath: r.GetSourcePath(), + ContentHash: r.GetContentHash(), + SizeBytes: r.GetSizeBytes(), + Status: r.GetStatus(), + Error: r.GetError(), + Name: r.GetName(), + Description: r.GetDescription(), + }) + } + return out +} + // SyncStatusResponse contains the status information for a unit. type SyncStatusResponse struct { UnitName unit.ID `table:"unit,default_sort" json:"unit_name"` @@ -179,3 +274,32 @@ type DependencyInfo struct { CurrentStatus unit.Status `table:"current status" json:"current_status"` IsSatisfied bool `table:"satisfied" json:"is_satisfied"` } + +// ContextSource is a registered workspace-context scan root. +type ContextSource struct { + Path string `table:"path,default_sort" json:"path"` +} + +// ContextResource is a resolved workspace-context resource. Payload bytes are +// never carried over the socket. +type ContextResource struct { + Kind string `table:"kind,default_sort" json:"kind"` + Name string `table:"name" json:"name"` + Source string `table:"source" json:"source"` + SourcePath string `table:"source path" json:"source_path"` + Status string `table:"status" json:"status"` + SizeBytes uint64 `table:"size bytes" json:"size_bytes"` + Error string `table:"error" json:"error"` + Description string `table:"-" json:"description"` + ID string `table:"-" json:"id"` + ContentHash string `table:"-" json:"content_hash"` +} + +// ContextSnapshot is the agent's resolved workspace-context state. +type ContextSnapshot struct { + Version uint64 `json:"version"` + AggregateHash string `json:"aggregate_hash"` + Resources []ContextResource `json:"resources"` + PayloadBytes uint64 `json:"payload_bytes"` + SnapshotError string `json:"snapshot_error"` +} diff --git a/agent/agentsocket/context_test.go b/agent/agentsocket/context_test.go new file mode 100644 index 0000000000000..423d9300dcd4d --- /dev/null +++ b/agent/agentsocket/context_test.go @@ -0,0 +1,180 @@ +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) + + _, err = client.ContextSources(ctx) + require.Error(t, err) + }) +} diff --git a/agent/agentsocket/proto/agentsocket.pb.go b/agent/agentsocket/proto/agentsocket.pb.go index 298664b2d95f7..a724b87c99a1a 100644 --- a/agent/agentsocket/proto/agentsocket.pb.go +++ b/agent/agentsocket/proto/agentsocket.pb.go @@ -795,6 +795,787 @@ func (x *SyncListResponse) GetUnits() []*UnitInfo { return nil } +// ContextSource is a user-declared scan root the agent watches for +// workspace context (instruction files, skills, MCP configs) in +// addition to its built-in defaults. Identity is the canonical path. +type ContextSource struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` +} + +func (x *ContextSource) Reset() { + *x = ContextSource{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ContextSource) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ContextSource) ProtoMessage() {} + +func (x *ContextSource) ProtoReflect() protoreflect.Message { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[16] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ContextSource.ProtoReflect.Descriptor instead. +func (*ContextSource) Descriptor() ([]byte, []int) { + return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{16} +} + +func (x *ContextSource) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type ContextSourcesRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *ContextSourcesRequest) Reset() { + *x = ContextSourcesRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ContextSourcesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ContextSourcesRequest) ProtoMessage() {} + +func (x *ContextSourcesRequest) ProtoReflect() protoreflect.Message { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[17] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ContextSourcesRequest.ProtoReflect.Descriptor instead. +func (*ContextSourcesRequest) Descriptor() ([]byte, []int) { + return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{17} +} + +type ContextSourcesResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Sources []*ContextSource `protobuf:"bytes,1,rep,name=sources,proto3" json:"sources,omitempty"` +} + +func (x *ContextSourcesResponse) Reset() { + *x = ContextSourcesResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ContextSourcesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ContextSourcesResponse) ProtoMessage() {} + +func (x *ContextSourcesResponse) ProtoReflect() protoreflect.Message { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[18] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ContextSourcesResponse.ProtoReflect.Descriptor instead. +func (*ContextSourcesResponse) Descriptor() ([]byte, []int) { + return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{18} +} + +func (x *ContextSourcesResponse) GetSources() []*ContextSource { + if x != nil { + return x.Sources + } + return nil +} + +type GetContextSourceRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` +} + +func (x *GetContextSourceRequest) Reset() { + *x = GetContextSourceRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetContextSourceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetContextSourceRequest) ProtoMessage() {} + +func (x *GetContextSourceRequest) ProtoReflect() protoreflect.Message { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[19] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetContextSourceRequest.ProtoReflect.Descriptor instead. +func (*GetContextSourceRequest) Descriptor() ([]byte, []int) { + return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{19} +} + +func (x *GetContextSourceRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type GetContextSourceResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Source *ContextSource `protobuf:"bytes,1,opt,name=source,proto3" json:"source,omitempty"` +} + +func (x *GetContextSourceResponse) Reset() { + *x = GetContextSourceResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetContextSourceResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetContextSourceResponse) ProtoMessage() {} + +func (x *GetContextSourceResponse) ProtoReflect() protoreflect.Message { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[20] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetContextSourceResponse.ProtoReflect.Descriptor instead. +func (*GetContextSourceResponse) Descriptor() ([]byte, []int) { + return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{20} +} + +func (x *GetContextSourceResponse) GetSource() *ContextSource { + if x != nil { + return x.Source + } + return nil +} + +type AddContextSourceRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` +} + +func (x *AddContextSourceRequest) Reset() { + *x = AddContextSourceRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AddContextSourceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddContextSourceRequest) ProtoMessage() {} + +func (x *AddContextSourceRequest) ProtoReflect() protoreflect.Message { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[21] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AddContextSourceRequest.ProtoReflect.Descriptor instead. +func (*AddContextSourceRequest) Descriptor() ([]byte, []int) { + return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{21} +} + +func (x *AddContextSourceRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type AddContextSourceResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Source *ContextSource `protobuf:"bytes,1,opt,name=source,proto3" json:"source,omitempty"` +} + +func (x *AddContextSourceResponse) Reset() { + *x = AddContextSourceResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AddContextSourceResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddContextSourceResponse) ProtoMessage() {} + +func (x *AddContextSourceResponse) ProtoReflect() protoreflect.Message { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[22] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AddContextSourceResponse.ProtoReflect.Descriptor instead. +func (*AddContextSourceResponse) Descriptor() ([]byte, []int) { + return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{22} +} + +func (x *AddContextSourceResponse) GetSource() *ContextSource { + if x != nil { + return x.Source + } + return nil +} + +type RemoveContextSourceRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` +} + +func (x *RemoveContextSourceRequest) Reset() { + *x = RemoveContextSourceRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *RemoveContextSourceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveContextSourceRequest) ProtoMessage() {} + +func (x *RemoveContextSourceRequest) ProtoReflect() protoreflect.Message { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[23] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveContextSourceRequest.ProtoReflect.Descriptor instead. +func (*RemoveContextSourceRequest) Descriptor() ([]byte, []int) { + return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{23} +} + +func (x *RemoveContextSourceRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type RemoveContextSourceResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *RemoveContextSourceResponse) Reset() { + *x = RemoveContextSourceResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *RemoveContextSourceResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveContextSourceResponse) ProtoMessage() {} + +func (x *RemoveContextSourceResponse) ProtoReflect() protoreflect.Message { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[24] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveContextSourceResponse.ProtoReflect.Descriptor instead. +func (*RemoveContextSourceResponse) Descriptor() ([]byte, []int) { + return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{24} +} + +// ContextResource is the on-wire form of a resolved context resource. +// Payload bytes are never sent over the socket; they ship to coderd via +// the drpc PushContextState path. Mirrors agentcontext.Resource minus +// the payload. +type ContextResource struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Kind string `protobuf:"bytes,2,opt,name=kind,proto3" json:"kind,omitempty"` + Source string `protobuf:"bytes,3,opt,name=source,proto3" json:"source,omitempty"` + SourcePath string `protobuf:"bytes,4,opt,name=source_path,json=sourcePath,proto3" json:"source_path,omitempty"` + ContentHash string `protobuf:"bytes,5,opt,name=content_hash,json=contentHash,proto3" json:"content_hash,omitempty"` + SizeBytes uint64 `protobuf:"varint,6,opt,name=size_bytes,json=sizeBytes,proto3" json:"size_bytes,omitempty"` + Status string `protobuf:"bytes,7,opt,name=status,proto3" json:"status,omitempty"` + Error string `protobuf:"bytes,8,opt,name=error,proto3" json:"error,omitempty"` + Name string `protobuf:"bytes,9,opt,name=name,proto3" json:"name,omitempty"` + Description string `protobuf:"bytes,10,opt,name=description,proto3" json:"description,omitempty"` +} + +func (x *ContextResource) Reset() { + *x = ContextResource{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ContextResource) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ContextResource) ProtoMessage() {} + +func (x *ContextResource) ProtoReflect() protoreflect.Message { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[25] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ContextResource.ProtoReflect.Descriptor instead. +func (*ContextResource) Descriptor() ([]byte, []int) { + return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{25} +} + +func (x *ContextResource) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ContextResource) GetKind() string { + if x != nil { + return x.Kind + } + return "" +} + +func (x *ContextResource) GetSource() string { + if x != nil { + return x.Source + } + return "" +} + +func (x *ContextResource) GetSourcePath() string { + if x != nil { + return x.SourcePath + } + return "" +} + +func (x *ContextResource) GetContentHash() string { + if x != nil { + return x.ContentHash + } + return "" +} + +func (x *ContextResource) GetSizeBytes() uint64 { + if x != nil { + return x.SizeBytes + } + return 0 +} + +func (x *ContextResource) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *ContextResource) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *ContextResource) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ContextResource) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +// ContextSnapshot is the agent's resolved context state. +type ContextSnapshot struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Version uint64 `protobuf:"varint,1,opt,name=version,proto3" json:"version,omitempty"` + AggregateHash string `protobuf:"bytes,2,opt,name=aggregate_hash,json=aggregateHash,proto3" json:"aggregate_hash,omitempty"` + Resources []*ContextResource `protobuf:"bytes,3,rep,name=resources,proto3" json:"resources,omitempty"` + PayloadBytes uint64 `protobuf:"varint,4,opt,name=payload_bytes,json=payloadBytes,proto3" json:"payload_bytes,omitempty"` + SnapshotError string `protobuf:"bytes,5,opt,name=snapshot_error,json=snapshotError,proto3" json:"snapshot_error,omitempty"` +} + +func (x *ContextSnapshot) Reset() { + *x = ContextSnapshot{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ContextSnapshot) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ContextSnapshot) ProtoMessage() {} + +func (x *ContextSnapshot) ProtoReflect() protoreflect.Message { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[26] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ContextSnapshot.ProtoReflect.Descriptor instead. +func (*ContextSnapshot) Descriptor() ([]byte, []int) { + return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{26} +} + +func (x *ContextSnapshot) GetVersion() uint64 { + if x != nil { + return x.Version + } + return 0 +} + +func (x *ContextSnapshot) GetAggregateHash() string { + if x != nil { + return x.AggregateHash + } + return "" +} + +func (x *ContextSnapshot) GetResources() []*ContextResource { + if x != nil { + return x.Resources + } + return nil +} + +func (x *ContextSnapshot) GetPayloadBytes() uint64 { + if x != nil { + return x.PayloadBytes + } + return 0 +} + +func (x *ContextSnapshot) GetSnapshotError() string { + if x != nil { + return x.SnapshotError + } + return "" +} + +type ContextSnapshotRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *ContextSnapshotRequest) Reset() { + *x = ContextSnapshotRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ContextSnapshotRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ContextSnapshotRequest) ProtoMessage() {} + +func (x *ContextSnapshotRequest) ProtoReflect() protoreflect.Message { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[27] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ContextSnapshotRequest.ProtoReflect.Descriptor instead. +func (*ContextSnapshotRequest) Descriptor() ([]byte, []int) { + return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{27} +} + +type ContextSnapshotResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Snapshot *ContextSnapshot `protobuf:"bytes,1,opt,name=snapshot,proto3" json:"snapshot,omitempty"` +} + +func (x *ContextSnapshotResponse) Reset() { + *x = ContextSnapshotResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ContextSnapshotResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ContextSnapshotResponse) ProtoMessage() {} + +func (x *ContextSnapshotResponse) ProtoReflect() protoreflect.Message { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[28] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ContextSnapshotResponse.ProtoReflect.Descriptor instead. +func (*ContextSnapshotResponse) Descriptor() ([]byte, []int) { + return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{28} +} + +func (x *ContextSnapshotResponse) GetSnapshot() *ContextSnapshot { + if x != nil { + return x.Snapshot + } + return nil +} + +type ResyncContextRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *ResyncContextRequest) Reset() { + *x = ResyncContextRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ResyncContextRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResyncContextRequest) ProtoMessage() {} + +func (x *ResyncContextRequest) ProtoReflect() protoreflect.Message { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[29] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResyncContextRequest.ProtoReflect.Descriptor instead. +func (*ResyncContextRequest) Descriptor() ([]byte, []int) { + return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{29} +} + +type ResyncContextResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Snapshot *ContextSnapshot `protobuf:"bytes,1,opt,name=snapshot,proto3" json:"snapshot,omitempty"` +} + +func (x *ResyncContextResponse) Reset() { + *x = ResyncContextResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ResyncContextResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResyncContextResponse) ProtoMessage() {} + +func (x *ResyncContextResponse) ProtoReflect() protoreflect.Message { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[30] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResyncContextResponse.ProtoReflect.Descriptor instead. +func (*ResyncContextResponse) Descriptor() ([]byte, []int) { + return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{30} +} + +func (x *ResyncContextResponse) GetSnapshot() *ContextSnapshot { + if x != nil { + return x.Snapshot + } + return nil +} + var File_agent_agentsocket_proto_agentsocket_proto protoreflect.FileDescriptor var file_agent_agentsocket_proto_agentsocket_proto_rawDesc = []byte{ @@ -858,59 +1639,180 @@ var file_agent_agentsocket_proto_agentsocket_proto_rawDesc = []byte{ 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x34, 0x0a, 0x05, 0x75, 0x6e, 0x69, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x6e, 0x69, - 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x05, 0x75, 0x6e, 0x69, 0x74, 0x73, 0x32, 0xfa, 0x05, 0x0a, - 0x0b, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x53, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x12, 0x4d, 0x0a, 0x04, - 0x50, 0x69, 0x6e, 0x67, 0x12, 0x21, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, - 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x69, 0x6e, 0x67, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, - 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x50, - 0x69, 0x6e, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5c, 0x0a, 0x09, 0x53, - 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x72, 0x74, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, + 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x05, 0x75, 0x6e, 0x69, 0x74, 0x73, 0x22, 0x23, 0x0a, 0x0d, + 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x12, 0x0a, + 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, + 0x68, 0x22, 0x17, 0x0a, 0x15, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x57, 0x0a, 0x16, 0x43, 0x6f, + 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3d, 0x0a, 0x07, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, + 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, + 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, + 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x07, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x73, 0x22, 0x2d, 0x0a, 0x17, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, + 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, + 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, + 0x74, 0x68, 0x22, 0x57, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, + 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, + 0x0a, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x23, + 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, + 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x52, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x22, 0x2d, 0x0a, 0x17, 0x41, + 0x64, 0x64, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x22, 0x57, 0x0a, 0x18, 0x41, 0x64, + 0x64, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, + 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, + 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x06, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x22, 0x30, 0x0a, 0x1a, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x43, 0x6f, 0x6e, + 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x04, 0x70, 0x61, 0x74, 0x68, 0x22, 0x1d, 0x0a, 0x1b, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x43, + 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x94, 0x02, 0x0a, 0x0f, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, + 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x16, 0x0a, 0x06, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x70, + 0x61, 0x74, 0x68, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x50, 0x61, 0x74, 0x68, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, + 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x6e, + 0x74, 0x65, 0x6e, 0x74, 0x48, 0x61, 0x73, 0x68, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x69, 0x7a, 0x65, + 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x04, 0x52, 0x09, 0x73, 0x69, + 0x7a, 0x65, 0x42, 0x79, 0x74, 0x65, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, + 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, + 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, + 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x09, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, + 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, + 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0xe3, 0x01, 0x0a, 0x0f, + 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x12, + 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, + 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x67, 0x67, + 0x72, 0x65, 0x67, 0x61, 0x74, 0x65, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0d, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x65, 0x48, 0x61, 0x73, 0x68, + 0x12, 0x43, 0x0a, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x03, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, + 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, + 0x78, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x09, 0x72, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, + 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0c, 0x70, 0x61, + 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x42, 0x79, 0x74, 0x65, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x73, 0x6e, + 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x05, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0d, 0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x45, 0x72, 0x72, 0x6f, + 0x72, 0x22, 0x18, 0x0a, 0x16, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6e, 0x61, 0x70, + 0x73, 0x68, 0x6f, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x5c, 0x0a, 0x17, 0x43, + 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x41, 0x0a, 0x08, 0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, + 0x6f, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, - 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, - 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x72, - 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x59, 0x0a, 0x08, 0x53, 0x79, 0x6e, - 0x63, 0x57, 0x61, 0x6e, 0x74, 0x12, 0x25, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, - 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, - 0x63, 0x57, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x63, + 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x52, + 0x08, 0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x22, 0x16, 0x0a, 0x14, 0x52, 0x65, 0x73, + 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x22, 0x5a, 0x0a, 0x15, 0x52, 0x65, 0x73, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6e, 0x74, 0x65, + 0x78, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x41, 0x0a, 0x08, 0x73, 0x6e, + 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, - 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x57, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x65, 0x0a, 0x0c, 0x53, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, - 0x6c, 0x65, 0x74, 0x65, 0x12, 0x29, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, - 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, - 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, - 0x2a, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, - 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, - 0x65, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5c, 0x0a, 0x09, 0x53, - 0x79, 0x6e, 0x63, 0x52, 0x65, 0x61, 0x64, 0x79, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, + 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6e, 0x61, 0x70, 0x73, + 0x68, 0x6f, 0x74, 0x52, 0x08, 0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x32, 0xa6, 0x0b, + 0x0a, 0x0b, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x53, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x12, 0x4d, 0x0a, + 0x04, 0x50, 0x69, 0x6e, 0x67, 0x12, 0x21, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, + 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x69, 0x6e, + 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, - 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x61, 0x64, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, - 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x61, 0x64, - 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5f, 0x0a, 0x0a, 0x53, 0x79, 0x6e, - 0x63, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, - 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, - 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x28, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, - 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x74, - 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x59, 0x0a, 0x08, 0x53, 0x79, - 0x6e, 0x63, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x25, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, + 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5c, 0x0a, 0x09, + 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x72, 0x74, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, + 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, + 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, + 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, + 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x59, 0x0a, 0x08, 0x53, 0x79, + 0x6e, 0x63, 0x57, 0x61, 0x6e, 0x74, 0x12, 0x25, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, - 0x6e, 0x63, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, + 0x6e, 0x63, 0x57, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, + 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, + 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x57, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x65, 0x0a, 0x0c, 0x53, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, + 0x70, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x29, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, + 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, + 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x2a, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, + 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, + 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5c, 0x0a, 0x09, + 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x61, 0x64, 0x79, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, + 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, + 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x61, 0x64, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, + 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x61, + 0x64, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5f, 0x0a, 0x0a, 0x53, 0x79, + 0x6e, 0x63, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, + 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, + 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x28, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, + 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, + 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x59, 0x0a, 0x08, 0x53, + 0x79, 0x6e, 0x63, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x25, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, + 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, + 0x79, 0x6e, 0x63, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, + 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, + 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x62, 0x0a, 0x0f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, + 0x41, 0x70, 0x70, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, + 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, + 0x65, 0x41, 0x70, 0x70, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, + 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x53, 0x74, 0x61, 0x74, + 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6b, 0x0a, 0x0e, 0x43, 0x6f, + 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x12, 0x2b, 0x2e, 0x63, + 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, + 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x63, 0x6f, 0x64, 0x65, + 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, + 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x71, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x43, 0x6f, + 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x2d, 0x2e, 0x63, 0x6f, + 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, + 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2e, 0x2e, 0x63, 0x6f, 0x64, + 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, + 0x31, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x71, 0x0a, 0x10, 0x41, 0x64, + 0x64, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x2d, + 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, + 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x64, 0x64, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, + 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2e, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, - 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x62, 0x0a, 0x0f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, - 0x70, 0x70, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, - 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, - 0x41, 0x70, 0x70, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, - 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x53, 0x74, 0x61, 0x74, 0x75, - 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x33, 0x5a, 0x31, 0x67, 0x69, 0x74, - 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, - 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2f, 0x61, 0x67, 0x65, - 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x64, 0x64, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x7a, 0x0a, + 0x13, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x12, 0x30, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, + 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x6d, 0x6f, + 0x76, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x31, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, + 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, + 0x6d, 0x6f, 0x76, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x71, 0x0a, 0x12, 0x47, 0x65, 0x74, + 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x12, + 0x2c, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, + 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6e, + 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2d, 0x2e, + 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, + 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6e, 0x61, 0x70, + 0x73, 0x68, 0x6f, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x68, 0x0a, 0x0d, + 0x52, 0x65, 0x73, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x12, 0x2a, 0x2e, + 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, + 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6e, 0x74, 0x65, + 0x78, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2b, 0x2e, 0x63, 0x6f, 0x64, 0x65, + 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, + 0x2e, 0x52, 0x65, 0x73, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x33, 0x5a, 0x31, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, + 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, + 0x2f, 0x76, 0x32, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, + 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x33, } var ( @@ -925,7 +1827,7 @@ func file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP() []byte { return file_agent_agentsocket_proto_agentsocket_proto_rawDescData } -var file_agent_agentsocket_proto_agentsocket_proto_msgTypes = make([]protoimpl.MessageInfo, 16) +var file_agent_agentsocket_proto_agentsocket_proto_msgTypes = make([]protoimpl.MessageInfo, 31) var file_agent_agentsocket_proto_agentsocket_proto_goTypes = []interface{}{ (*PingRequest)(nil), // 0: coder.agentsocket.v1.PingRequest (*PingResponse)(nil), // 1: coder.agentsocket.v1.PingResponse @@ -943,33 +1845,66 @@ var file_agent_agentsocket_proto_agentsocket_proto_goTypes = []interface{}{ (*SyncListRequest)(nil), // 13: coder.agentsocket.v1.SyncListRequest (*UnitInfo)(nil), // 14: coder.agentsocket.v1.UnitInfo (*SyncListResponse)(nil), // 15: coder.agentsocket.v1.SyncListResponse - (*proto.UpdateAppStatusRequest)(nil), // 16: coder.agent.v2.UpdateAppStatusRequest - (*proto.UpdateAppStatusResponse)(nil), // 17: coder.agent.v2.UpdateAppStatusResponse + (*ContextSource)(nil), // 16: coder.agentsocket.v1.ContextSource + (*ContextSourcesRequest)(nil), // 17: coder.agentsocket.v1.ContextSourcesRequest + (*ContextSourcesResponse)(nil), // 18: coder.agentsocket.v1.ContextSourcesResponse + (*GetContextSourceRequest)(nil), // 19: coder.agentsocket.v1.GetContextSourceRequest + (*GetContextSourceResponse)(nil), // 20: coder.agentsocket.v1.GetContextSourceResponse + (*AddContextSourceRequest)(nil), // 21: coder.agentsocket.v1.AddContextSourceRequest + (*AddContextSourceResponse)(nil), // 22: coder.agentsocket.v1.AddContextSourceResponse + (*RemoveContextSourceRequest)(nil), // 23: coder.agentsocket.v1.RemoveContextSourceRequest + (*RemoveContextSourceResponse)(nil), // 24: coder.agentsocket.v1.RemoveContextSourceResponse + (*ContextResource)(nil), // 25: coder.agentsocket.v1.ContextResource + (*ContextSnapshot)(nil), // 26: coder.agentsocket.v1.ContextSnapshot + (*ContextSnapshotRequest)(nil), // 27: coder.agentsocket.v1.ContextSnapshotRequest + (*ContextSnapshotResponse)(nil), // 28: coder.agentsocket.v1.ContextSnapshotResponse + (*ResyncContextRequest)(nil), // 29: coder.agentsocket.v1.ResyncContextRequest + (*ResyncContextResponse)(nil), // 30: coder.agentsocket.v1.ResyncContextResponse + (*proto.UpdateAppStatusRequest)(nil), // 31: coder.agent.v2.UpdateAppStatusRequest + (*proto.UpdateAppStatusResponse)(nil), // 32: coder.agent.v2.UpdateAppStatusResponse } var file_agent_agentsocket_proto_agentsocket_proto_depIdxs = []int32{ 11, // 0: coder.agentsocket.v1.SyncStatusResponse.dependencies:type_name -> coder.agentsocket.v1.DependencyInfo 14, // 1: coder.agentsocket.v1.SyncListResponse.units:type_name -> coder.agentsocket.v1.UnitInfo - 0, // 2: coder.agentsocket.v1.AgentSocket.Ping:input_type -> coder.agentsocket.v1.PingRequest - 2, // 3: coder.agentsocket.v1.AgentSocket.SyncStart:input_type -> coder.agentsocket.v1.SyncStartRequest - 4, // 4: coder.agentsocket.v1.AgentSocket.SyncWant:input_type -> coder.agentsocket.v1.SyncWantRequest - 6, // 5: coder.agentsocket.v1.AgentSocket.SyncComplete:input_type -> coder.agentsocket.v1.SyncCompleteRequest - 8, // 6: coder.agentsocket.v1.AgentSocket.SyncReady:input_type -> coder.agentsocket.v1.SyncReadyRequest - 10, // 7: coder.agentsocket.v1.AgentSocket.SyncStatus:input_type -> coder.agentsocket.v1.SyncStatusRequest - 13, // 8: coder.agentsocket.v1.AgentSocket.SyncList:input_type -> coder.agentsocket.v1.SyncListRequest - 16, // 9: coder.agentsocket.v1.AgentSocket.UpdateAppStatus:input_type -> coder.agent.v2.UpdateAppStatusRequest - 1, // 10: coder.agentsocket.v1.AgentSocket.Ping:output_type -> coder.agentsocket.v1.PingResponse - 3, // 11: coder.agentsocket.v1.AgentSocket.SyncStart:output_type -> coder.agentsocket.v1.SyncStartResponse - 5, // 12: coder.agentsocket.v1.AgentSocket.SyncWant:output_type -> coder.agentsocket.v1.SyncWantResponse - 7, // 13: coder.agentsocket.v1.AgentSocket.SyncComplete:output_type -> coder.agentsocket.v1.SyncCompleteResponse - 9, // 14: coder.agentsocket.v1.AgentSocket.SyncReady:output_type -> coder.agentsocket.v1.SyncReadyResponse - 12, // 15: coder.agentsocket.v1.AgentSocket.SyncStatus:output_type -> coder.agentsocket.v1.SyncStatusResponse - 15, // 16: coder.agentsocket.v1.AgentSocket.SyncList:output_type -> coder.agentsocket.v1.SyncListResponse - 17, // 17: coder.agentsocket.v1.AgentSocket.UpdateAppStatus:output_type -> coder.agent.v2.UpdateAppStatusResponse - 10, // [10:18] is the sub-list for method output_type - 2, // [2:10] is the sub-list for method input_type - 2, // [2:2] is the sub-list for extension type_name - 2, // [2:2] is the sub-list for extension extendee - 0, // [0:2] is the sub-list for field type_name + 16, // 2: coder.agentsocket.v1.ContextSourcesResponse.sources:type_name -> coder.agentsocket.v1.ContextSource + 16, // 3: coder.agentsocket.v1.GetContextSourceResponse.source:type_name -> coder.agentsocket.v1.ContextSource + 16, // 4: coder.agentsocket.v1.AddContextSourceResponse.source:type_name -> coder.agentsocket.v1.ContextSource + 25, // 5: coder.agentsocket.v1.ContextSnapshot.resources:type_name -> coder.agentsocket.v1.ContextResource + 26, // 6: coder.agentsocket.v1.ContextSnapshotResponse.snapshot:type_name -> coder.agentsocket.v1.ContextSnapshot + 26, // 7: coder.agentsocket.v1.ResyncContextResponse.snapshot:type_name -> coder.agentsocket.v1.ContextSnapshot + 0, // 8: coder.agentsocket.v1.AgentSocket.Ping:input_type -> coder.agentsocket.v1.PingRequest + 2, // 9: coder.agentsocket.v1.AgentSocket.SyncStart:input_type -> coder.agentsocket.v1.SyncStartRequest + 4, // 10: coder.agentsocket.v1.AgentSocket.SyncWant:input_type -> coder.agentsocket.v1.SyncWantRequest + 6, // 11: coder.agentsocket.v1.AgentSocket.SyncComplete:input_type -> coder.agentsocket.v1.SyncCompleteRequest + 8, // 12: coder.agentsocket.v1.AgentSocket.SyncReady:input_type -> coder.agentsocket.v1.SyncReadyRequest + 10, // 13: coder.agentsocket.v1.AgentSocket.SyncStatus:input_type -> coder.agentsocket.v1.SyncStatusRequest + 13, // 14: coder.agentsocket.v1.AgentSocket.SyncList:input_type -> coder.agentsocket.v1.SyncListRequest + 31, // 15: coder.agentsocket.v1.AgentSocket.UpdateAppStatus:input_type -> coder.agent.v2.UpdateAppStatusRequest + 17, // 16: coder.agentsocket.v1.AgentSocket.ContextSources:input_type -> coder.agentsocket.v1.ContextSourcesRequest + 19, // 17: coder.agentsocket.v1.AgentSocket.GetContextSource:input_type -> coder.agentsocket.v1.GetContextSourceRequest + 21, // 18: coder.agentsocket.v1.AgentSocket.AddContextSource:input_type -> coder.agentsocket.v1.AddContextSourceRequest + 23, // 19: coder.agentsocket.v1.AgentSocket.RemoveContextSource:input_type -> coder.agentsocket.v1.RemoveContextSourceRequest + 27, // 20: coder.agentsocket.v1.AgentSocket.GetContextSnapshot:input_type -> coder.agentsocket.v1.ContextSnapshotRequest + 29, // 21: coder.agentsocket.v1.AgentSocket.ResyncContext:input_type -> coder.agentsocket.v1.ResyncContextRequest + 1, // 22: coder.agentsocket.v1.AgentSocket.Ping:output_type -> coder.agentsocket.v1.PingResponse + 3, // 23: coder.agentsocket.v1.AgentSocket.SyncStart:output_type -> coder.agentsocket.v1.SyncStartResponse + 5, // 24: coder.agentsocket.v1.AgentSocket.SyncWant:output_type -> coder.agentsocket.v1.SyncWantResponse + 7, // 25: coder.agentsocket.v1.AgentSocket.SyncComplete:output_type -> coder.agentsocket.v1.SyncCompleteResponse + 9, // 26: coder.agentsocket.v1.AgentSocket.SyncReady:output_type -> coder.agentsocket.v1.SyncReadyResponse + 12, // 27: coder.agentsocket.v1.AgentSocket.SyncStatus:output_type -> coder.agentsocket.v1.SyncStatusResponse + 15, // 28: coder.agentsocket.v1.AgentSocket.SyncList:output_type -> coder.agentsocket.v1.SyncListResponse + 32, // 29: coder.agentsocket.v1.AgentSocket.UpdateAppStatus:output_type -> coder.agent.v2.UpdateAppStatusResponse + 18, // 30: coder.agentsocket.v1.AgentSocket.ContextSources:output_type -> coder.agentsocket.v1.ContextSourcesResponse + 20, // 31: coder.agentsocket.v1.AgentSocket.GetContextSource:output_type -> coder.agentsocket.v1.GetContextSourceResponse + 22, // 32: coder.agentsocket.v1.AgentSocket.AddContextSource:output_type -> coder.agentsocket.v1.AddContextSourceResponse + 24, // 33: coder.agentsocket.v1.AgentSocket.RemoveContextSource:output_type -> coder.agentsocket.v1.RemoveContextSourceResponse + 28, // 34: coder.agentsocket.v1.AgentSocket.GetContextSnapshot:output_type -> coder.agentsocket.v1.ContextSnapshotResponse + 30, // 35: coder.agentsocket.v1.AgentSocket.ResyncContext:output_type -> coder.agentsocket.v1.ResyncContextResponse + 22, // [22:36] is the sub-list for method output_type + 8, // [8:22] is the sub-list for method input_type + 8, // [8:8] is the sub-list for extension type_name + 8, // [8:8] is the sub-list for extension extendee + 0, // [0:8] is the sub-list for field type_name } func init() { file_agent_agentsocket_proto_agentsocket_proto_init() } @@ -1170,6 +2105,186 @@ func file_agent_agentsocket_proto_agentsocket_proto_init() { return nil } } + file_agent_agentsocket_proto_agentsocket_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ContextSource); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_agentsocket_proto_agentsocket_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ContextSourcesRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_agentsocket_proto_agentsocket_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ContextSourcesResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_agentsocket_proto_agentsocket_proto_msgTypes[19].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetContextSourceRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_agentsocket_proto_agentsocket_proto_msgTypes[20].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetContextSourceResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_agentsocket_proto_agentsocket_proto_msgTypes[21].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*AddContextSourceRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_agentsocket_proto_agentsocket_proto_msgTypes[22].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*AddContextSourceResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_agentsocket_proto_agentsocket_proto_msgTypes[23].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*RemoveContextSourceRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_agentsocket_proto_agentsocket_proto_msgTypes[24].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*RemoveContextSourceResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_agentsocket_proto_agentsocket_proto_msgTypes[25].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ContextResource); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_agentsocket_proto_agentsocket_proto_msgTypes[26].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ContextSnapshot); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_agentsocket_proto_agentsocket_proto_msgTypes[27].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ContextSnapshotRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_agentsocket_proto_agentsocket_proto_msgTypes[28].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ContextSnapshotResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_agentsocket_proto_agentsocket_proto_msgTypes[29].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ResyncContextRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_agentsocket_proto_agentsocket_proto_msgTypes[30].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ResyncContextResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } } type x struct{} out := protoimpl.TypeBuilder{ @@ -1177,7 +2292,7 @@ func file_agent_agentsocket_proto_agentsocket_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_agent_agentsocket_proto_agentsocket_proto_rawDesc, NumEnums: 0, - NumMessages: 16, + NumMessages: 31, NumExtensions: 0, NumServices: 1, }, diff --git a/agent/agentsocket/proto/agentsocket.proto b/agent/agentsocket/proto/agentsocket.proto index ad0bbe0f7ec00..7fc9817f42b40 100644 --- a/agent/agentsocket/proto/agentsocket.proto +++ b/agent/agentsocket/proto/agentsocket.proto @@ -70,6 +70,79 @@ message SyncListResponse { repeated UnitInfo units = 1; } +// ContextSource is a user-declared scan root the agent watches for +// workspace context (instruction files, skills, MCP configs) in +// addition to its built-in defaults. Identity is the canonical path. +message ContextSource { + string path = 1; +} + +message ContextSourcesRequest {} + +message ContextSourcesResponse { + repeated ContextSource sources = 1; +} + +message GetContextSourceRequest { + string path = 1; +} + +message GetContextSourceResponse { + ContextSource source = 1; +} + +message AddContextSourceRequest { + string path = 1; +} + +message AddContextSourceResponse { + ContextSource source = 1; +} + +message RemoveContextSourceRequest { + string path = 1; +} + +message RemoveContextSourceResponse {} + +// ContextResource is the on-wire form of a resolved context resource. +// Payload bytes are never sent over the socket; they ship to coderd via +// the drpc PushContextState path. Mirrors agentcontext.Resource minus +// the payload. +message ContextResource { + string id = 1; + string kind = 2; + string source = 3; + string source_path = 4; + string content_hash = 5; + uint64 size_bytes = 6; + string status = 7; + string error = 8; + string name = 9; + string description = 10; +} + +// ContextSnapshot is the agent's resolved context state. +message ContextSnapshot { + uint64 version = 1; + string aggregate_hash = 2; + repeated ContextResource resources = 3; + uint64 payload_bytes = 4; + string snapshot_error = 5; +} + +message ContextSnapshotRequest {} + +message ContextSnapshotResponse { + ContextSnapshot snapshot = 1; +} + +message ResyncContextRequest {} + +message ResyncContextResponse { + ContextSnapshot snapshot = 1; +} + // AgentSocket provides direct access to the agent over local IPC. service AgentSocket { // Ping the agent to check if it is alive. @@ -88,4 +161,16 @@ service AgentSocket { rpc SyncList(SyncListRequest) returns (SyncListResponse); // Update app status, forwarded to coderd. rpc UpdateAppStatus(coder.agent.v2.UpdateAppStatusRequest) returns (coder.agent.v2.UpdateAppStatusResponse); + // List the workspace context sources registered on the agent. + rpc ContextSources(ContextSourcesRequest) returns (ContextSourcesResponse); + // Get a single registered context source by path. + rpc GetContextSource(GetContextSourceRequest) returns (GetContextSourceResponse); + // Register a new context source (additional scan root). + rpc AddContextSource(AddContextSourceRequest) returns (AddContextSourceResponse); + // Remove a previously-registered context source. + rpc RemoveContextSource(RemoveContextSourceRequest) returns (RemoveContextSourceResponse); + // Return the agent's current resolved context snapshot without forcing a re-walk. + rpc GetContextSnapshot(ContextSnapshotRequest) returns (ContextSnapshotResponse); + // Force a re-walk and synchronous push, returning the resulting snapshot (barrier). + rpc ResyncContext(ResyncContextRequest) returns (ResyncContextResponse); } diff --git a/agent/agentsocket/proto/agentsocket_drpc.pb.go b/agent/agentsocket/proto/agentsocket_drpc.pb.go index 04836e6f87db7..664443072d250 100644 --- a/agent/agentsocket/proto/agentsocket_drpc.pb.go +++ b/agent/agentsocket/proto/agentsocket_drpc.pb.go @@ -47,6 +47,12 @@ type DRPCAgentSocketClient interface { SyncStatus(ctx context.Context, in *SyncStatusRequest) (*SyncStatusResponse, error) SyncList(ctx context.Context, in *SyncListRequest) (*SyncListResponse, error) UpdateAppStatus(ctx context.Context, in *proto1.UpdateAppStatusRequest) (*proto1.UpdateAppStatusResponse, error) + ContextSources(ctx context.Context, in *ContextSourcesRequest) (*ContextSourcesResponse, error) + GetContextSource(ctx context.Context, in *GetContextSourceRequest) (*GetContextSourceResponse, error) + AddContextSource(ctx context.Context, in *AddContextSourceRequest) (*AddContextSourceResponse, error) + RemoveContextSource(ctx context.Context, in *RemoveContextSourceRequest) (*RemoveContextSourceResponse, error) + GetContextSnapshot(ctx context.Context, in *ContextSnapshotRequest) (*ContextSnapshotResponse, error) + ResyncContext(ctx context.Context, in *ResyncContextRequest) (*ResyncContextResponse, error) } type drpcAgentSocketClient struct { @@ -131,6 +137,60 @@ func (c *drpcAgentSocketClient) UpdateAppStatus(ctx context.Context, in *proto1. return out, nil } +func (c *drpcAgentSocketClient) ContextSources(ctx context.Context, in *ContextSourcesRequest) (*ContextSourcesResponse, error) { + out := new(ContextSourcesResponse) + err := c.cc.Invoke(ctx, "/coder.agentsocket.v1.AgentSocket/ContextSources", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}, in, out) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *drpcAgentSocketClient) GetContextSource(ctx context.Context, in *GetContextSourceRequest) (*GetContextSourceResponse, error) { + out := new(GetContextSourceResponse) + err := c.cc.Invoke(ctx, "/coder.agentsocket.v1.AgentSocket/GetContextSource", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}, in, out) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *drpcAgentSocketClient) AddContextSource(ctx context.Context, in *AddContextSourceRequest) (*AddContextSourceResponse, error) { + out := new(AddContextSourceResponse) + err := c.cc.Invoke(ctx, "/coder.agentsocket.v1.AgentSocket/AddContextSource", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}, in, out) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *drpcAgentSocketClient) RemoveContextSource(ctx context.Context, in *RemoveContextSourceRequest) (*RemoveContextSourceResponse, error) { + out := new(RemoveContextSourceResponse) + err := c.cc.Invoke(ctx, "/coder.agentsocket.v1.AgentSocket/RemoveContextSource", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}, in, out) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *drpcAgentSocketClient) GetContextSnapshot(ctx context.Context, in *ContextSnapshotRequest) (*ContextSnapshotResponse, error) { + out := new(ContextSnapshotResponse) + err := c.cc.Invoke(ctx, "/coder.agentsocket.v1.AgentSocket/GetContextSnapshot", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}, in, out) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *drpcAgentSocketClient) ResyncContext(ctx context.Context, in *ResyncContextRequest) (*ResyncContextResponse, error) { + out := new(ResyncContextResponse) + err := c.cc.Invoke(ctx, "/coder.agentsocket.v1.AgentSocket/ResyncContext", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}, in, out) + if err != nil { + return nil, err + } + return out, nil +} + type DRPCAgentSocketServer interface { Ping(context.Context, *PingRequest) (*PingResponse, error) SyncStart(context.Context, *SyncStartRequest) (*SyncStartResponse, error) @@ -140,6 +200,12 @@ type DRPCAgentSocketServer interface { SyncStatus(context.Context, *SyncStatusRequest) (*SyncStatusResponse, error) SyncList(context.Context, *SyncListRequest) (*SyncListResponse, error) UpdateAppStatus(context.Context, *proto1.UpdateAppStatusRequest) (*proto1.UpdateAppStatusResponse, error) + ContextSources(context.Context, *ContextSourcesRequest) (*ContextSourcesResponse, error) + GetContextSource(context.Context, *GetContextSourceRequest) (*GetContextSourceResponse, error) + AddContextSource(context.Context, *AddContextSourceRequest) (*AddContextSourceResponse, error) + RemoveContextSource(context.Context, *RemoveContextSourceRequest) (*RemoveContextSourceResponse, error) + GetContextSnapshot(context.Context, *ContextSnapshotRequest) (*ContextSnapshotResponse, error) + ResyncContext(context.Context, *ResyncContextRequest) (*ResyncContextResponse, error) } type DRPCAgentSocketUnimplementedServer struct{} @@ -176,9 +242,33 @@ func (s *DRPCAgentSocketUnimplementedServer) UpdateAppStatus(context.Context, *p return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented) } +func (s *DRPCAgentSocketUnimplementedServer) ContextSources(context.Context, *ContextSourcesRequest) (*ContextSourcesResponse, error) { + return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented) +} + +func (s *DRPCAgentSocketUnimplementedServer) GetContextSource(context.Context, *GetContextSourceRequest) (*GetContextSourceResponse, error) { + return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented) +} + +func (s *DRPCAgentSocketUnimplementedServer) AddContextSource(context.Context, *AddContextSourceRequest) (*AddContextSourceResponse, error) { + return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented) +} + +func (s *DRPCAgentSocketUnimplementedServer) RemoveContextSource(context.Context, *RemoveContextSourceRequest) (*RemoveContextSourceResponse, error) { + return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented) +} + +func (s *DRPCAgentSocketUnimplementedServer) GetContextSnapshot(context.Context, *ContextSnapshotRequest) (*ContextSnapshotResponse, error) { + return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented) +} + +func (s *DRPCAgentSocketUnimplementedServer) ResyncContext(context.Context, *ResyncContextRequest) (*ResyncContextResponse, error) { + return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented) +} + type DRPCAgentSocketDescription struct{} -func (DRPCAgentSocketDescription) NumMethods() int { return 8 } +func (DRPCAgentSocketDescription) NumMethods() int { return 14 } func (DRPCAgentSocketDescription) Method(n int) (string, drpc.Encoding, drpc.Receiver, interface{}, bool) { switch n { @@ -254,6 +344,60 @@ func (DRPCAgentSocketDescription) Method(n int) (string, drpc.Encoding, drpc.Rec in1.(*proto1.UpdateAppStatusRequest), ) }, DRPCAgentSocketServer.UpdateAppStatus, true + case 8: + return "/coder.agentsocket.v1.AgentSocket/ContextSources", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}, + func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) { + return srv.(DRPCAgentSocketServer). + ContextSources( + ctx, + in1.(*ContextSourcesRequest), + ) + }, DRPCAgentSocketServer.ContextSources, true + case 9: + return "/coder.agentsocket.v1.AgentSocket/GetContextSource", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}, + func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) { + return srv.(DRPCAgentSocketServer). + GetContextSource( + ctx, + in1.(*GetContextSourceRequest), + ) + }, DRPCAgentSocketServer.GetContextSource, true + case 10: + return "/coder.agentsocket.v1.AgentSocket/AddContextSource", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}, + func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) { + return srv.(DRPCAgentSocketServer). + AddContextSource( + ctx, + in1.(*AddContextSourceRequest), + ) + }, DRPCAgentSocketServer.AddContextSource, true + case 11: + return "/coder.agentsocket.v1.AgentSocket/RemoveContextSource", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}, + func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) { + return srv.(DRPCAgentSocketServer). + RemoveContextSource( + ctx, + in1.(*RemoveContextSourceRequest), + ) + }, DRPCAgentSocketServer.RemoveContextSource, true + case 12: + return "/coder.agentsocket.v1.AgentSocket/GetContextSnapshot", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}, + func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) { + return srv.(DRPCAgentSocketServer). + GetContextSnapshot( + ctx, + in1.(*ContextSnapshotRequest), + ) + }, DRPCAgentSocketServer.GetContextSnapshot, true + case 13: + return "/coder.agentsocket.v1.AgentSocket/ResyncContext", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}, + func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) { + return srv.(DRPCAgentSocketServer). + ResyncContext( + ctx, + in1.(*ResyncContextRequest), + ) + }, DRPCAgentSocketServer.ResyncContext, true default: return "", nil, nil, nil, false } @@ -390,3 +534,99 @@ func (x *drpcAgentSocket_UpdateAppStatusStream) SendAndClose(m *proto1.UpdateApp } return x.CloseSend() } + +type DRPCAgentSocket_ContextSourcesStream interface { + drpc.Stream + SendAndClose(*ContextSourcesResponse) error +} + +type drpcAgentSocket_ContextSourcesStream struct { + drpc.Stream +} + +func (x *drpcAgentSocket_ContextSourcesStream) SendAndClose(m *ContextSourcesResponse) error { + if err := x.MsgSend(m, drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}); err != nil { + return err + } + return x.CloseSend() +} + +type DRPCAgentSocket_GetContextSourceStream interface { + drpc.Stream + SendAndClose(*GetContextSourceResponse) error +} + +type drpcAgentSocket_GetContextSourceStream struct { + drpc.Stream +} + +func (x *drpcAgentSocket_GetContextSourceStream) SendAndClose(m *GetContextSourceResponse) error { + if err := x.MsgSend(m, drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}); err != nil { + return err + } + return x.CloseSend() +} + +type DRPCAgentSocket_AddContextSourceStream interface { + drpc.Stream + SendAndClose(*AddContextSourceResponse) error +} + +type drpcAgentSocket_AddContextSourceStream struct { + drpc.Stream +} + +func (x *drpcAgentSocket_AddContextSourceStream) SendAndClose(m *AddContextSourceResponse) error { + if err := x.MsgSend(m, drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}); err != nil { + return err + } + return x.CloseSend() +} + +type DRPCAgentSocket_RemoveContextSourceStream interface { + drpc.Stream + SendAndClose(*RemoveContextSourceResponse) error +} + +type drpcAgentSocket_RemoveContextSourceStream struct { + drpc.Stream +} + +func (x *drpcAgentSocket_RemoveContextSourceStream) SendAndClose(m *RemoveContextSourceResponse) error { + if err := x.MsgSend(m, drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}); err != nil { + return err + } + return x.CloseSend() +} + +type DRPCAgentSocket_GetContextSnapshotStream interface { + drpc.Stream + SendAndClose(*ContextSnapshotResponse) error +} + +type drpcAgentSocket_GetContextSnapshotStream struct { + drpc.Stream +} + +func (x *drpcAgentSocket_GetContextSnapshotStream) SendAndClose(m *ContextSnapshotResponse) error { + if err := x.MsgSend(m, drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}); err != nil { + return err + } + return x.CloseSend() +} + +type DRPCAgentSocket_ResyncContextStream interface { + drpc.Stream + SendAndClose(*ResyncContextResponse) error +} + +type drpcAgentSocket_ResyncContextStream struct { + drpc.Stream +} + +func (x *drpcAgentSocket_ResyncContextStream) SendAndClose(m *ResyncContextResponse) error { + if err := x.MsgSend(m, drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}); err != nil { + return err + } + return x.CloseSend() +} diff --git a/agent/agentsocket/server.go b/agent/agentsocket/server.go index 380b792da1d0c..605feeec05a39 100644 --- a/agent/agentsocket/server.go +++ b/agent/agentsocket/server.go @@ -44,8 +44,9 @@ func NewServer(logger slog.Logger, opts ...Option) (*Server, error) { logger: logger, path: options.path, service: &DRPCAgentSocketService{ - logger: logger, - unitManager: unit.NewManager(), + logger: logger, + unitManager: unit.NewManager(), + contextManager: options.contextManager, }, } diff --git a/agent/agentsocket/service.go b/agent/agentsocket/service.go index 97be1b45c5865..4e6c94fc3f314 100644 --- a/agent/agentsocket/service.go +++ b/agent/agentsocket/service.go @@ -2,12 +2,14 @@ package agentsocket import ( "context" + "encoding/hex" "errors" "sync" "golang.org/x/xerrors" "cdr.dev/slog/v3" + "github.com/coder/coder/v2/agent/agentcontext" "github.com/coder/coder/v2/agent/agentsocket/proto" agentproto "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/agent/unit" @@ -16,14 +18,29 @@ import ( var _ proto.DRPCAgentSocketServer = (*DRPCAgentSocketService)(nil) var ( - ErrUnitManagerNotAvailable = xerrors.New("unit manager not available") - ErrAgentAPINotConnected = xerrors.New("agent not connected to coderd") + ErrUnitManagerNotAvailable = xerrors.New("unit manager not available") + ErrAgentAPINotConnected = xerrors.New("agent not connected to coderd") + ErrContextManagerNotAvailable = xerrors.New("context manager not available") + ErrContextSourceNotFound = xerrors.New("context source not found") ) +// ContextManager is the subset of *agentcontext.Manager the socket +// service needs to serve workspace-context source CRUD. It is an +// interface so tests can supply a fake. +type ContextManager interface { + Sources() []agentcontext.Source + HasSource(path string) (canonical string, ok bool) + AddSource(s agentcontext.Source) (agentcontext.Source, error) + RemoveSource(path string) error + Snapshot() agentcontext.Snapshot + Resync(ctx context.Context) (agentcontext.Snapshot, error) +} + // DRPCAgentSocketService implements the DRPC agent socket service. type DRPCAgentSocketService struct { - unitManager *unit.Manager - logger slog.Logger + unitManager *unit.Manager + contextManager ContextManager + logger slog.Logger mu sync.Mutex agentAPI agentproto.DRPCAgentClient28 @@ -210,3 +227,106 @@ func (s *DRPCAgentSocketService) UpdateAppStatus(ctx context.Context, req *agent } return api.UpdateAppStatus(ctx, req) } + +// ContextSources lists the workspace-context sources registered on the agent. +func (s *DRPCAgentSocketService) ContextSources(_ context.Context, _ *proto.ContextSourcesRequest) (*proto.ContextSourcesResponse, error) { + if s.contextManager == nil { + return nil, ErrContextManagerNotAvailable + } + sources := s.contextManager.Sources() + out := &proto.ContextSourcesResponse{Sources: make([]*proto.ContextSource, 0, len(sources))} + for _, src := range sources { + out.Sources = append(out.Sources, &proto.ContextSource{Path: src.Path}) + } + return out, nil +} + +// GetContextSource returns a single registered source, canonicalizing the +// requested path before matching. +func (s *DRPCAgentSocketService) GetContextSource(_ context.Context, req *proto.GetContextSourceRequest) (*proto.GetContextSourceResponse, error) { + if s.contextManager == nil { + return nil, ErrContextManagerNotAvailable + } + canonical, ok := s.contextManager.HasSource(req.Path) + if !ok { + return nil, xerrors.Errorf("%q: %w", req.Path, ErrContextSourceNotFound) + } + return &proto.GetContextSourceResponse{Source: &proto.ContextSource{Path: canonical}}, nil +} + +// AddContextSource registers a new scan root and triggers a re-resolve. +func (s *DRPCAgentSocketService) AddContextSource(_ context.Context, req *proto.AddContextSourceRequest) (*proto.AddContextSourceResponse, error) { + if s.contextManager == nil { + return nil, ErrContextManagerNotAvailable + } + src, err := s.contextManager.AddSource(agentcontext.Source{Path: req.Path}) + if err != nil { + return nil, xerrors.Errorf("add context source: %w", err) + } + return &proto.AddContextSourceResponse{Source: &proto.ContextSource{Path: src.Path}}, nil +} + +// RemoveContextSource removes a previously-registered scan root. +func (s *DRPCAgentSocketService) RemoveContextSource(_ context.Context, req *proto.RemoveContextSourceRequest) (*proto.RemoveContextSourceResponse, error) { + if s.contextManager == nil { + return nil, ErrContextManagerNotAvailable + } + if err := s.contextManager.RemoveSource(req.Path); err != nil { + if errors.Is(err, agentcontext.ErrSourceNotFound) { + return nil, xerrors.Errorf("%q: %w", req.Path, ErrContextSourceNotFound) + } + return nil, xerrors.Errorf("remove context source: %w", err) + } + return &proto.RemoveContextSourceResponse{}, nil +} + +// GetContextSnapshot returns the agent's current resolved snapshot without +// forcing a re-walk. +func (s *DRPCAgentSocketService) GetContextSnapshot(_ context.Context, _ *proto.ContextSnapshotRequest) (*proto.ContextSnapshotResponse, error) { + if s.contextManager == nil { + return nil, ErrContextManagerNotAvailable + } + return &proto.ContextSnapshotResponse{Snapshot: contextSnapshotToProto(s.contextManager.Snapshot())}, nil +} + +// ResyncContext forces a re-walk and synchronous push, returning the +// resulting snapshot. Callers use it as a barrier before fanning out a +// refresh. +func (s *DRPCAgentSocketService) ResyncContext(ctx context.Context, _ *proto.ResyncContextRequest) (*proto.ResyncContextResponse, error) { + if s.contextManager == nil { + return nil, ErrContextManagerNotAvailable + } + snap, err := s.contextManager.Resync(ctx) + if err != nil { + return nil, xerrors.Errorf("resync context: %w", err) + } + return &proto.ResyncContextResponse{Snapshot: contextSnapshotToProto(snap)}, nil +} + +// contextSnapshotToProto converts an agentcontext.Snapshot to its on-wire +// form. Payload bytes are intentionally omitted; they reach coderd via the +// drpc PushContextState path. +func contextSnapshotToProto(s agentcontext.Snapshot) *proto.ContextSnapshot { + out := &proto.ContextSnapshot{ + Version: s.Version, + AggregateHash: hex.EncodeToString(s.AggregateHash[:]), + Resources: make([]*proto.ContextResource, 0, len(s.Resources)), + PayloadBytes: s.PayloadBytes, + SnapshotError: s.SnapshotError, + } + for _, r := range s.Resources { + out.Resources = append(out.Resources, &proto.ContextResource{ + Id: r.ID, + Kind: r.Kind.String(), + Source: r.Source, + SourcePath: r.SourcePath, + ContentHash: hex.EncodeToString(r.ContentHash[:]), + SizeBytes: r.SizeBytes, + Status: r.Status.String(), + Error: r.Error, + Name: r.Name, + Description: r.Description, + }) + } + return out +} diff --git a/agent/contextmcp.go b/agent/contextmcp.go new file mode 100644 index 0000000000000..7af060be6304b --- /dev/null +++ b/agent/contextmcp.go @@ -0,0 +1,159 @@ +package agent + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "slices" + "strings" + + "github.com/coder/coder/v2/agent/agentcontext" + "github.com/coder/coder/v2/agent/x/agentmcp" + "github.com/coder/coder/v2/codersdk/workspacesdk" +) + +// mcpContextProvider adapts the agent's MCP manager to the +// agentcontext.MCPProvider seam. It reads the manager's per-server +// health snapshot (never blocking) and turns each server into one +// KindMCPServer resource, so live MCP servers and their tools appear +// in the workspace-context snapshot alongside instruction files and +// skills. Servers that failed to connect surface as non-OK resources +// so they are no longer silently dropped. +type mcpContextProvider struct { + // cachedServers returns the current per-server MCP snapshot + // without blocking. It is *agentmcp.Manager.CachedServers in + // production. + cachedServers func() []agentmcp.ServerStatus +} + +// MCPResources implements agentcontext.MCPProvider. It must never block; +// the resolver calls it on every re-resolve. +func (p mcpContextProvider) MCPResources() []agentcontext.Resource { + if p.cachedServers == nil { + return nil + } + return buildMCPServerResources(p.cachedServers()) +} + +// buildMCPServerResources turns a per-server MCP snapshot into one +// KindMCPServer resource per server. Servers are emitted in name order, +// and tools within a server in name order, so the resource ID list and +// content hashes are deterministic across resolves. +// +// A connected server that exposes at least one tool becomes a +// StatusOK resource carrying its tools. A server that failed to connect +// becomes a StatusUnreadable resource carrying the connection error, so +// it appears in the snapshot's issues instead of vanishing. A connected +// server with no tools yet is skipped until its tools arrive (a later +// re-resolve, driven by onToolsChanged, surfaces it). A server's +// .mcp.json entry still appears separately as a KindMCPConfig resource. +func buildMCPServerResources(servers []agentmcp.ServerStatus) []agentcontext.Resource { + if len(servers) == 0 { + return nil + } + sorted := slices.Clone(servers) + slices.SortFunc(sorted, func(a, b agentmcp.ServerStatus) int { + return strings.Compare(a.Name, b.Name) + }) + + resources := make([]agentcontext.Resource, 0, len(sorted)) + for _, s := range sorted { + if s.Name == "" { + continue + } + if !s.Connected { + errMsg := s.Err + if errMsg == "" { + errMsg = "failed to connect" + } + resources = append(resources, agentcontext.Resource{ + ID: resourceID(agentcontext.KindMCPServer, s.Name), + Kind: agentcontext.KindMCPServer, + Source: s.Name, + Name: s.Name, + Status: agentcontext.StatusUnreadable, + Error: errMsg, + ContentHash: hashMCPServerError(s.Name, errMsg), + }) + continue + } + if len(s.Tools) == 0 { + continue + } + serverTools := slices.Clone(s.Tools) + slices.SortFunc(serverTools, func(a, b workspacesdk.MCPToolInfo) int { + return strings.Compare(a.Name, b.Name) + }) + converted := make([]agentcontext.MCPTool, 0, len(serverTools)) + for _, t := range serverTools { + converted = append(converted, agentcontext.MCPTool{ + Name: t.Name, + Description: t.Description, + InputSchema: t.Schema, + }) + } + resources = append(resources, agentcontext.Resource{ + ID: resourceID(agentcontext.KindMCPServer, s.Name), + Kind: agentcontext.KindMCPServer, + Source: s.Name, + Name: s.Name, + Status: agentcontext.StatusOK, + ContentHash: hashMCPServer(s.Name, converted), + Tools: converted, + }) + } + if len(resources) == 0 { + return nil + } + return resources +} + +// resourceID mirrors agentcontext's unexported ID scheme +// (":") so MCP server resources sort and dedup +// consistently with filesystem-resolved resources. +func resourceID(kind agentcontext.ResourceKind, source string) string { + return kind.String() + ":" + source +} + +// hashMCPServer produces a deterministic content hash over a server's +// identity and full tool set (name, description, and input schema) so any +// tool-set change flips the snapshot's aggregate hash and re-pins dirty +// chats. The schema is encoded with encoding/json, which sorts map keys. +func hashMCPServer(server string, tools []agentcontext.MCPTool) [32]byte { + h := sha256.New() + writeHashField(h, server) + for _, t := range tools { + writeHashField(h, t.Name) + writeHashField(h, t.Description) + if len(t.InputSchema) > 0 { + if schema, err := json.Marshal(t.InputSchema); err == nil { + writeHashField(h, string(schema)) + } + } + } + var sum [32]byte + copy(sum[:], h.Sum(nil)) + return sum +} + +// hashMCPServerError produces a deterministic content hash for a +// failed-to-connect server. The "unreadable" discriminator keeps a +// failed server's hash distinct from an OK server's, so a server that +// transitions between connected and failed (or whose error text +// changes) flips the aggregate hash and re-pins dirty chats. +func hashMCPServerError(server, errMsg string) [32]byte { + h := sha256.New() + writeHashField(h, "unreadable") + writeHashField(h, server) + writeHashField(h, errMsg) + var sum [32]byte + copy(sum[:], h.Sum(nil)) + return sum +} + +// writeHashField writes a length-prefixed field so adjacent fields cannot +// be confused by concatenation (e.g. "ab"+"c" vs "a"+"bc"). +func writeHashField(h io.Writer, s string) { + _, _ = fmt.Fprintf(h, "%d:%s", len(s), s) +} diff --git a/agent/contextmcp_internal_test.go b/agent/contextmcp_internal_test.go new file mode 100644 index 0000000000000..8fbe651008f4a --- /dev/null +++ b/agent/contextmcp_internal_test.go @@ -0,0 +1,202 @@ +package agent + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/agent/agentcontext" + "github.com/coder/coder/v2/agent/x/agentmcp" + "github.com/coder/coder/v2/codersdk/workspacesdk" +) + +func TestBuildMCPServerResources(t *testing.T) { + t.Parallel() + + t.Run("Empty", func(t *testing.T) { + t.Parallel() + require.Nil(t, buildMCPServerResources(nil)) + require.Nil(t, buildMCPServerResources([]agentmcp.ServerStatus{})) + }) + + t.Run("GroupsByServerSortedWithTools", func(t *testing.T) { + t.Parallel() + servers := []agentmcp.ServerStatus{ + {Name: "github", Connected: true, Tools: []workspacesdk.MCPToolInfo{ + {Name: "github__search", Description: "Search"}, + {Name: "github__create", Description: "Create"}, + }}, + {Name: "fs", Connected: true, Tools: []workspacesdk.MCPToolInfo{ + {Name: "fs__read", Description: "Read", Schema: map[string]any{"type": "object"}}, + }}, + // Dropped: a server with no name cannot be addressed. + {Name: "", Connected: true, Tools: []workspacesdk.MCPToolInfo{{Name: "orphan"}}}, + } + got := buildMCPServerResources(servers) + require.Len(t, got, 2) + + // Servers are emitted in name order: fs, then github. + require.Equal(t, "fs", got[0].Source) + require.Equal(t, "fs", got[0].Name) + require.Equal(t, agentcontext.KindMCPServer, got[0].Kind) + require.Equal(t, "mcp_server:fs", got[0].ID) + require.Equal(t, agentcontext.StatusOK, got[0].Status) + require.NotEqual(t, [32]byte{}, got[0].ContentHash) + require.Len(t, got[0].Tools, 1) + require.Equal(t, "fs__read", got[0].Tools[0].Name) + require.Equal(t, map[string]any{"type": "object"}, got[0].Tools[0].InputSchema) + + require.Equal(t, "github", got[1].Source) + require.Len(t, got[1].Tools, 2) + // Tools within a server are sorted by name: create, then search. + require.Equal(t, "github__create", got[1].Tools[0].Name) + require.Equal(t, "github__search", got[1].Tools[1].Name) + }) + + t.Run("ConnectedWithoutToolsSkipped", func(t *testing.T) { + t.Parallel() + // A connected server that has not yet reported any tools is + // not surfaced; a later re-resolve picks it up once tools + // arrive. + require.Nil(t, buildMCPServerResources([]agentmcp.ServerStatus{ + {Name: "fs", Connected: true}, + })) + }) + + t.Run("FailedServerSurfacesAsIssue", func(t *testing.T) { + t.Parallel() + got := buildMCPServerResources([]agentmcp.ServerStatus{ + {Name: "broken", Connected: false, Err: "initialize \"broken\": exec: no such file"}, + }) + require.Len(t, got, 1) + require.Equal(t, agentcontext.KindMCPServer, got[0].Kind) + require.Equal(t, "broken", got[0].Source) + require.Equal(t, "broken", got[0].Name) + require.Equal(t, "mcp_server:broken", got[0].ID) + require.Equal(t, agentcontext.StatusUnreadable, got[0].Status) + require.Equal(t, "initialize \"broken\": exec: no such file", got[0].Error) + require.Empty(t, got[0].Tools) + require.NotEqual(t, [32]byte{}, got[0].ContentHash) + }) + + t.Run("FailedServerWithoutErrorGetsDefault", func(t *testing.T) { + t.Parallel() + got := buildMCPServerResources([]agentmcp.ServerStatus{ + {Name: "broken", Connected: false}, + }) + require.Len(t, got, 1) + require.Equal(t, agentcontext.StatusUnreadable, got[0].Status) + require.Equal(t, "failed to connect", got[0].Error) + }) + + t.Run("ContentHashStableAndToolSensitive", func(t *testing.T) { + t.Parallel() + base := []agentmcp.ServerStatus{ + {Name: "fs", Connected: true, Tools: []workspacesdk.MCPToolInfo{ + {Name: "fs__read", Description: "Read"}, + }}, + } + h1 := buildMCPServerResources(base)[0].ContentHash + // Identical input is hashed identically. + require.Equal(t, h1, buildMCPServerResources(base)[0].ContentHash) + // A description change flips the hash. + require.NotEqual(t, h1, buildMCPServerResources([]agentmcp.ServerStatus{ + {Name: "fs", Connected: true, Tools: []workspacesdk.MCPToolInfo{ + {Name: "fs__read", Description: "Read files"}, + }}, + })[0].ContentHash) + // Adding a tool flips the hash. + require.NotEqual(t, h1, buildMCPServerResources([]agentmcp.ServerStatus{ + {Name: "fs", Connected: true, Tools: []workspacesdk.MCPToolInfo{ + {Name: "fs__read", Description: "Read"}, + {Name: "fs__write", Description: "Write"}, + }}, + })[0].ContentHash) + // A schema change flips the hash. + require.NotEqual(t, h1, buildMCPServerResources([]agentmcp.ServerStatus{ + {Name: "fs", Connected: true, Tools: []workspacesdk.MCPToolInfo{ + {Name: "fs__read", Description: "Read", Schema: map[string]any{"type": "object"}}, + }}, + })[0].ContentHash) + }) + + t.Run("FailedServerHashErrorSensitive", func(t *testing.T) { + t.Parallel() + h1 := buildMCPServerResources([]agentmcp.ServerStatus{ + {Name: "fs", Connected: false, Err: "boom"}, + })[0].ContentHash + // The error text participates in the hash so a changed error + // re-pins dirty chats. + require.NotEqual(t, h1, buildMCPServerResources([]agentmcp.ServerStatus{ + {Name: "fs", Connected: false, Err: "different"}, + })[0].ContentHash) + // A failed server hashes differently from a connected one, so + // the connected->failed transition flips the aggregate hash. + require.NotEqual(t, h1, buildMCPServerResources([]agentmcp.ServerStatus{ + {Name: "fs", Connected: true, Tools: []workspacesdk.MCPToolInfo{ + {Name: "fs__read", Description: "boom"}, + }}, + })[0].ContentHash) + }) + + t.Run("ProviderDelegates", func(t *testing.T) { + t.Parallel() + // A nil cache source yields no resources rather than panicking. + require.Nil(t, mcpContextProvider{}.MCPResources()) + + p := mcpContextProvider{cachedServers: func() []agentmcp.ServerStatus { + return []agentmcp.ServerStatus{ + {Name: "fs", Connected: true, Tools: []workspacesdk.MCPToolInfo{{Name: "fs__read"}}}, + {Name: "broken", Connected: false, Err: "nope"}, + } + }} + got := p.MCPResources() + require.Len(t, got, 2) + // Emitted in name order: broken (failed), then fs (ok). + require.Equal(t, "broken", got[0].Source) + require.Equal(t, agentcontext.StatusUnreadable, got[0].Status) + require.Equal(t, "fs", got[1].Source) + require.Equal(t, agentcontext.StatusOK, got[1].Status) + }) +} + +func TestMCPConfigPaths(t *testing.T) { + t.Parallel() + + t.Run("OnlySnapshotSources", func(t *testing.T) { + t.Parallel() + // Regression for the case where the manifest working directory is + // empty (so the statically configured set is empty) but a + // .mcp.json was contributed by a context source added at runtime. + // The MCP manager must still be told about it. + got := mcpConfigPaths(nil, agentcontext.Snapshot{Resources: []agentcontext.Resource{ + {Kind: agentcontext.KindMCPConfig, Source: "/home/coder/test/.mcp.json"}, + }}) + require.Equal(t, []string{"/home/coder/test/.mcp.json"}, got) + }) + + t.Run("UnionSortedDeduped", func(t *testing.T) { + t.Parallel() + snap := agentcontext.Snapshot{Resources: []agentcontext.Resource{ + {Kind: agentcontext.KindMCPConfig, Source: "/work/.mcp.json"}, + {Kind: agentcontext.KindMCPConfig, Source: "/added/.mcp.json"}, + // Same path as a configured entry: deduped. + {Kind: agentcontext.KindMCPConfig, Source: "/cfg/.mcp.json"}, + // Other kinds and empty sources are ignored. + {Kind: agentcontext.KindInstructionFile, Source: "/work/AGENTS.md"}, + {Kind: agentcontext.KindMCPServer, Source: "go-language-server"}, + {Kind: agentcontext.KindMCPConfig, Source: ""}, + }} + got := mcpConfigPaths([]string{"/cfg/.mcp.json", "/work/.mcp.json"}, snap) + require.Equal(t, []string{ + "/added/.mcp.json", + "/cfg/.mcp.json", + "/work/.mcp.json", + }, got) + }) + + t.Run("Empty", func(t *testing.T) { + t.Parallel() + require.Empty(t, mcpConfigPaths(nil, agentcontext.Snapshot{})) + }) +} diff --git a/agent/x/agentmcp/api_internal_test.go b/agent/x/agentmcp/api_internal_test.go index 42689475119b1..d507043d623be 100644 --- a/agent/x/agentmcp/api_internal_test.go +++ b/agent/x/agentmcp/api_internal_test.go @@ -174,7 +174,7 @@ func TestHandleListTools_ReloadsAfterStartupSettled(t *testing.T) { t.Cleanup(func() { _ = m.Close() }) // No prior m.Reload: snapshot empty and tools unset. - require.Empty(t, m.cachedTools(), "manager should start with no tools") + require.Empty(t, m.CachedTools(), "manager should start with no tools") api := NewAPI(logger, m, func() []string { return []string{configPath} diff --git a/agent/x/agentmcp/cachedservers_internal_test.go b/agent/x/agentmcp/cachedservers_internal_test.go new file mode 100644 index 0000000000000..1d5bdb75e8dd5 --- /dev/null +++ b/agent/x/agentmcp/cachedservers_internal_test.go @@ -0,0 +1,69 @@ +package agentmcp + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3" + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/agent/agentexec" + "github.com/coder/coder/v2/testutil" +) + +// TestManager_CachedServers verifies the non-blocking CachedServers +// accessor reports every configured server, including one that failed +// to connect (which never enters m.servers and would otherwise be +// invisible to the workspace-context snapshot), and that a closed +// manager reports no servers. +func TestManager_CachedServers(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) + dir := t.TempDir() + + m := NewManager(ctx, logger, agentexec.DefaultExecer, nil) + m.MarkStartupSettled() + t.Cleanup(func() { _ = m.Close() }) + + // CachedServers never blocks and is empty before the first reload. + require.Empty(t, m.CachedServers()) + + // One healthy server (re-exec fake) plus one whose binary does + // not exist, so its connect fails. + _, good := fakeMCPServerConfig(t, "good") + bad := mcpServerEntry{Command: "/nonexistent/agentmcp-binary"} + configPath := writeMCPConfig(t, dir, map[string]mcpServerEntry{ + "good": good, + "bad": bad, + }) + + // Reload succeeds: per-server connect failures are logged and + // swallowed, not fatal. + require.NoError(t, m.Reload(ctx, []string{configPath})) + + byName := make(map[string]ServerStatus) + for _, s := range m.CachedServers() { + byName[s.Name] = s + } + require.Len(t, byName, 2, "both configured servers must be reported") + + gotGood, ok := byName["good"] + require.True(t, ok) + require.True(t, gotGood.Connected) + require.Empty(t, gotGood.Err) + require.Len(t, gotGood.Tools, 1, "connected server exposes its tools") + require.True(t, strings.HasPrefix(gotGood.Tools[0].Name, "good"+ToolNameSep)) + + gotBad, ok := byName["bad"] + require.True(t, ok, "a server that failed to connect must still be reported") + require.False(t, gotBad.Connected) + require.NotEmpty(t, gotBad.Err, "failed server must carry a connect error") + require.Empty(t, gotBad.Tools) + + // After Close the manager reports no servers. + require.NoError(t, m.Close()) + require.Empty(t, m.CachedServers()) +} diff --git a/agent/x/agentmcp/cachedtools_internal_test.go b/agent/x/agentmcp/cachedtools_internal_test.go new file mode 100644 index 0000000000000..df67e22a8d154 --- /dev/null +++ b/agent/x/agentmcp/cachedtools_internal_test.go @@ -0,0 +1,52 @@ +package agentmcp + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3" + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/agent/agentexec" + "github.com/coder/coder/v2/testutil" +) + +// TestManager_CachedToolsAndOnToolsChanged verifies the non-blocking +// CachedTools accessor returns an independent copy of the cache and that a +// reload which writes the cache fires the onToolsChanged hook (used to +// re-resolve the workspace-context snapshot). +func TestManager_CachedToolsAndOnToolsChanged(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) + dir := t.TempDir() + + m := NewManager(ctx, logger, agentexec.DefaultExecer, nil) + m.MarkStartupSettled() + t.Cleanup(func() { _ = m.Close() }) + + // CachedTools never blocks and is empty before the first reload. + require.Empty(t, m.CachedTools()) + + changed := make(chan struct{}, 8) + m.SetOnToolsChanged(func() { changed <- struct{}{} }) + + _, entry := fakeMCPServerConfig(t, "srv") + configPath := writeMCPConfig(t, dir, map[string]mcpServerEntry{"srv": entry}) + + tools, err := m.Tools(ctx, []string{configPath}) + require.NoError(t, err) + require.Len(t, tools, 1) + + // The reload that produced the tools fired the change hook. + testutil.RequireReceive(ctx, t, changed) + + // CachedTools reflects the same set, returned as an independent copy + // so callers cannot mutate the manager's cache. + cached := m.CachedTools() + require.Len(t, cached, 1) + require.Equal(t, tools[0].Name, cached[0].Name) + cached[0].Name = "mutated" + require.NotEqual(t, "mutated", m.CachedTools()[0].Name) +} diff --git a/agent/x/agentmcp/configwatcher_internal_test.go b/agent/x/agentmcp/configwatcher_internal_test.go index 4b93242ed35af..07a8245d8ed12 100644 --- a/agent/x/agentmcp/configwatcher_internal_test.go +++ b/agent/x/agentmcp/configwatcher_internal_test.go @@ -38,7 +38,7 @@ func awaitTools(ctx context.Context, t *testing.T, m *Manager, pred func([]works t.Helper() var final []workspacesdk.MCPToolInfo testutil.Eventually(ctx, t, func(context.Context) bool { - final = m.cachedTools() + final = m.CachedTools() return pred(final) }, testutil.IntervalFast) return final @@ -74,7 +74,7 @@ func TestWatcher_LateFileTriggersReload(t *testing.T) { // First Reload arms the watcher but finds nothing on disk. require.NoError(t, m.Reload(ctx, []string{configPath})) - require.Empty(t, m.cachedTools(), "manager should start with no tools") + require.Empty(t, m.CachedTools(), "manager should start with no tools") // Write the file after the manager has already settled. The // watcher must observe the Create event, debounce it, and @@ -114,7 +114,7 @@ func TestWatcher_RewriteTriggersReload(t *testing.T) { t.Cleanup(func() { _ = m.Close() }) require.NoError(t, m.Reload(ctx, []string{configPath})) - tools := m.cachedTools() + tools := m.CachedTools() require.Len(t, tools, 1) assert.Contains(t, tools[0].Name, "srv") @@ -153,14 +153,14 @@ func TestWatcher_RemovalTransitionsToEmpty(t *testing.T) { t.Cleanup(func() { _ = m.Close() }) require.NoError(t, m.Reload(ctx, []string{configPath})) - require.Len(t, m.cachedTools(), 1) + require.Len(t, m.CachedTools(), 1) require.NoError(t, os.Remove(configPath)) awaitTools(ctx, t, m, func(tools []workspacesdk.MCPToolInfo) bool { return len(tools) == 0 }) - assert.Empty(t, m.cachedTools()) + assert.Empty(t, m.CachedTools()) } // TestWatcher_DebouncesBurst uses the quartz mock clock to @@ -288,7 +288,7 @@ func TestWatcher_DualAgentHTTPNoStall(t *testing.T) { // First Reload races ahead of the host agent: empty config. require.NoError(t, m.Reload(ctx, []string{configPath})) - require.Empty(t, m.cachedTools()) + require.Empty(t, m.CachedTools()) api := NewAPI(logger, m, func() []string { return []string{configPath} }) @@ -347,7 +347,7 @@ func TestWatcher_LateParentDirTriggersReload(t *testing.T) { t.Cleanup(func() { _ = m.Close() }) require.NoError(t, m.Reload(ctx, []string{configPath})) - require.Empty(t, m.cachedTools()) + require.Empty(t, m.CachedTools()) // Create the missing parent directory. fsnotify will deliver // a Create event on root; handleEvent must release the root diff --git a/agent/x/agentmcp/manager.go b/agent/x/agentmcp/manager.go index cd6a7051515fd..30993b16d577c 100644 --- a/agent/x/agentmcp/manager.go +++ b/agent/x/agentmcp/manager.go @@ -128,6 +128,40 @@ type Manager struct { // in-flight reload (for example, to verify Close()'s // shutdown ordering does not stall on a stuck connect). connectStartedHook func() + + // onToolsChanged is invoked (outside m.mu) after a reload + // writes a new tool cache, letting listeners such as the + // agentcontext manager re-resolve so MCP server resources + // track the live tool set. Guarded by m.mu; nil until set + // via SetOnToolsChanged. + onToolsChanged func() + + // serverHealth records, per configured server name, whether the + // last reload connected it and the connect error otherwise. It + // is rebuilt at the end of every reload and read (joined with + // the tool cache) by CachedServers so failed-to-connect servers + // surface in the workspace-context snapshot instead of vanishing. + // Guarded by m.mu. + serverHealth map[string]serverHealthEntry +} + +// serverHealthEntry is the per-server connection outcome recorded +// after a reload. err is non-empty only when connected is false. +type serverHealthEntry struct { + connected bool + err string +} + +// ServerStatus is a non-blocking snapshot of a single configured MCP +// server's connection health and current tool set, suitable for +// surfacing in the workspace-context UI. Connected servers carry their +// Tools (possibly empty before the first tool list arrives); servers +// that failed to connect carry a non-empty Err and no Tools. +type ServerStatus struct { + Name string + Connected bool + Err string + Tools []workspacesdk.MCPToolInfo } // serverEntry pairs a server config with its connected client. @@ -155,6 +189,7 @@ func NewManager( execer: execer, updateEnv: updateEnv, servers: make(map[string]*serverEntry), + serverHealth: make(map[string]serverHealthEntry), snapshot: make(map[string]fileSnapshot), startupSettled: make(chan struct{}), closedCh: make(chan struct{}), @@ -189,6 +224,17 @@ func (m *Manager) MarkStartupSettled() { m.startupOnce.Do(func() { close(m.startupSettled) }) } +// SetOnToolsChanged registers a callback invoked after a reload +// updates the cached tool set. The callback runs outside the +// manager lock and must not block; it is typically wired to the +// agentcontext manager's Trigger so MCP server resources are +// re-resolved when tools change. Passing nil clears the hook. +func (m *Manager) SetOnToolsChanged(fn func()) { + m.mu.Lock() + defer m.mu.Unlock() + m.onToolsChanged = fn +} + // Tools returns the current MCP tool cache after startup-safe config // synchronization. // @@ -209,13 +255,13 @@ func (m *Manager) Tools(ctx context.Context, paths []string) ([]workspacesdk.MCP return m.toolsAfterReloadError(err) } if !started { - return normalizeTools(m.cachedTools()), nil + return normalizeTools(m.CachedTools()), nil } if err := m.waitReload(ctx, ch, toolsReloadTimeout); err != nil { return m.toolsAfterReloadError(err) } - return normalizeTools(m.cachedTools()), nil + return normalizeTools(m.CachedTools()), nil } func (m *Manager) waitForStartupSettled(ctx context.Context) error { @@ -508,13 +554,19 @@ func (m *Manager) doReload(ctx context.Context, mcpConfigFiles []string) error { return err } - connected := m.connectAll(ctx, diff.toConnect) + connected, failedConnects := m.connectAll(ctx, diff.toConnect) replaced, err := m.installServers(wanted, diff, connected, snap) if err != nil { return err } + // Record per-server connection health after installServers + // commits the new server map, so CachedServers can surface + // servers that failed to connect (which never enter m.servers + // and would otherwise vanish from the snapshot). + m.recordServerHealth(wanted, failedConnects) + // Close removed and replaced servers outside the lock to // avoid leaking child processes and to avoid blocking // concurrent readers on subprocess I/O. @@ -618,8 +670,10 @@ func (m *Manager) classifyServers(wanted map[string]ServerConfig) (*serverDiff, } // connectAll runs connectServer in parallel for the given configs. -// Failed connects are logged and skipped. -func (m *Manager) connectAll(ctx context.Context, toConnect []ServerConfig) []connectedServer { +// Failed connects are logged and skipped; their server name and +// error string are returned in the failed map so the caller can +// record per-server health for the workspace-context snapshot. +func (m *Manager) connectAll(ctx context.Context, toConnect []ServerConfig) ([]connectedServer, map[string]string) { logger := m.logger.With(agentchat.Fields(ctx)...) if hook := m.connectStartedHook; hook != nil { @@ -629,6 +683,7 @@ func (m *Manager) connectAll(ctx context.Context, toConnect []ServerConfig) []co var ( mu sync.Mutex connected []connectedServer + failed = make(map[string]string) ) var eg errgroup.Group for _, cfg := range toConnect { @@ -640,6 +695,9 @@ func (m *Manager) connectAll(ctx context.Context, toConnect []ServerConfig) []co slog.F("transport", cfg.Transport), slog.Error(err), ) + mu.Lock() + failed[cfg.Name] = err.Error() + mu.Unlock() return nil // Don't fail the group. } mu.Lock() @@ -651,7 +709,7 @@ func (m *Manager) connectAll(ctx context.Context, toConnect []ServerConfig) []co }) } _ = eg.Wait() - return connected + return connected, failed } // installServers builds the new server map from diff.keep and the @@ -728,14 +786,76 @@ func captureSnapshot(paths []string) map[string]fileSnapshot { return snap } -// cachedTools returns the cached tool list. Thread-safe. -func (m *Manager) cachedTools() []workspacesdk.MCPToolInfo { +// CachedTools returns a copy of the current tool cache. It is +// thread-safe and non-blocking (no startup-settle wait or reload), +// intended for callers that must never block, e.g. the agentcontext +// resolver's MCP provider invoked on every re-resolve. The cache is +// empty until the first reload completes; SetOnToolsChanged lets +// callers learn when it is populated. +func (m *Manager) CachedTools() []workspacesdk.MCPToolInfo { m.mu.RLock() defer m.mu.RUnlock() return slices.Clone(m.tools) } +// recordServerHealth rebuilds the per-server health map from the +// committed server set. A wanted server present in m.servers is +// connected (a connect that failed but retained a prior working +// client still counts as connected); one that is absent failed to +// connect and carries its connect error. Read by CachedServers. +func (m *Manager) recordServerHealth(wanted map[string]ServerConfig, failedConnects map[string]string) { + m.mu.Lock() + defer m.mu.Unlock() + + // A concurrent Close clears the server set and health; do not + // repopulate after that. + if m.closed { + return + } + + health := make(map[string]serverHealthEntry, len(wanted)) + for name := range wanted { + if _, ok := m.servers[name]; ok { + health[name] = serverHealthEntry{connected: true} + continue + } + msg := failedConnects[name] + if msg == "" { + msg = "failed to connect" + } + health[name] = serverHealthEntry{connected: false, err: msg} + } + m.serverHealth = health +} + +// CachedServers returns a non-blocking per-server snapshot joining the +// recorded connection health with the cached tool set. Connected +// servers carry their current tools; servers that failed to connect +// carry a non-empty Err and no tools. Like CachedTools it never blocks +// (no startup-settle wait or reload) and is intended for the +// agentcontext MCP provider, which runs on every re-resolve. +func (m *Manager) CachedServers() []ServerStatus { + m.mu.RLock() + defer m.mu.RUnlock() + + byServer := make(map[string][]workspacesdk.MCPToolInfo, len(m.serverHealth)) + for _, t := range m.tools { + byServer[t.ServerName] = append(byServer[t.ServerName], t) + } + + out := make([]ServerStatus, 0, len(m.serverHealth)) + for name, h := range m.serverHealth { + out = append(out, ServerStatus{ + Name: name, + Connected: h.connected, + Err: h.err, + Tools: slices.Clone(byServer[name]), + }) + } + return out +} + // CallTool proxies a tool call to the appropriate MCP server. func (m *Manager) CallTool(ctx context.Context, req workspacesdk.CallMCPToolRequest) (workspacesdk.CallMCPToolResponse, error) { serverName, originalName, err := splitToolName(req.ToolName) @@ -858,11 +978,21 @@ func (m *Manager) RefreshTools(ctx context.Context) error { // Skip the write if the server map changed since the // snapshot. A doReload that bumped the generation will // produce a correct tool list; this write would be stale. + changed := false if m.serverGen == gen { m.tools = merged + changed = true } + cb := m.onToolsChanged m.mu.Unlock() + // Notify listeners outside the lock so a re-resolve can pick up + // the new tool set. Fired only when this reload actually wrote + // the cache; listeners dedupe via the snapshot aggregate hash. + if changed && cb != nil { + cb() + } + return errors.Join(errs...) } @@ -908,6 +1038,9 @@ func (m *Manager) Close() error { } } m.servers = make(map[string]*serverEntry) + // Drop recorded health so a closed manager reports no servers + // via CachedServers. + m.serverHealth = nil // Prevent an in-flight RefreshTools from repopulating tools // after Close clears the cache. m.serverGen++ diff --git a/agent/x/agentmcp/reload_internal_test.go b/agent/x/agentmcp/reload_internal_test.go index 1557b336e8fee..a68e7d746d07e 100644 --- a/agent/x/agentmcp/reload_internal_test.go +++ b/agent/x/agentmcp/reload_internal_test.go @@ -220,7 +220,7 @@ func TestSnapshotChanged_MultipleConfigFiles(t *testing.T) { require.NoError(t, err) // Tools from both files should be present. - tools := m.cachedTools() + tools := m.CachedTools() require.Len(t, tools, 2, "should have tools from both config files") assert.Contains(t, tools[0].Name, "srv1", "first tool should be from first config") @@ -246,7 +246,7 @@ func TestReload(t *testing.T) { err := m.Reload(ctx, []string{configPath}) require.NoError(t, err) - tools := m.cachedTools() + tools := m.CachedTools() require.Len(t, tools, 1, "should have one tool from the fake server") assert.Contains(t, tools[0].Name, "echo") @@ -293,7 +293,7 @@ func TestReload(t *testing.T) { assert.NoError(t, err, "caller %d should not fail", i) } - tools := m.cachedTools() + tools := m.CachedTools() require.Len(t, tools, 1) }) @@ -340,7 +340,7 @@ func TestReload(t *testing.T) { // First reload. err := m.Reload(ctx, []string{configPath}) require.NoError(t, err) - tools1 := m.cachedTools() + tools1 := m.CachedTools() require.Len(t, tools1, 1) assert.Contains(t, tools1[0].Name, "srv1") @@ -352,7 +352,7 @@ func TestReload(t *testing.T) { assert.True(t, m.SnapshotChanged([]string{configPath})) err = m.Reload(ctx, []string{configPath}) require.NoError(t, err) - tools2 := m.cachedTools() + tools2 := m.CachedTools() require.Len(t, tools2, 1) assert.Contains(t, tools2[0].Name, "srv2") }) @@ -393,14 +393,14 @@ func TestReload(t *testing.T) { err := m.Reload(ctx, []string{configPath}) require.NoError(t, err) - require.Len(t, m.cachedTools(), 1) + require.Len(t, m.CachedTools(), 1) // Delete config file. require.NoError(t, os.Remove(configPath)) err = m.Reload(ctx, []string{configPath}) require.NoError(t, err) - assert.Empty(t, m.cachedTools(), "tools should be empty after config deleted") + assert.Empty(t, m.CachedTools(), "tools should be empty after config deleted") // Subsequent reload finds snapshot unchanged. assert.False(t, m.SnapshotChanged([]string{configPath})) @@ -451,7 +451,7 @@ func TestDifferentialReload(t *testing.T) { "unchanged server should reuse client pointer") // Both servers should have tools. - tools := m.cachedTools() + tools := m.CachedTools() require.Len(t, tools, 2) }) @@ -505,7 +505,7 @@ func TestDifferentialReload(t *testing.T) { err := m.Reload(ctx, []string{configPath}) require.NoError(t, err) - require.Len(t, m.cachedTools(), 2) + require.Len(t, m.CachedTools(), 2) // Capture srvB's client before removal. m.mu.RLock() @@ -519,7 +519,7 @@ func TestDifferentialReload(t *testing.T) { err = m.Reload(ctx, []string{configPath}) require.NoError(t, err) - tools := m.cachedTools() + tools := m.CachedTools() require.Len(t, tools, 1) assert.Contains(t, tools[0].Name, "srvA") @@ -545,7 +545,7 @@ func TestDifferentialReload(t *testing.T) { err := m.Reload(ctx, []string{configPath}) require.NoError(t, err) - require.Len(t, m.cachedTools(), 1) + require.Len(t, m.CachedTools(), 1) m.mu.RLock() origClient := m.servers["srv"].client @@ -568,7 +568,7 @@ func TestDifferentialReload(t *testing.T) { "failed connect should retain old client") // Tools should still work. - tools := m.cachedTools() + tools := m.CachedTools() require.Len(t, tools, 1) }) @@ -586,7 +586,7 @@ func TestDifferentialReload(t *testing.T) { err := m.Reload(ctx, []string{configPath}) require.NoError(t, err) - tools := m.cachedTools() + tools := m.CachedTools() require.Len(t, tools, 1) toolName := tools[0].Name @@ -637,7 +637,7 @@ func TestReload_FirstBootPath(t *testing.T) { err := m.Reload(ctx, []string{configPath}) require.NoError(t, err) - tools := m.cachedTools() + tools := m.CachedTools() require.Len(t, tools, 1) assert.Contains(t, tools[0].Name, "echo") } @@ -709,7 +709,7 @@ func TestClose_SuppressesSubprocessExitError(t *testing.T) { err := m.Reload(ctx, []string{configPath}) require.NoError(t, err) - require.Len(t, m.cachedTools(), 1, "server should be connected") + require.Len(t, m.CachedTools(), 1, "server should be connected") // Close kills the subprocess. The ExitError guard should // suppress the "signal: killed" error. diff --git a/cli/exp_chat.go b/cli/exp_chat.go index 61c017f172e5f..3e23139a695ce 100644 --- a/cli/exp_chat.go +++ b/cli/exp_chat.go @@ -1,14 +1,19 @@ package cli import ( + "context" "fmt" "os" "path/filepath" + "strings" "github.com/google/uuid" "golang.org/x/xerrors" "github.com/coder/coder/v2/agent/agentcontextconfig" + "github.com/coder/coder/v2/agent/agentsocket" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/serpent" ) @@ -28,103 +33,367 @@ func (r *RootCmd) chatCommand() *serpent.Command { } func (r *RootCmd) chatContextCommand() *serpent.Command { + // socketPath is shared by the in-workspace source commands (list, show, + // add, remove) and the no-argument refresh, which all talk to the agent's + // local IPC socket. + var socketPath string return &serpent.Command{ Use: "context", - Short: "Manage chat context", - Long: "Add or clear context files and skills for an active chat session.", + Short: "Manage workspace context", + Long: "Inspect and manage the workspace context sources (instruction files, " + + "skills, and MCP configs) the agent resolves, and refresh a chat to the " + + "agent's latest snapshot.\n\nThe list, show, add, and remove commands manage " + + "agent-local sources and must be run from inside the workspace.", Handler: func(i *serpent.Invocation) error { return i.Command.HelpHandler(i) }, Children: []*serpent.Command{ - r.chatContextAddCommand(), + r.chatContextListCommand(&socketPath), + r.chatContextShowCommand(&socketPath), + r.chatContextAddCommand(&socketPath), + r.chatContextRemoveCommand(&socketPath), + r.chatContextRefreshCommand(&socketPath), r.chatContextClearCommand(), }, + Options: serpent.OptionSet{{ + Flag: "socket-path", + Env: "CODER_AGENT_SOCKET_PATH", + Description: "Path to the agent socket used by the in-workspace source commands.", + Value: serpent.StringOf(&socketPath), + }}, + } +} + +// resolveContextSourcePath makes a user-supplied source path absolute so the +// agent (which requires absolute, canonical paths) accepts it. A leading ~ is +// preserved for the agent to expand against its own home directory; other +// relative paths are resolved against the CLI's working directory, which shares +// the workspace filesystem with the agent. +func resolveContextSourcePath(p string) (string, error) { + p = strings.TrimSpace(p) + if p == "" { + return "", xerrors.New("path is empty") + } + if p == "~" || strings.HasPrefix(p, "~/") { + return p, nil + } + abs, err := filepath.Abs(p) + if err != nil { + return "", xerrors.Errorf("resolve path %q: %w", p, err) + } + return abs, nil +} + +// dialAgentContextSocket connects to the workspace agent's local IPC socket. +// It is only reachable from inside the workspace. +func dialAgentContextSocket(ctx context.Context, socketPath string) (*agentsocket.Client, error) { + opts := []agentsocket.Option{} + if socketPath != "" { + opts = append(opts, agentsocket.WithPath(socketPath)) + } + client, err := agentsocket.NewClient(ctx, opts...) + if err != nil { + return nil, xerrors.Errorf("connect to agent socket (run this from inside the workspace): %w", err) + } + return client, nil +} + +func (*RootCmd) chatContextListCommand(socketPath *string) *serpent.Command { + formatter := cliui.NewOutputFormatter( + cliui.TableFormat([]agentsocket.ContextSource{}, []string{"path"}), + cliui.JSONFormat(), + ) + cmd := &serpent.Command{ + Use: "list", + Short: "List the workspace context sources registered on the agent", + Long: "List the additional scan roots registered on this workspace's agent. " + + "Built-in defaults (the working directory, ~/.coder, ~/.claude) are always " + + "scanned and are not shown here.\n\nMust be run from inside the workspace.", + Middleware: serpent.RequireNArgs(0), + Handler: func(inv *serpent.Invocation) error { + ctx := inv.Context() + client, err := dialAgentContextSocket(ctx, *socketPath) + if err != nil { + return err + } + defer client.Close() + + sources, err := client.ContextSources(ctx) + if err != nil { + return xerrors.Errorf("list context sources: %w", err) + } + if len(sources) == 0 && formatter.FormatID() == "table" { + cliui.Info(inv.Stdout, "No context sources registered.") + return nil + } + out, err := formatter.Format(ctx, sources) + if err != nil { + return xerrors.Errorf("format output: %w", err) + } + _, _ = fmt.Fprintln(inv.Stdout, out) + return nil + }, } + formatter.AttachOptions(&cmd.Options) + return cmd } -func (*RootCmd) chatContextAddCommand() *serpent.Command { - var ( - dir string - chatID string +func (*RootCmd) chatContextShowCommand(socketPath *string) *serpent.Command { + formatter := cliui.NewOutputFormatter( + cliui.TableFormat( + []agentsocket.ContextResource{}, + []string{"kind", "name", "source", "status", "size bytes", "error"}, + ), + cliui.JSONFormat(), ) + cmd := &serpent.Command{ + Use: "show ", + Short: "Show a context source and the resources it contributes", + Long: "Show a registered context source and the resources the agent currently " + + "resolves from it (instruction files, skills, MCP configs), including any " + + "that failed to read or parse.\n\nMust be run from inside the workspace.", + Middleware: serpent.RequireNArgs(1), + Handler: func(inv *serpent.Invocation) error { + ctx := inv.Context() + client, err := dialAgentContextSocket(ctx, *socketPath) + if err != nil { + return err + } + defer client.Close() + + path, err := resolveContextSourcePath(inv.Args[0]) + if err != nil { + return err + } + src, err := client.GetContextSource(ctx, path) + if err != nil { + return xerrors.Errorf("get context source: %w", err) + } + snap, err := client.GetContextSnapshot(ctx) + if err != nil { + return xerrors.Errorf("get context snapshot: %w", err) + } + resources := make([]agentsocket.ContextResource, 0, len(snap.Resources)) + for _, res := range snap.Resources { + if res.SourcePath == src.Path { + resources = append(resources, res) + } + } + + if formatter.FormatID() == "table" { + cliui.Infof(inv.Stdout, "Source: %s (%d resources)", src.Path, len(resources)) + } + out, err := formatter.Format(ctx, resources) + if err != nil { + return xerrors.Errorf("format output: %w", err) + } + _, _ = fmt.Fprintln(inv.Stdout, out) + return nil + }, + } + formatter.AttachOptions(&cmd.Options) + return cmd +} + +func (*RootCmd) chatContextAddCommand(socketPath *string) *serpent.Command { + var chatID string agentAuth := &AgentAuth{} cmd := &serpent.Command{ - Use: "add", - Short: "Add context to an active chat", - Long: "Read instruction files and discover skills from a directory, then add " + - "them as context to an active chat session. Multiple calls " + - "are additive.", + Use: "add ", + Short: "Register a workspace context source", + Long: "Register a path as an additional context source on this workspace's agent. " + + "The agent treats it as an extra scan root, applying the same discovery rules " + + "it uses for the working directory: AGENTS.md / CLAUDE.md / .cursorrules, " + + ".agents/skills//SKILL.md, and .mcp.json are picked up now and as they " + + "appear. Any change to a recognized file dirties this workspace's chats until " + + "you refresh.\n\nA path may be a file or a directory. Must be run from inside " + + "the workspace.\n\nPass --chat to keep the legacy one-shot behavior: read " + + "context from the path once and inject it into a single chat without " + + "registering a source.", + Middleware: serpent.RequireNArgs(1), Handler: func(inv *serpent.Invocation) error { ctx := inv.Context() ctx, stop := inv.SignalNotifyContext(ctx, StopSignals...) defer stop() - if dir == "" && inv.Environ.Get("CODER") != "true" { - return xerrors.New("this command must be run inside a Coder workspace (set --dir to override)") + // Legacy one-shot inject into a single chat. + if chatID != "" { + return addChatContextOneShot(ctx, inv, agentAuth, inv.Args[0], chatID) } - client, err := agentAuth.CreateClient() + // Source registration (default). + path, err := resolveContextSourcePath(inv.Args[0]) if err != nil { - return xerrors.Errorf("create agent client: %w", err) + return err + } + client, err := dialAgentContextSocket(ctx, *socketPath) + if err != nil { + return err } + defer client.Close() - resolvedDir := dir - if resolvedDir == "" { - resolvedDir, err = os.Getwd() - if err != nil { - return xerrors.Errorf("get working directory: %w", err) - } + src, err := client.AddContextSource(ctx, path) + if err != nil { + return xerrors.Errorf("add context source: %w", err) } - resolvedDir, err = filepath.Abs(resolvedDir) + _, _ = fmt.Fprintf(inv.Stdout, "Registered context source %s\n", src.Path) + return nil + }, + Options: serpent.OptionSet{{ + Name: "Chat ID", + Flag: "chat", + Env: "CODER_CHAT_ID", + Description: "Inject context from into a single chat (legacy one-shot) instead of registering a source. Auto-detected from CODER_CHAT_ID, the only active chat, or the only top-level active chat.", + Value: serpent.StringOf(&chatID), + }}, + } + agentAuth.AttachOptions(cmd, false) + return cmd +} + +// addChatContextOneShot preserves the legacy `add --chat` behavior: read +// context files and skills from a directory and inject them into a single +// chat via coderd, without registering a persistent source. +func addChatContextOneShot(ctx context.Context, inv *serpent.Invocation, agentAuth *AgentAuth, path, chatID string) error { + client, err := agentAuth.CreateClient() + if err != nil { + return xerrors.Errorf("create agent client: %w", err) + } + + resolvedDir, err := filepath.Abs(path) + if err != nil { + return xerrors.Errorf("resolve directory: %w", err) + } + info, err := os.Stat(resolvedDir) + if err != nil { + return xerrors.Errorf("cannot read directory %q: %w", resolvedDir, err) + } + if !info.IsDir() { + return xerrors.Errorf("--chat one-shot inject requires a directory, but %q is a file", resolvedDir) + } + + parts := agentcontextconfig.ContextPartsFromDir(resolvedDir) + if len(parts) == 0 { + _, _ = fmt.Fprintln(inv.Stderr, "No context files or skills found in "+resolvedDir) + return nil + } + + resolvedChatID, err := parseChatID(chatID) + if err != nil { + return err + } + + resp, err := client.AddChatContext(ctx, agentsdk.AddChatContextRequest{ + ChatID: resolvedChatID, + Parts: parts, + }) + if err != nil { + return xerrors.Errorf("add chat context: %w", err) + } + + _, _ = fmt.Fprintf(inv.Stdout, "Added %d context part(s) to chat %s\n", resp.Count, resp.ChatID) + return nil +} + +func (*RootCmd) chatContextRemoveCommand(socketPath *string) *serpent.Command { + cmd := &serpent.Command{ + Use: "remove ", + Short: "Remove a workspace context source", + Long: "Remove a previously-registered context source from this workspace's agent " + + "and re-resolve. Built-in default scan roots cannot be removed.\n\nMust be run " + + "from inside the workspace.", + Middleware: serpent.RequireNArgs(1), + Handler: func(inv *serpent.Invocation) error { + ctx := inv.Context() + client, err := dialAgentContextSocket(ctx, *socketPath) if err != nil { - return xerrors.Errorf("resolve directory: %w", err) + return err } - info, err := os.Stat(resolvedDir) + defer client.Close() + + path, err := resolveContextSourcePath(inv.Args[0]) if err != nil { - return xerrors.Errorf("cannot read directory %q: %w", resolvedDir, err) + return err } - if !info.IsDir() { - return xerrors.Errorf("%q is not a directory", resolvedDir) + if err := client.RemoveContextSource(ctx, path); err != nil { + return xerrors.Errorf("remove context source: %w", err) } + _, _ = fmt.Fprintf(inv.Stdout, "Removed context source %s\n", path) + return nil + }, + } + return cmd +} + +func (r *RootCmd) chatContextRefreshCommand(socketPath *string) *serpent.Command { + agentAuth := &AgentAuth{} + cmd := &serpent.Command{ + Use: "refresh []", + Short: "Refresh chat context to the agent's latest snapshot", + Long: "Re-pin a chat to the workspace agent's latest context snapshot and clear " + + "its drift marker. The chat's next turn uses the refreshed context.\n\nWith a " + + " argument, refreshes that chat and works from anywhere.\n\nWith no " + + "argument, run from inside the workspace: forces the agent to re-resolve its " + + "sources (catching freshly-cloned repos and startup-script writes the watcher " + + "has not seen yet), then refreshes every drifted chat. This path authenticates " + + "with the agent token, so it does not require 'coder login'.", + Middleware: serpent.RequireRangeArgs(0, 1), + Handler: func(inv *serpent.Invocation) error { + ctx := inv.Context() - parts := agentcontextconfig.ContextPartsFromDir(resolvedDir) - if len(parts) == 0 { - _, _ = fmt.Fprintln(inv.Stderr, "No context files or skills found in "+resolvedDir) + // With a argument: refresh that specific chat through the + // user-facing API. Works from anywhere with a logged-in CLI. + if len(inv.Args) == 1 { + chatID, err := uuid.Parse(inv.Args[0]) + if err != nil { + return xerrors.Errorf("invalid chat ID %q: %w", inv.Args[0], err) + } + client, err := r.InitClient(inv) + if err != nil { + return err + } + exp := codersdk.NewExperimentalClient(client) + chat, err := exp.RefreshChatContext(ctx, chatID) + if err != nil { + return xerrors.Errorf("refresh chat context: %w", err) + } + _, _ = fmt.Fprintf(inv.Stdout, "Refreshed context for chat %s.\n", chatID) + if chat.Context != nil && chat.Context.Error != "" { + _, _ = fmt.Fprintf(inv.Stdout, "Snapshot reported an error: %s\n", chat.Context.Error) + } return nil } - // Resolve chat ID from flag or auto-detect. - resolvedChatID, err := parseChatID(chatID) + // No argument: in-workspace. Re-resolve the agent's sources over + // the local context socket, then ask the agent (using its own + // token) to re-pin every drifted chat. Neither step needs a + // logged-in user session. + sock, err := dialAgentContextSocket(ctx, *socketPath) if err != nil { - return err + return xerrors.Errorf("connect to agent context socket "+ + "(run inside the workspace, or pass a ID): %w", err) } - - resp, err := client.AddChatContext(ctx, agentsdk.AddChatContextRequest{ - ChatID: resolvedChatID, - Parts: parts, - }) + defer sock.Close() + snap, err := sock.ResyncContext(ctx) if err != nil { - return xerrors.Errorf("add chat context: %w", err) + return xerrors.Errorf("re-resolve agent context: %w", err) + } + _, _ = fmt.Fprintf(inv.Stdout, "Re-resolved agent context (version %d, %d resources).\n", snap.Version, len(snap.Resources)) + if snap.SnapshotError != "" { + _, _ = fmt.Fprintf(inv.Stdout, "Snapshot reported an error: %s\n", snap.SnapshotError) } - _, _ = fmt.Fprintf(inv.Stdout, "Added %d context part(s) to chat %s\n", resp.Count, resp.ChatID) + agentClient, err := agentAuth.CreateClient() + if err != nil { + return xerrors.Errorf("create agent client: %w", err) + } + resp, err := agentClient.RefreshChatContext(ctx) + if err != nil { + return xerrors.Errorf("refresh chat context: %w", err) + } + _, _ = fmt.Fprintf(inv.Stdout, "Refreshed %d drifted chat(s).\n", resp.Refreshed) return nil }, - Options: serpent.OptionSet{ - { - Name: "Directory", - Flag: "dir", - Description: "Directory to read context files and skills from. Defaults to the current working directory.", - Value: serpent.StringOf(&dir), - }, - { - Name: "Chat ID", - Flag: "chat", - Env: "CODER_CHAT_ID", - Description: "Chat ID to add context to. Auto-detected from CODER_CHAT_ID, the only active chat, or the only top-level active chat.", - Value: serpent.StringOf(&chatID), - }, - }, } agentAuth.AttachOptions(cmd, false) return cmd diff --git a/cli/exp_chat_internal_test.go b/cli/exp_chat_internal_test.go new file mode 100644 index 0000000000000..68dbec0e7eeb2 --- /dev/null +++ b/cli/exp_chat_internal_test.go @@ -0,0 +1,77 @@ +package cli + +import ( + "path/filepath" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestParseChatID(t *testing.T) { + t.Parallel() + + t.Run("EmptyIsNil", func(t *testing.T) { + t.Parallel() + got, err := parseChatID("") + require.NoError(t, err) + require.Equal(t, uuid.Nil, got) + }) + + t.Run("ValidUUID", func(t *testing.T) { + t.Parallel() + want := uuid.MustParse("11111111-1111-4111-8111-111111111111") + got, err := parseChatID(want.String()) + require.NoError(t, err) + require.Equal(t, want, got) + }) + + t.Run("InvalidErrors", func(t *testing.T) { + t.Parallel() + _, err := parseChatID("not-a-uuid") + require.Error(t, err) + require.Contains(t, err.Error(), "invalid chat ID") + }) +} + +func TestResolveContextSourcePath(t *testing.T) { + t.Parallel() + + t.Run("EmptyErrors", func(t *testing.T) { + t.Parallel() + _, err := resolveContextSourcePath(" ") + require.Error(t, err) + require.Contains(t, err.Error(), "empty") + }) + + t.Run("PreservesTilde", func(t *testing.T) { + t.Parallel() + // A leading ~ is left for the agent to expand against its own home. + got, err := resolveContextSourcePath("~") + require.NoError(t, err) + require.Equal(t, "~", got) + + got, err = resolveContextSourcePath(" ~/skills/deploy ") + require.NoError(t, err) + require.Equal(t, "~/skills/deploy", got) + }) + + t.Run("KeepsAbsolute", func(t *testing.T) { + t.Parallel() + got, err := resolveContextSourcePath("/home/coder/AGENTS.md") + require.NoError(t, err) + require.Equal(t, "/home/coder/AGENTS.md", got) + }) + + t.Run("MakesRelativeAbsolute", func(t *testing.T) { + t.Parallel() + // "./" was the reported failure: a relative path must be resolved to an + // absolute one before it reaches the agent. + got, err := resolveContextSourcePath("./") + require.NoError(t, err) + require.True(t, filepath.IsAbs(got), "want absolute, got %q", got) + want, err := filepath.Abs("./") + require.NoError(t, err) + require.Equal(t, want, got) + }) +} diff --git a/cli/exp_chat_test.go b/cli/exp_chat_test.go index 30696c6ecad48..f204db3301013 100644 --- a/cli/exp_chat_test.go +++ b/cli/exp_chat_test.go @@ -1,6 +1,7 @@ package cli_test import ( + "path/filepath" "testing" "github.com/stretchr/testify/require" @@ -11,36 +12,31 @@ import ( func TestExpChatContextAdd(t *testing.T) { t.Parallel() - t.Run("RequiresWorkspaceOrDir", func(t *testing.T) { + t.Run("RequiresPathArgument", func(t *testing.T) { t.Parallel() + // `add` registers a context source identified by , so the path + // argument is required and a bare invocation is a usage error. inv, _ := clitest.New(t, "exp", "chat", "context", "add") err := inv.Run() require.Error(t, err) - require.Contains(t, err.Error(), "this command must be run inside a Coder workspace") + require.Contains(t, err.Error(), "wanted 1 args but got 0") }) - t.Run("AllowsExplicitDir", func(t *testing.T) { + t.Run("RequiresWorkspaceSocket", func(t *testing.T) { t.Parallel() - inv, _ := clitest.New(t, "exp", "chat", "context", "add", "--dir", t.TempDir()) + // Source registration talks to the agent over its local socket, so + // outside a workspace it fails to connect rather than silently doing + // nothing. Point at a socket path that does not exist so the dial + // fails deterministically (and never touches a real agent socket). + missingSocket := filepath.Join(t.TempDir(), "agent.sock") + inv, _ := clitest.New(t, "exp", "chat", "context", "add", t.TempDir(), + "--socket-path", missingSocket) err := inv.Run() - if err != nil { - require.NotContains(t, err.Error(), "this command must be run inside a Coder workspace") - } - }) - - t.Run("AllowsWorkspaceEnv", func(t *testing.T) { - t.Parallel() - - inv, _ := clitest.New(t, "exp", "chat", "context", "add") - inv.Environ.Set("CODER", "true") - - err := inv.Run() - if err != nil { - require.NotContains(t, err.Error(), "this command must be run inside a Coder workspace") - } + require.Error(t, err) + require.Contains(t, err.Error(), "inside the workspace") }) } diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 24eaec094114b..613cc09d9f16b 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -16608,6 +16608,13 @@ const docTemplate = `{ "codersdk.ChatContext": { "type": "object", "properties": { + "changes": { + "description": "Changes lists how the pinned context differs from the agent's latest\nsnapshot, by source. It is populated only on the single-chat GET\nresponse and only while the chat is dirty; otherwise nil.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatContextResourceChange" + } + }, "dirty": { "description": "Dirty is true when the agent's latest snapshot hash differs from the\nchat's pinned hash.", "type": "boolean" @@ -16620,9 +16627,145 @@ const docTemplate = `{ "error": { "description": "Error is the snapshot-level error copied from the pinned snapshot\n(empty when healthy).", "type": "string" + }, + "resources": { + "description": "Resources is the chat's pinned context (instruction files and\nskills) the prompt is built from, metadata only (no bodies). It is\npopulated only on the single-chat GET response; list and watch\npayloads leave it nil to stay lightweight.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatContextResource" + } } } }, + "codersdk.ChatContextMCPTool": { + "type": "object", + "properties": { + "description": { + "description": "Description is the tool's human-readable summary; may be empty.", + "type": "string" + }, + "name": { + "description": "Name is the tool name with the \"\u003cserver\u003e__\" prefix the agent adds\nstripped, so it reads as the server exposes it.", + "type": "string" + } + } + }, + "codersdk.ChatContextResource": { + "type": "object", + "properties": { + "error": { + "description": "Error explains a non-ok Status; empty when healthy. May also carry a\nnon-fatal warning when Status is ok.", + "type": "string" + }, + "kind": { + "$ref": "#/definitions/codersdk.ChatContextResourceKind" + }, + "mcp_tools": { + "description": "McpTools lists the tools exposed by an MCP server. Populated only for\nthe mcp_server kind; nil otherwise.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatContextMCPTool" + } + }, + "size_bytes": { + "description": "SizeBytes is the original payload size in bytes.", + "type": "integer" + }, + "skill_description": { + "type": "string" + }, + "skill_name": { + "description": "SkillName and SkillDescription are populated only for skill kinds.", + "type": "string" + }, + "source": { + "description": "Source is the resource locator: the canonical file path for an\ninstruction file, the skill directory for a skill, the file path for\nan MCP config, or the server name for an MCP server.", + "type": "string" + }, + "status": { + "description": "Status is the resource's health. Non-ok resources (invalid, unreadable,\noversize, excluded) are still reported so the UI can surface why a\nresource was dropped from the prompt instead of silently omitting it;\ntheir body-specific fields (skill name, MCP tools) are empty.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.ChatContextResourceStatus" + } + ] + } + } + }, + "codersdk.ChatContextResourceChange": { + "type": "object", + "properties": { + "kind": { + "$ref": "#/definitions/codersdk.ChatContextResourceKind" + }, + "new_content": { + "type": "string" + }, + "old_content": { + "description": "OldContent and NewContent carry the sanitized instruction-file bodies\nfor the pinned and snapshot sides, capped for display. Removed changes\nfill OldContent only, added changes fill NewContent only, and modified\nchanges fill both. Empty for skills.", + "type": "string" + }, + "skill_description": { + "type": "string" + }, + "skill_name": { + "description": "SkillName and SkillDescription identify a changed skill: the snapshot\nside for added/modified, the pinned side for removed. Empty for\ninstruction files.", + "type": "string" + }, + "source": { + "description": "Source is the resource locator that differs.", + "type": "string" + }, + "status": { + "$ref": "#/definitions/codersdk.ChatContextResourceChangeStatus" + } + } + }, + "codersdk.ChatContextResourceChangeStatus": { + "type": "string", + "enum": [ + "added", + "removed", + "modified" + ], + "x-enum-varnames": [ + "ChatContextResourceChangeStatusAdded", + "ChatContextResourceChangeStatusRemoved", + "ChatContextResourceChangeStatusModified" + ] + }, + "codersdk.ChatContextResourceKind": { + "type": "string", + "enum": [ + "instruction_file", + "skill", + "mcp_config", + "mcp_server" + ], + "x-enum-varnames": [ + "ChatContextResourceKindInstructionFile", + "ChatContextResourceKindSkill", + "ChatContextResourceKindMCPConfig", + "ChatContextResourceKindMCPServer" + ] + }, + "codersdk.ChatContextResourceStatus": { + "type": "string", + "enum": [ + "ok", + "oversize", + "unreadable", + "invalid", + "excluded" + ], + "x-enum-varnames": [ + "ChatContextResourceStatusOK", + "ChatContextResourceStatusOversize", + "ChatContextResourceStatusUnreadable", + "ChatContextResourceStatusInvalid", + "ChatContextResourceStatusExcluded" + ] + }, "codersdk.ChatDiffContents": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 26c4aff908ff3..66e8faba56428 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -14910,6 +14910,13 @@ "codersdk.ChatContext": { "type": "object", "properties": { + "changes": { + "description": "Changes lists how the pinned context differs from the agent's latest\nsnapshot, by source. It is populated only on the single-chat GET\nresponse and only while the chat is dirty; otherwise nil.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatContextResourceChange" + } + }, "dirty": { "description": "Dirty is true when the agent's latest snapshot hash differs from the\nchat's pinned hash.", "type": "boolean" @@ -14922,9 +14929,130 @@ "error": { "description": "Error is the snapshot-level error copied from the pinned snapshot\n(empty when healthy).", "type": "string" + }, + "resources": { + "description": "Resources is the chat's pinned context (instruction files and\nskills) the prompt is built from, metadata only (no bodies). It is\npopulated only on the single-chat GET response; list and watch\npayloads leave it nil to stay lightweight.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatContextResource" + } + } + } + }, + "codersdk.ChatContextMCPTool": { + "type": "object", + "properties": { + "description": { + "description": "Description is the tool's human-readable summary; may be empty.", + "type": "string" + }, + "name": { + "description": "Name is the tool name with the \"\u003cserver\u003e__\" prefix the agent adds\nstripped, so it reads as the server exposes it.", + "type": "string" + } + } + }, + "codersdk.ChatContextResource": { + "type": "object", + "properties": { + "error": { + "description": "Error explains a non-ok Status; empty when healthy. May also carry a\nnon-fatal warning when Status is ok.", + "type": "string" + }, + "kind": { + "$ref": "#/definitions/codersdk.ChatContextResourceKind" + }, + "mcp_tools": { + "description": "McpTools lists the tools exposed by an MCP server. Populated only for\nthe mcp_server kind; nil otherwise.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatContextMCPTool" + } + }, + "size_bytes": { + "description": "SizeBytes is the original payload size in bytes.", + "type": "integer" + }, + "skill_description": { + "type": "string" + }, + "skill_name": { + "description": "SkillName and SkillDescription are populated only for skill kinds.", + "type": "string" + }, + "source": { + "description": "Source is the resource locator: the canonical file path for an\ninstruction file, the skill directory for a skill, the file path for\nan MCP config, or the server name for an MCP server.", + "type": "string" + }, + "status": { + "description": "Status is the resource's health. Non-ok resources (invalid, unreadable,\noversize, excluded) are still reported so the UI can surface why a\nresource was dropped from the prompt instead of silently omitting it;\ntheir body-specific fields (skill name, MCP tools) are empty.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.ChatContextResourceStatus" + } + ] } } }, + "codersdk.ChatContextResourceChange": { + "type": "object", + "properties": { + "kind": { + "$ref": "#/definitions/codersdk.ChatContextResourceKind" + }, + "new_content": { + "type": "string" + }, + "old_content": { + "description": "OldContent and NewContent carry the sanitized instruction-file bodies\nfor the pinned and snapshot sides, capped for display. Removed changes\nfill OldContent only, added changes fill NewContent only, and modified\nchanges fill both. Empty for skills.", + "type": "string" + }, + "skill_description": { + "type": "string" + }, + "skill_name": { + "description": "SkillName and SkillDescription identify a changed skill: the snapshot\nside for added/modified, the pinned side for removed. Empty for\ninstruction files.", + "type": "string" + }, + "source": { + "description": "Source is the resource locator that differs.", + "type": "string" + }, + "status": { + "$ref": "#/definitions/codersdk.ChatContextResourceChangeStatus" + } + } + }, + "codersdk.ChatContextResourceChangeStatus": { + "type": "string", + "enum": ["added", "removed", "modified"], + "x-enum-varnames": [ + "ChatContextResourceChangeStatusAdded", + "ChatContextResourceChangeStatusRemoved", + "ChatContextResourceChangeStatusModified" + ] + }, + "codersdk.ChatContextResourceKind": { + "type": "string", + "enum": ["instruction_file", "skill", "mcp_config", "mcp_server"], + "x-enum-varnames": [ + "ChatContextResourceKindInstructionFile", + "ChatContextResourceKindSkill", + "ChatContextResourceKindMCPConfig", + "ChatContextResourceKindMCPServer" + ] + }, + "codersdk.ChatContextResourceStatus": { + "type": "string", + "enum": ["ok", "oversize", "unreadable", "invalid", "excluded"], + "x-enum-varnames": [ + "ChatContextResourceStatusOK", + "ChatContextResourceStatusOversize", + "ChatContextResourceStatusUnreadable", + "ChatContextResourceStatusInvalid", + "ChatContextResourceStatusExcluded" + ] + }, "codersdk.ChatDiffContents": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index 601669d321076..50f40241332d6 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1789,6 +1789,7 @@ func New(options *Options) *API { r.Route("/experimental", func(r chi.Router) { r.Post("/chat-context", api.workspaceAgentAddChatContext) r.Delete("/chat-context", api.workspaceAgentClearChatContext) + r.Post("/chat-context/refresh", api.workspaceAgentRefreshChatContext) }) r.Route("/tasks/{task}", func(r chi.Router) { r.Post("/log-snapshot", api.postWorkspaceAgentTaskLogSnapshot) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 5962ef3319fea..afb35e9b5552f 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -2042,6 +2042,25 @@ func (api *API) getChat(rw http.ResponseWriter, r *http.Request) { sdkChat := db2sdk.Chat(chat, diffStatus, chatFiles) + // Enrich the lightweight context summary with the chat's pinned + // resources and, when it has drifted, the change set against the + // agent's latest snapshot. This detail is computed on read and only + // attached on the single-chat GET; list and watch payloads stay + // lightweight. A failure here is non-fatal: the chat is still usable + // without the detail, so we log and return the rest of the response. + if sdkChat.Context != nil && api.chatDaemon != nil { + resources, changes, err := api.chatDaemon.ContextDetail(ctx, chat) + if err != nil { + api.Logger.Error(ctx, "failed to compute chat context detail", + slog.F("chat_id", chat.ID), + slog.Error(err), + ) + } else { + sdkChat.Context.Resources = resources + sdkChat.Context.Changes = changes + } + } + // For root chats, embed children so callers get a complete // tree in a single response. if !chat.ParentChatID.Valid { @@ -2647,7 +2666,28 @@ func (api *API) refreshChatContext(rw http.ResponseWriter, r *http.Request) { return } - httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Chat(updated, nil, nil)) + sdkChat := db2sdk.Chat(updated, nil, nil) + + // Enrich the context summary with the freshly pinned resources (and any + // change set) so the client reflects the refresh immediately, without a + // full reload. This mirrors getChat; we pass the re-pinned chat so the + // detail reflects the post-refresh state (dirty marker cleared, so + // changes is nil). A failure here is non-fatal: the refresh already + // succeeded, so we log and return the rest of the response. + if sdkChat.Context != nil && api.chatDaemon != nil { + resources, changes, err := api.chatDaemon.ContextDetail(ctx, updated) + if err != nil { + api.Logger.Error(ctx, "failed to compute chat context detail after refresh", + slog.F("chat_id", updated.ID), + slog.Error(err), + ) + } else { + sdkChat.Context.Resources = resources + sdkChat.Context.Changes = changes + } + } + + httpapi.Write(ctx, rw, http.StatusOK, sdkChat) } // patchChat updates a chat resource. Supports updating labels, diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 0dc91010ccfab..f4c0aa0c99074 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -2691,6 +2691,69 @@ func (api *API) workspaceAgentClearChatContext(rw http.ResponseWriter, r *http.R }) } +// workspaceAgentRefreshChatContext re-pins every drifted chat bound to the +// calling agent to the agent's latest context snapshot, clearing their +// drift markers. It backs the in-workspace `coder exp chat context refresh` +// (no chat argument), which uses the agent token rather than a user +// session, mirroring workspaceAgentClearChatContext's auth model. +// +// @x-apidocgen {"skip": true} +func (api *API) workspaceAgentRefreshChatContext(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + workspaceAgent := httpmw.WorkspaceAgent(r) + + // Chats are processed by the chat daemon; without it there is + // nothing to refresh. + if api.chatDaemon == nil { + httpapi.Write(ctx, rw, http.StatusOK, agentsdk.RefreshChatContextResponse{}) + return + } + + // Use system context for chat operations since the workspace agent + // scope does not include chat resources. + //nolint:gocritic // Agent needs system access to read/write chat resources. + sysCtx := dbauthz.AsSystemRestricted(ctx) + workspace, err := api.Database.GetWorkspaceByAgentID(sysCtx, workspaceAgent.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to determine workspace from agent token.", + Detail: err.Error(), + }) + return + } + + chats, err := api.Database.GetActiveChatsByAgentID(sysCtx, workspaceAgent.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to list chats for agent.", + Detail: err.Error(), + }) + return + } + + refreshed := 0 + for _, chat := range chats { + // Only re-pin chats owned by this workspace's owner that have + // drifted from the agent's latest snapshot. + if chat.OwnerID != workspace.OwnerID || !chat.ContextDirtySince.Valid { + continue + } + if _, err := api.chatDaemon.RefreshChatContext(sysCtx, chat); err != nil { + api.Logger.Warn(ctx, "failed to refresh chat context for agent", + slog.F("chat_id", chat.ID), + slog.F("agent_id", workspaceAgent.ID), + slog.Error(err), + ) + continue + } + refreshed++ + } + + httpapi.Write(ctx, rw, http.StatusOK, agentsdk.RefreshChatContextResponse{ + Refreshed: refreshed, + }) +} + var ( errNoActiveChats = xerrors.New("no active chats found") errChatNotFound = xerrors.New("chat not found") diff --git a/coderd/x/chatd/context_detail.go b/coderd/x/chatd/context_detail.go new file mode 100644 index 0000000000000..3d5aa82b1a662 --- /dev/null +++ b/coderd/x/chatd/context_detail.go @@ -0,0 +1,282 @@ +package chatd + +import ( + "bytes" + "context" + "encoding/json" + "sort" + "unicode/utf8" + + "golang.org/x/xerrors" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/codersdk" +) + +// maxContextChangeContentBytes caps each side of an instruction-file change so +// the single-chat GET response stays bounded. The agent push admits resource +// bodies up to 256KiB; for the on-read diff we surface at most this many bytes +// per side, truncated on a rune boundary. +const maxContextChangeContentBytes = 64 * 1024 + +// ContextDetail computes the chat's pinned context resource list and, when the +// chat has drifted, the per-source change set against the agent's latest +// pushed snapshot. It is read-only and intended for the single-chat GET +// handler; list and watch payloads omit this detail to stay lightweight. +// +// resources lists the chat's full pinned inventory (instruction files, skills, +// and MCP configs/servers); changes is nil unless the chat is dirty (and has a +// resolvable agent), so the second read is only paid for when it can differ. +func (server *Server) ContextDetail( + ctx context.Context, + chat database.Chat, +) (resources []codersdk.ChatContextResource, changes []codersdk.ChatContextResourceChange, err error) { + pinned, err := server.db.ListChatContextResourcesByChatID(ctx, chat.ID) + if err != nil { + return nil, nil, xerrors.Errorf("list chat context resources: %w", err) + } + resources = pinnedContextResources(pinned) + + if !chat.ContextDirtySince.Valid || !chat.AgentID.Valid { + return resources, nil, nil + } + snapshot, err := server.db.ListWorkspaceAgentContextResources(ctx, chat.AgentID.UUID) + if err != nil { + return nil, nil, xerrors.Errorf("list workspace agent context resources: %w", err) + } + changes = diffContextResources(pinned, snapshot) + server.logger.Debug(ctx, "computed chat context detail", + slog.F("chat_id", chat.ID), + slog.F("resource_count", len(resources)), + slog.F("change_count", len(changes)), + ) + return resources, changes, nil +} + +// pinnedContextResources converts a chat's pinned context rows into the +// metadata-only resource list reported on the chat. It surfaces the full +// pinned inventory the user can act on, each stamped with its Status: +// +// - OK instruction files with non-empty (sanitized) content, OK skills with +// a name, and OK MCP configs/servers (mcp_server carries its tools). +// - Non-OK rows (invalid, unreadable, oversize, excluded) of a tracked kind, +// carrying Status and Error so the UI can explain why the resource was +// dropped from the prompt instead of silently omitting it. Their +// body-specific fields are empty. +// +// OK-but-empty instruction files, OK skills with no name, and untracked kinds +// (reserved plugin/hook/subagent/command) are skipped. Input order (source ASC +// from the query) is preserved. +func pinnedContextResources(resources []database.ChatContextResource) []codersdk.ChatContextResource { + var out []codersdk.ChatContextResource + for _, r := range resources { + kind, ok := contextResourceKind(r.BodyKind) + if !ok { + continue + } + if r.Status != database.WorkspaceAgentContextResourceStatusOk { + // Surface the failure (with its reason) rather than dropping it + // silently; the body is empty for non-OK rows. + out = append(out, codersdk.ChatContextResource{ + Source: r.Source, + Kind: kind, + SizeBytes: r.SizeBytes, + Status: codersdk.ChatContextResourceStatus(r.Status), + Error: r.Error, + }) + continue + } + switch r.BodyKind { + case database.WorkspaceAgentContextBodyKindInstructionFile: + body, decoded := decodeInstructionFileBody(r.Body) + if !decoded || SanitizePromptText(string(body.GetContent())) == "" { + continue + } + out = append(out, codersdk.ChatContextResource{ + Source: r.Source, + Kind: kind, + SizeBytes: r.SizeBytes, + Status: codersdk.ChatContextResourceStatusOK, + }) + case database.WorkspaceAgentContextBodyKindSkill: + body, decoded := decodeSkillMetaBody(r.Body) + if !decoded || body.GetName() == "" { + continue + } + out = append(out, codersdk.ChatContextResource{ + Source: r.Source, + Kind: kind, + SizeBytes: r.SizeBytes, + Status: codersdk.ChatContextResourceStatusOK, + SkillName: body.GetName(), + SkillDescription: body.GetDescription(), + }) + case database.WorkspaceAgentContextBodyKindMcpConfig: + out = append(out, codersdk.ChatContextResource{ + Source: r.Source, + Kind: kind, + SizeBytes: r.SizeBytes, + Status: codersdk.ChatContextResourceStatusOK, + }) + case database.WorkspaceAgentContextBodyKindMcpServer: + out = append(out, codersdk.ChatContextResource{ + Source: r.Source, + Kind: kind, + SizeBytes: r.SizeBytes, + Status: codersdk.ChatContextResourceStatusOK, + McpTools: mcpToolsFromServerBody(r.Source, r.Body), + }) + } + } + return out +} + +// contextResourceSide is the subset of a context resource row needed to diff +// one source across the pinned copy and the agent snapshot. +type contextResourceSide struct { + kind database.WorkspaceAgentContextBodyKind + body json.RawMessage + contentHash []byte +} + +// diffContextResources compares a chat's pinned context against the agent's +// latest snapshot, by source, and returns the changes among prompt kinds +// (instruction files and skills). A source present on both sides with an equal +// content hash is unchanged and omitted; a differing hash is modified; +// pinned-only is removed; snapshot-only is added. Output is ordered by source. +func diffContextResources( + pinned []database.ChatContextResource, + snapshot []database.WorkspaceAgentContextResource, +) []codersdk.ChatContextResourceChange { + pinnedBySource := make(map[string]contextResourceSide, len(pinned)) + for _, r := range pinned { + pinnedBySource[r.Source] = contextResourceSide{kind: r.BodyKind, body: r.Body, contentHash: r.ContentHash} + } + snapshotBySource := make(map[string]contextResourceSide, len(snapshot)) + sources := make([]string, 0, len(pinned)+len(snapshot)) + for _, r := range pinned { + sources = append(sources, r.Source) + } + for _, r := range snapshot { + if _, ok := pinnedBySource[r.Source]; !ok { + sources = append(sources, r.Source) + } + snapshotBySource[r.Source] = contextResourceSide{kind: r.BodyKind, body: r.Body, contentHash: r.ContentHash} + } + sort.Strings(sources) + + var changes []codersdk.ChatContextResourceChange + for _, source := range sources { + pinnedSide, hasPinned := pinnedBySource[source] + snapshotSide, hasSnapshot := snapshotBySource[source] + switch { + case hasPinned && hasSnapshot: + if bytes.Equal(pinnedSide.contentHash, snapshotSide.contentHash) { + continue + } + if change, ok := buildResourceChange(source, codersdk.ChatContextResourceChangeStatusModified, &pinnedSide, &snapshotSide); ok { + changes = append(changes, change) + } + case hasPinned: + if change, ok := buildResourceChange(source, codersdk.ChatContextResourceChangeStatusRemoved, &pinnedSide, nil); ok { + changes = append(changes, change) + } + case hasSnapshot: + if change, ok := buildResourceChange(source, codersdk.ChatContextResourceChangeStatusAdded, nil, &snapshotSide); ok { + changes = append(changes, change) + } + } + } + return changes +} + +// buildResourceChange assembles a change entry for one source. The reported +// kind comes from the side that exists now (snapshot for added/modified, +// pinned for removed); ok is false only for kinds chatd does not track. An +// instruction-file change carries the sanitized, capped bodies of whichever +// sides are present; a skill change carries the identifying name and +// description; MCP config/server changes carry only source, kind, and status. +func buildResourceChange( + source string, + status codersdk.ChatContextResourceChangeStatus, + pinned, snapshot *contextResourceSide, +) (codersdk.ChatContextResourceChange, bool) { + current := snapshot + if current == nil { + current = pinned + } + kind, ok := contextResourceKind(current.kind) + if !ok { + return codersdk.ChatContextResourceChange{}, false + } + + change := codersdk.ChatContextResourceChange{ + Source: source, + Kind: kind, + Status: status, + } + switch kind { + case codersdk.ChatContextResourceKindInstructionFile: + if pinned != nil { + change.OldContent = cappedInstructionContent(pinned.body) + } + if snapshot != nil { + change.NewContent = cappedInstructionContent(snapshot.body) + } + case codersdk.ChatContextResourceKindSkill: + // Removed skills exist only on the pinned side; otherwise the snapshot + // identifies what a refresh would adopt. + identity := snapshot + if identity == nil { + identity = pinned + } + if body, decoded := decodeSkillMetaBody(identity.body); decoded { + change.SkillName = body.GetName() + change.SkillDescription = body.GetDescription() + } + } + return change, true +} + +// contextResourceKind maps a database body kind to the codersdk kind reported +// on the chat. ok is false only for kinds chatd does not track yet (the +// reserved plugin/hook/subagent/command kinds), which are omitted from the +// resource list and change set. +func contextResourceKind(kind database.WorkspaceAgentContextBodyKind) (codersdk.ChatContextResourceKind, bool) { + switch kind { + case database.WorkspaceAgentContextBodyKindInstructionFile: + return codersdk.ChatContextResourceKindInstructionFile, true + case database.WorkspaceAgentContextBodyKindSkill: + return codersdk.ChatContextResourceKindSkill, true + case database.WorkspaceAgentContextBodyKindMcpConfig: + return codersdk.ChatContextResourceKindMCPConfig, true + case database.WorkspaceAgentContextBodyKindMcpServer: + return codersdk.ChatContextResourceKindMCPServer, true + default: + return "", false + } +} + +// cappedInstructionContent decodes, sanitizes, and length-caps an instruction +// file body for display in a change diff. It returns "" when the body is not a +// decodable instruction file (e.g. a non-OK snapshot with an empty body). +func cappedInstructionContent(body json.RawMessage) string { + decoded, ok := decodeInstructionFileBody(body) + if !ok { + return "" + } + return truncateUTF8(SanitizePromptText(string(decoded.GetContent())), maxContextChangeContentBytes) +} + +// truncateUTF8 returns s truncated to at most n bytes without splitting a +// multi-byte rune. +func truncateUTF8(s string, n int) string { + if len(s) <= n { + return s + } + for n > 0 && !utf8.RuneStart(s[n]) { + n-- + } + return s[:n] +} diff --git a/coderd/x/chatd/context_detail_internal_test.go b/coderd/x/chatd/context_detail_internal_test.go new file mode 100644 index 0000000000000..badf6e323c7c9 --- /dev/null +++ b/coderd/x/chatd/context_detail_internal_test.go @@ -0,0 +1,446 @@ +package chatd + +import ( + "context" + "crypto/sha256" + "database/sql" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "golang.org/x/xerrors" + + agentproto "github.com/coder/coder/v2/agent/proto" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbmock" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/codersdk" +) + +func contentHash(s string) []byte { + sum := sha256.Sum256([]byte(s)) + return sum[:] +} + +// pinnedInstruction builds a pinned instruction-file row with an explicit +// content hash so diff tests can control add/modify/remove independently of +// the body bytes. +func pinnedInstruction(t *testing.T, source, content string, hash []byte) database.ChatContextResource { + t.Helper() + return database.ChatContextResource{ + Source: source, + BodyKind: database.WorkspaceAgentContextBodyKindInstructionFile, + Body: mustMarshalContextBody(t, &agentproto.InstructionFileBody{Content: []byte(content)}), + ContentHash: hash, + SizeBytes: int64(len(content)), + Status: database.WorkspaceAgentContextResourceStatusOk, + } +} + +func snapshotInstruction(t *testing.T, source, content string, hash []byte) database.WorkspaceAgentContextResource { + t.Helper() + return database.WorkspaceAgentContextResource{ + Source: source, + BodyKind: database.WorkspaceAgentContextBodyKindInstructionFile, + Body: mustMarshalContextBody(t, &agentproto.InstructionFileBody{Content: []byte(content)}), + ContentHash: hash, + SizeBytes: int64(len(content)), + Status: database.WorkspaceAgentContextResourceStatusOk, + } +} + +func pinnedSkill(t *testing.T, source, name, description string, hash []byte) database.ChatContextResource { + t.Helper() + return database.ChatContextResource{ + Source: source, + BodyKind: database.WorkspaceAgentContextBodyKindSkill, + Body: mustMarshalContextBody(t, &agentproto.SkillMetaBody{Meta: []byte("# " + name), Name: name, Description: description}), + ContentHash: hash, + Status: database.WorkspaceAgentContextResourceStatusOk, + } +} + +func snapshotSkill(t *testing.T, source, name, description string, hash []byte) database.WorkspaceAgentContextResource { + t.Helper() + return database.WorkspaceAgentContextResource{ + Source: source, + BodyKind: database.WorkspaceAgentContextBodyKindSkill, + Body: mustMarshalContextBody(t, &agentproto.SkillMetaBody{Meta: []byte("# " + name), Name: name, Description: description}), + ContentHash: hash, + Status: database.WorkspaceAgentContextResourceStatusOk, + } +} + +func TestPinnedContextResources(t *testing.T) { + t.Parallel() + + t.Run("InstructionAndSkillMetadata", func(t *testing.T) { + t.Parallel() + + resources := []database.ChatContextResource{ + instructionResource(t, "/home/coder/AGENTS.md", "be helpful", database.WorkspaceAgentContextResourceStatusOk), + skillResource(t, "/home/coder/.coder/skills/deploy", "deploy", "Deploy the app", database.WorkspaceAgentContextResourceStatusOk), + } + // instructionResource/skillResource leave SizeBytes zero; set one to + // confirm it is carried through. + resources[0].SizeBytes = 10 + + out := pinnedContextResources(resources) + require.Len(t, out, 2) + + require.Equal(t, codersdk.ChatContextResource{ + Source: "/home/coder/AGENTS.md", + Kind: codersdk.ChatContextResourceKindInstructionFile, + SizeBytes: 10, + Status: codersdk.ChatContextResourceStatusOK, + }, out[0]) + + require.Equal(t, codersdk.ChatContextResource{ + Source: "/home/coder/.coder/skills/deploy", + Kind: codersdk.ChatContextResourceKindSkill, + Status: codersdk.ChatContextResourceStatusOK, + SkillName: "deploy", + SkillDescription: "Deploy the app", + }, out[1]) + }) + + t.Run("SkipsOKButEmpty", func(t *testing.T) { + t.Parallel() + + resources := []database.ChatContextResource{ + // OK instruction file with empty content. + instructionResource(t, "/b/AGENTS.md", "", database.WorkspaceAgentContextResourceStatusOk), + // OK skill with no name. + skillResource(t, "/c/skills/x", "", "no name", database.WorkspaceAgentContextResourceStatusOk), + } + require.Empty(t, pinnedContextResources(resources)) + }) + + t.Run("IncludesNonOKWithError", func(t *testing.T) { + t.Parallel() + + oversize := instructionResource(t, "/a/AGENTS.md", "ignored", database.WorkspaceAgentContextResourceStatusOversize) + oversize.SizeBytes = 999 + oversize.Error = "file size exceeds cap" + invalidSkill := skillResource(t, "/c/skills/moo", "", "", database.WorkspaceAgentContextResourceStatusInvalid) + invalidSkill.Error = `front-matter name "x" does not match directory "moo"` + resources := []database.ChatContextResource{oversize, invalidSkill} + + out := pinnedContextResources(resources) + require.Equal(t, []codersdk.ChatContextResource{ + { + Source: "/a/AGENTS.md", + Kind: codersdk.ChatContextResourceKindInstructionFile, + SizeBytes: 999, + Status: codersdk.ChatContextResourceStatusOversize, + Error: "file size exceeds cap", + }, + { + Source: "/c/skills/moo", + Kind: codersdk.ChatContextResourceKindSkill, + Status: codersdk.ChatContextResourceStatusInvalid, + Error: `front-matter name "x" does not match directory "moo"`, + }, + }, out) + }) + + t.Run("IncludesMCPConfigAndServer", func(t *testing.T) { + t.Parallel() + + resources := []database.ChatContextResource{ + { + Source: "/home/coder/.mcp.json", + BodyKind: database.WorkspaceAgentContextBodyKindMcpConfig, + Status: database.WorkspaceAgentContextResourceStatusOk, + SizeBytes: 670, + }, + { + Source: "github", + BodyKind: database.WorkspaceAgentContextBodyKindMcpServer, + Status: database.WorkspaceAgentContextResourceStatusOk, + SizeBytes: 12, + // Tool names carry the "__" prefix the agent adds. + Body: mustMarshalContextBody(t, &agentproto.MCPServerBody{ + ServerName: "github", + Tools: []*agentproto.MCPTool{ + {Name: "github__create", Description: "Create an issue"}, + {Name: "github__search", Description: "Search code"}, + }, + }), + }, + } + out := pinnedContextResources(resources) + require.Equal(t, []codersdk.ChatContextResource{ + { + Source: "/home/coder/.mcp.json", + Kind: codersdk.ChatContextResourceKindMCPConfig, + SizeBytes: 670, + Status: codersdk.ChatContextResourceStatusOK, + }, + { + Source: "github", + Kind: codersdk.ChatContextResourceKindMCPServer, + SizeBytes: 12, + Status: codersdk.ChatContextResourceStatusOK, + // Tool names are reported with the "github__" prefix stripped. + McpTools: []codersdk.ChatContextMCPTool{ + {Name: "create", Description: "Create an issue"}, + {Name: "search", Description: "Search code"}, + }, + }, + }, out) + }) +} + +func TestDiffContextResources(t *testing.T) { + t.Parallel() + + t.Run("AddedModifiedRemovedUnchanged", func(t *testing.T) { + t.Parallel() + + pinned := []database.ChatContextResource{ + pinnedInstruction(t, "/keep.md", "same", contentHash("same")), + pinnedInstruction(t, "/edit.md", "old body", contentHash("old body")), + pinnedInstruction(t, "/gone.md", "removed body", contentHash("removed body")), + } + snapshot := []database.WorkspaceAgentContextResource{ + snapshotInstruction(t, "/keep.md", "same", contentHash("same")), + snapshotInstruction(t, "/edit.md", "new body", contentHash("new body")), + snapshotInstruction(t, "/new.md", "added body", contentHash("added body")), + } + + changes := diffContextResources(pinned, snapshot) + // Ordered by source: /edit.md, /gone.md, /new.md. /keep.md is omitted. + require.Len(t, changes, 3) + + require.Equal(t, codersdk.ChatContextResourceChange{ + Source: "/edit.md", + Kind: codersdk.ChatContextResourceKindInstructionFile, + Status: codersdk.ChatContextResourceChangeStatusModified, + OldContent: "old body", + NewContent: "new body", + }, changes[0]) + + require.Equal(t, codersdk.ChatContextResourceChange{ + Source: "/gone.md", + Kind: codersdk.ChatContextResourceKindInstructionFile, + Status: codersdk.ChatContextResourceChangeStatusRemoved, + OldContent: "removed body", + }, changes[1]) + + require.Equal(t, codersdk.ChatContextResourceChange{ + Source: "/new.md", + Kind: codersdk.ChatContextResourceKindInstructionFile, + Status: codersdk.ChatContextResourceChangeStatusAdded, + NewContent: "added body", + }, changes[2]) + }) + + t.Run("SkillIdentitySides", func(t *testing.T) { + t.Parallel() + + pinned := []database.ChatContextResource{ + pinnedSkill(t, "/skills/edit", "edit-old", "old desc", contentHash("edit-old")), + pinnedSkill(t, "/skills/gone", "gone", "leaving", contentHash("gone")), + } + snapshot := []database.WorkspaceAgentContextResource{ + snapshotSkill(t, "/skills/edit", "edit-new", "new desc", contentHash("edit-new")), + snapshotSkill(t, "/skills/add", "added", "joining", contentHash("added")), + } + + changes := diffContextResources(pinned, snapshot) + require.Len(t, changes, 3) + + // Modified skill reports the snapshot identity (what a refresh adopts). + require.Equal(t, codersdk.ChatContextResourceChange{ + Source: "/skills/add", + Kind: codersdk.ChatContextResourceKindSkill, + Status: codersdk.ChatContextResourceChangeStatusAdded, + SkillName: "added", + SkillDescription: "joining", + }, changes[0]) + require.Equal(t, codersdk.ChatContextResourceChange{ + Source: "/skills/edit", + Kind: codersdk.ChatContextResourceKindSkill, + Status: codersdk.ChatContextResourceChangeStatusModified, + SkillName: "edit-new", + SkillDescription: "new desc", + }, changes[1]) + // Removed skill reports the pinned identity (only side that exists). + require.Equal(t, codersdk.ChatContextResourceChange{ + Source: "/skills/gone", + Kind: codersdk.ChatContextResourceKindSkill, + Status: codersdk.ChatContextResourceChangeStatusRemoved, + SkillName: "gone", + SkillDescription: "leaving", + }, changes[2]) + }) + + t.Run("IncludesMCPChanges", func(t *testing.T) { + t.Parallel() + + pinned := []database.ChatContextResource{ + {Source: "/p/.mcp.json", BodyKind: database.WorkspaceAgentContextBodyKindMcpConfig, ContentHash: contentHash("old")}, + } + snapshot := []database.WorkspaceAgentContextResource{ + {Source: "/p/.mcp.json", BodyKind: database.WorkspaceAgentContextBodyKindMcpConfig, ContentHash: contentHash("new")}, + } + changes := diffContextResources(pinned, snapshot) + // MCP changes carry only source, kind, and status (no body diff). + require.Equal(t, []codersdk.ChatContextResourceChange{{ + Source: "/p/.mcp.json", + Kind: codersdk.ChatContextResourceKindMCPConfig, + Status: codersdk.ChatContextResourceChangeStatusModified, + }}, changes) + }) + + t.Run("SanitizesAndCapsContent", func(t *testing.T) { + t.Parallel() + + // CRLF is normalized by SanitizePromptText, and content beyond the cap + // is truncated. + large := strings.Repeat("a", maxContextChangeContentBytes+500) + pinned := []database.ChatContextResource{ + pinnedInstruction(t, "/a.md", "line1\r\nline2", contentHash("old")), + pinnedInstruction(t, "/big.md", large, contentHash("big-old")), + } + snapshot := []database.WorkspaceAgentContextResource{ + snapshotInstruction(t, "/a.md", "line1\r\nchanged", contentHash("new")), + snapshotInstruction(t, "/big.md", large+"-changed", contentHash("big-new")), + } + + changes := diffContextResources(pinned, snapshot) + require.Len(t, changes, 2) + require.Equal(t, "line1\nline2", changes[0].OldContent) + require.Equal(t, "line1\nchanged", changes[0].NewContent) + require.Len(t, changes[1].OldContent, maxContextChangeContentBytes) + require.Len(t, changes[1].NewContent, maxContextChangeContentBytes) + }) +} + +func TestTruncateUTF8(t *testing.T) { + t.Parallel() + + require.Equal(t, "abc", truncateUTF8("abc", 10)) + require.Equal(t, "abc", truncateUTF8("abc", 3)) + require.Equal(t, "ab", truncateUTF8("abc", 2)) + require.Equal(t, "", truncateUTF8("abc", 0)) + + // "é" is two bytes (0xC3 0xA9); a cap landing inside it backs off so the + // rune is not split. + require.Equal(t, "a", truncateUTF8("aé", 2)) + require.Equal(t, "aé", truncateUTF8("aé", 3)) +} + +func TestContextDetail(t *testing.T) { + t.Parallel() + + t.Run("NotDirtySkipsSnapshotRead", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + chatID := uuid.New() + db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chatID). + Return([]database.ChatContextResource{ + instructionResource(t, "/home/coder/AGENTS.md", "be helpful", database.WorkspaceAgentContextResourceStatusOk), + }, nil) + // No ListWorkspaceAgentContextResources call is configured: a clean + // chat must not read the snapshot. + server := newPinServer(t, db) + + resources, changes, err := server.ContextDetail(context.Background(), database.Chat{ID: chatID}) + require.NoError(t, err) + require.Len(t, resources, 1) + require.Nil(t, changes) + }) + + t.Run("DirtyComputesChanges", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + chatID := uuid.New() + agentID := uuid.New() + db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chatID). + Return([]database.ChatContextResource{ + pinnedInstruction(t, "/home/coder/AGENTS.md", "old", contentHash("old")), + }, nil) + db.EXPECT().ListWorkspaceAgentContextResources(gomock.Any(), agentID). + Return([]database.WorkspaceAgentContextResource{ + snapshotInstruction(t, "/home/coder/AGENTS.md", "new", contentHash("new")), + }, nil) + server := newPinServer(t, db) + + chat := database.Chat{ + ID: chatID, + AgentID: uuid.NullUUID{UUID: agentID, Valid: true}, + ContextDirtySince: sql.NullTime{Time: dbtime.Now(), Valid: true}, + } + resources, changes, err := server.ContextDetail(context.Background(), chat) + require.NoError(t, err) + require.Len(t, resources, 1) + require.Len(t, changes, 1) + require.Equal(t, codersdk.ChatContextResourceChangeStatusModified, changes[0].Status) + require.Equal(t, "old", changes[0].OldContent) + require.Equal(t, "new", changes[0].NewContent) + }) + + t.Run("DirtyWithoutAgentSkipsSnapshot", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + chatID := uuid.New() + db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chatID). + Return([]database.ChatContextResource{}, nil) + server := newPinServer(t, db) + + chat := database.Chat{ + ID: chatID, + ContextDirtySince: sql.NullTime{Time: dbtime.Now(), Valid: true}, + } + resources, changes, err := server.ContextDetail(context.Background(), chat) + require.NoError(t, err) + require.Empty(t, resources) + require.Nil(t, changes) + }) + + t.Run("PinnedListError", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + chatID := uuid.New() + db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chatID). + Return(nil, xerrors.New("boom")) + server := newPinServer(t, db) + + _, _, err := server.ContextDetail(context.Background(), database.Chat{ID: chatID}) + require.Error(t, err) + }) + + t.Run("SnapshotListError", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + chatID := uuid.New() + agentID := uuid.New() + db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chatID). + Return([]database.ChatContextResource{}, nil) + db.EXPECT().ListWorkspaceAgentContextResources(gomock.Any(), agentID). + Return(nil, xerrors.New("boom")) + server := newPinServer(t, db) + + chat := database.Chat{ + ID: chatID, + AgentID: uuid.NullUUID{UUID: agentID, Valid: true}, + ContextDirtySince: sql.NullTime{Time: dbtime.Now(), Valid: true}, + } + _, _, err := server.ContextDetail(context.Background(), chat) + require.Error(t, err) + }) +} diff --git a/coderd/x/chatd/context_integration_test.go b/coderd/x/chatd/context_integration_test.go index 75c8e197be23b..7e6092517941f 100644 --- a/coderd/x/chatd/context_integration_test.go +++ b/coderd/x/chatd/context_integration_test.go @@ -133,6 +133,22 @@ func TestChatContextDirtyFromAgentPush(t *testing.T) { return out } + // Index the GET-only context detail (resources + changes) by source. + resourcesBySource := func(resources []codersdk.ChatContextResource) map[string]codersdk.ChatContextResource { + out := make(map[string]codersdk.ChatContextResource, len(resources)) + for _, r := range resources { + out[r.Source] = r + } + return out + } + changesBySource := func(changes []codersdk.ChatContextResourceChange) map[string]codersdk.ChatContextResourceChange { + out := make(map[string]codersdk.ChatContextResourceChange, len(changes)) + for _, c := range changes { + out[c.Source] = c + } + return out + } + // Connect as the agent and push the initial snapshot. The push runs the // hydrate/dirty fan-out synchronously inside its transaction, so the chat // reflects the change by the time the RPC returns. @@ -160,6 +176,13 @@ func TestChatContextDirtyFromAgentPush(t *testing.T) { require.False(t, got.Context.Dirty, "initial hydration is clean") require.Nil(t, got.Context.DirtySince) + // The single-chat GET surfaces the pinned resources; a clean chat carries + // no change set. + require.Len(t, got.Context.Resources, 1, "GET reports the pinned resources") + require.Equal(t, agentsSource, got.Context.Resources[0].Source) + require.Equal(t, codersdk.ChatContextResourceKindInstructionFile, got.Context.Resources[0].Kind) + require.Empty(t, got.Context.Changes, "a clean chat has no changes") + // The initial push also copied the agent's resources onto the chat. pinned := pinnedResources(chat.ID) require.Len(t, pinned, 1, "initial hydration copies the agent's resources") @@ -193,6 +216,24 @@ func TestChatContextDirtyFromAgentPush(t *testing.T) { require.Empty(t, got.Context.Error, "dirty marking leaves the pinned hash and error unchanged") requireChatContextNil(otherChat.ID, "agent-less chat unaffected by the dirty fan-out") + // While dirty the GET still reports the pinned (hashA) resources, plus a + // change set computed against the agent's latest (hashB) snapshot: the + // instruction file is modified (with old/new bodies) and the skill is + // added. + require.Len(t, got.Context.Resources, 1, "resources stay pinned while dirty") + require.Equal(t, agentsSource, got.Context.Resources[0].Source) + dirtyChanges := changesBySource(got.Context.Changes) + require.Len(t, dirtyChanges, 2, "GET reports the per-source change set while dirty") + agentsChange := dirtyChanges[agentsSource] + require.Equal(t, codersdk.ChatContextResourceChangeStatusModified, agentsChange.Status) + require.Equal(t, codersdk.ChatContextResourceKindInstructionFile, agentsChange.Kind) + require.Equal(t, "hello-v1", agentsChange.OldContent) + require.Equal(t, "hello-v2", agentsChange.NewContent) + skillChange := dirtyChanges[skillSource] + require.Equal(t, codersdk.ChatContextResourceChangeStatusAdded, skillChange.Status) + require.Equal(t, codersdk.ChatContextResourceKindSkill, skillChange.Kind) + require.Equal(t, "example", skillChange.SkillName) + // The dirty fan-out must NOT re-copy resources: the chat keeps the bodies // from its pinned (hashA) snapshot until it is refreshed. pinned = pinnedResources(chat.ID) @@ -207,6 +248,17 @@ func TestChatContextDirtyFromAgentPush(t *testing.T) { require.False(t, refreshed.Context.Dirty, "refresh clears the dirty marker") require.Equal(t, snapshotError, refreshed.Context.Error, "refresh re-pins the snapshot error") + // The refresh response itself must carry the freshly pinned resources + // (and no change set), so the client reflects the refresh without a + // full reload. A regression here blanks the context indicator until + // the page is reloaded (which re-fetches via GET). + refreshRespResources := resourcesBySource(refreshed.Context.Resources) + require.Len(t, refreshRespResources, 2, "refresh response includes the re-pinned resources") + require.Equal(t, codersdk.ChatContextResourceKindInstructionFile, refreshRespResources[agentsSource].Kind) + require.Equal(t, codersdk.ChatContextResourceKindSkill, refreshRespResources[skillSource].Kind) + require.Equal(t, "example", refreshRespResources[skillSource].SkillName) + require.Empty(t, refreshed.Context.Changes, "a freshly refreshed chat has no changes") + // Refresh re-pinned the agent's current resources (the hashB set). pinned = pinnedResources(chat.ID) require.Len(t, pinned, 2, "refresh re-pins the agent's current resources") @@ -219,6 +271,14 @@ func TestChatContextDirtyFromAgentPush(t *testing.T) { require.NotNil(t, got.Context) require.False(t, got.Context.Dirty) + // Refresh advanced the pin to hashB, so the GET now reports both pinned + // resources and, being clean again, no changes. + refreshedResources := resourcesBySource(got.Context.Resources) + require.Len(t, refreshedResources, 2, "refresh re-pins both resources for the GET") + require.Equal(t, codersdk.ChatContextResourceKindInstructionFile, refreshedResources[agentsSource].Kind) + require.Equal(t, codersdk.ChatContextResourceKindSkill, refreshedResources[skillSource].Kind) + require.Equal(t, "example", refreshedResources[skillSource].SkillName) + require.Empty(t, got.Context.Changes, "a refreshed chat has no changes") // Re-pushing the now-pinned hash proves the refresh advanced the pin to // hashB: a matching hash must not re-dirty the chat. resp, err = aAPI.PushContextState(ctx, &agentproto.PushContextStateRequest{ @@ -233,3 +293,130 @@ func TestChatContextDirtyFromAgentPush(t *testing.T) { require.NotNil(t, got.Context) require.False(t, got.Context.Dirty, "re-push of the pinned hash stays clean") } + +// TestChatContextRefreshFromAgentToken covers the in-workspace +// `coder exp chat context refresh` (no chat argument) path, which authenticates +// with the agent token instead of a user session. The agent endpoint re-pins +// every drifted chat bound to the calling agent to its latest snapshot and +// clears the drift marker, returning how many were refreshed. A chat bound to +// no agent must stay untouched, guarding the agent-scoped query. +func TestChatContextRefreshFromAgentToken(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + DeploymentValues: directChatRoutingDeploymentValues(t), + IncludeProvisionerDaemon: true, + }) + user := coderdtest.CreateFirstUser(t, client) + expClient := codersdk.NewExperimentalClient(client) + + // Build a workspace with an agent via the echo provisioner so the agent + // token is accepted by the agent middleware backing the endpoint. + agentToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: echo.PlanComplete, + ProvisionApply: echo.ApplyComplete, + ProvisionGraph: echo.ProvisionGraphWithAgent(agentToken), + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + ws, err := client.Workspace(ctx, workspace.ID) + require.NoError(t, err) + require.Len(t, ws.LatestBuild.Resources, 1) + require.Len(t, ws.LatestBuild.Resources[0].Agents, 1) + agentID := ws.LatestBuild.Resources[0].Agents[0].ID + + // A chat bound to the agent, plus an unrelated chat bound to no agent that + // must stay untouched by the agent-scoped refresh. + model := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{}) + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + WorkspaceID: uuid.NullUUID{UUID: workspace.ID, Valid: true}, + AgentID: uuid.NullUUID{UUID: agentID, Valid: true}, + LastModelConfigID: model.ID, + Status: database.ChatStatusWaiting, + }) + otherChat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + LastModelConfigID: model.ID, + Status: database.ChatStatusWaiting, + }) + + agentsSource := "/home/coder/workspace/AGENTS.md" + instructionResource := func(content string, hash []byte) *agentproto.ContextResource { + return &agentproto.ContextResource{ + Source: agentsSource, + ContentHash: hash, + SizeBytes: uint64(len(content)), + Status: agentproto.ContextResource_OK, + Body: &agentproto.ContextResource_InstructionFile{ + InstructionFile: &agentproto.InstructionFileBody{Content: []byte(content)}, + }, + } + } + + // The agent token drives both the DRPC push and the REST refresh. + agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(agentToken)) + aAPI, _, err := agentClient.ConnectRPC210(ctx) + require.NoError(t, err) + defer func() { _ = aAPI.DRPCConn().Close() }() + + // Initial push hydrates the chat to a clean context. + resp, err := aAPI.PushContextState(ctx, &agentproto.PushContextStateRequest{ + Version: 1, + Initial: true, + AggregateHash: []byte{0x01}, + Resources: []*agentproto.ContextResource{instructionResource("hello-v1", []byte{0x11})}, + }) + require.NoError(t, err) + require.True(t, resp.GetAccepted()) + + // With nothing dirty, the agent-token refresh is a no-op. + refresh, err := agentClient.RefreshChatContext(ctx) + require.NoError(t, err) + require.Equal(t, 0, refresh.Refreshed, "no dirty chats to refresh") + + // A second push with a different hash drifts the bound chat dirty. + resp, err = aAPI.PushContextState(ctx, &agentproto.PushContextStateRequest{ + Version: 2, + AggregateHash: []byte{0x02}, + Resources: []*agentproto.ContextResource{instructionResource("hello-v2", []byte{0x22})}, + }) + require.NoError(t, err) + require.True(t, resp.GetAccepted()) + + got, err := expClient.GetChat(ctx, chat.ID) + require.NoError(t, err) + require.NotNil(t, got.Context) + require.True(t, got.Context.Dirty, "second push drifts the chat dirty") + + // The agent-token refresh re-pins every drifted chat bound to the agent. + refresh, err = agentClient.RefreshChatContext(ctx) + require.NoError(t, err) + require.Equal(t, 1, refresh.Refreshed, "the drifted chat is re-pinned") + + got, err = expClient.GetChat(ctx, chat.ID) + require.NoError(t, err) + require.NotNil(t, got.Context) + require.False(t, got.Context.Dirty, "refresh clears the dirty marker") + require.Len(t, got.Context.Resources, 1) + require.Equal(t, agentsSource, got.Context.Resources[0].Source) + + // The agent-less chat is never returned by the agent-scoped query, so it + // must stay unhydrated throughout. + other, err := expClient.GetChat(ctx, otherChat.ID) + require.NoError(t, err) + require.Nil(t, other.Context, "agent-less chat stays untouched") + + // A follow-up refresh with nothing dirty is a no-op again. + refresh, err = agentClient.RefreshChatContext(ctx) + require.NoError(t, err) + require.Equal(t, 0, refresh.Refreshed, "nothing left to refresh") +} diff --git a/coderd/x/chatd/context_prompt.go b/coderd/x/chatd/context_prompt.go new file mode 100644 index 0000000000000..da885218ce120 --- /dev/null +++ b/coderd/x/chatd/context_prompt.go @@ -0,0 +1,224 @@ +package chatd + +import ( + "context" + "encoding/json" + "strings" + + "golang.org/x/xerrors" + "google.golang.org/protobuf/encoding/protojson" + + "cdr.dev/slog/v3" + agentproto "github.com/coder/coder/v2/agent/proto" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/x/chatd/chattool" + "github.com/coder/coder/v2/codersdk" +) + +// contextBodyUnmarshalOptions reads the protojson resource bodies written by +// the agent context push (coderd/agentapi/context.go). DiscardUnknown keeps +// the reader forward compatible as new body fields are added to the proto. +var contextBodyUnmarshalOptions = protojson.UnmarshalOptions{DiscardUnknown: true} + +// decodeInstructionFileBody decodes a protojson instruction-file resource +// body. ok is false when the body cannot be decoded, letting callers count it +// as malformed rather than silently treating it as empty. +func decodeInstructionFileBody(body json.RawMessage) (*agentproto.InstructionFileBody, bool) { + var decoded agentproto.InstructionFileBody + if err := contextBodyUnmarshalOptions.Unmarshal(body, &decoded); err != nil { + return nil, false + } + return &decoded, true +} + +// decodeSkillMetaBody decodes a protojson skill resource body. ok is false +// when the body cannot be decoded. +func decodeSkillMetaBody(body json.RawMessage) (*agentproto.SkillMetaBody, bool) { + var decoded agentproto.SkillMetaBody + if err := contextBodyUnmarshalOptions.Unmarshal(body, &decoded); err != nil { + return nil, false + } + return &decoded, true +} + +// mcpToolsFromServerBody decodes a stored mcp_server resource body and returns +// its tool list for the chat response. The agent prefixes each tool name with +// "__"; that prefix is stripped so the name reads as the server +// exposes it. Returns nil when the body has no tools or cannot be decoded. +func mcpToolsFromServerBody(server string, body json.RawMessage) []codersdk.ChatContextMCPTool { + var decoded agentproto.MCPServerBody + if err := contextBodyUnmarshalOptions.Unmarshal(body, &decoded); err != nil { + return nil + } + tools := decoded.GetTools() + if len(tools) == 0 { + return nil + } + prefix := server + "__" + out := make([]codersdk.ChatContextMCPTool, 0, len(tools)) + for _, t := range tools { + name := strings.TrimPrefix(t.GetName(), prefix) + if name == "" { + continue + } + out = append(out, codersdk.ChatContextMCPTool{ + Name: name, + Description: t.GetDescription(), + }) + } + if len(out) == 0 { + return nil + } + return out +} + +// pinnedWorkspaceContext builds the system-prompt instruction block and +// workspace skills from the chat's pinned context resources +// (chat_context_resources), populated at hydrate and refresh time. +// +// ok reports whether the caller should use these values instead of the +// per-turn, history-derived path. It is false when the chat has no pinned +// rows (an older agent that never reported context, or a chat not yet +// hydrated), so the caller falls back to the legacy path. When rows exist ok +// is true even if they all filter to empty content, because the pin is then +// the source of truth. A read error is returned rather than swallowed, +// matching the other prompt-input reads in prepareGeneration. +// +// agent only decorates the instruction header with its OS and directory; an +// unresolved (zero-value) agent does not force a fallback, so the pin keeps +// working when the workspace is unreachable. +func (server *Server) pinnedWorkspaceContext( + ctx context.Context, + chat database.Chat, + agent database.WorkspaceAgent, +) (instruction string, skills []chattool.SkillMeta, ok bool, err error) { + resources, err := server.db.ListChatContextResourcesByChatID(ctx, chat.ID) + if err != nil { + return "", nil, false, xerrors.Errorf("list chat context resources: %w", err) + } + if len(resources) == 0 { + return "", nil, false, nil + } + + directory := agent.ExpandedDirectory + if directory == "" { + directory = agent.Directory + } + instruction, skills, malformed := contextResourcesToPrompt(resources, agent.OperatingSystem, directory) + if malformed > 0 { + // A status-OK resource whose body cannot be decoded means the pin + // hydrated content that is now unreadable; surface it so a proto + // or encoding regression does not silently drop context. + server.logger.Warn(ctx, "skipped malformed pinned chat context resources", + slog.F("chat_id", chat.ID), + slog.F("malformed_count", malformed), + slog.F("resource_count", len(resources)), + ) + } + server.logger.Debug(ctx, "built prompt context from pinned chat resources", + slog.F("chat_id", chat.ID), + slog.F("resource_count", len(resources)), + slog.F("skill_count", len(skills)), + slog.F("has_instruction", instruction != ""), + ) + return instruction, skills, true, nil +} + +// resolveTurnWorkspaceContext selects the instruction block and workspace +// skills for a turn. It prefers the chat's pinned context copy when the +// workspace agent has reported context, and falls back to the per-turn, +// history-derived context-file and skill parts for older agents that have +// not. The two paths are mutually exclusive. agent is the chat's resolved +// workspace agent, used only to decorate the pinned instruction header. A +// non-workspace chat yields no context. +func (server *Server) resolveTurnWorkspaceContext( + ctx context.Context, + chat database.Chat, + agent database.WorkspaceAgent, + promptRows []database.ChatMessage, +) (instruction string, skills []chattool.SkillMeta, err error) { + if !chat.WorkspaceID.Valid { + return "", nil, nil + } + + pinnedInstruction, pinnedSkills, ok, err := server.pinnedWorkspaceContext(ctx, chat, agent) + if err != nil { + return "", nil, err + } + if ok { + return pinnedInstruction, pinnedSkills, nil + } + + // History fallback: re-derive the instruction and skills from the + // context-file and skill parts the per-turn pull persisted. Skills are + // included only when context files are present; the pinned path resolves + // them independently. + if _, found := contextFileAgentID(promptRows); found { + return instructionFromContextFiles(promptRows), skillsFromParts(promptRows), nil + } + return "", nil, nil +} + +// contextResourcesToPrompt converts a chat's pinned context resources into +// the formatted instruction block and workspace skill metadata, the inverse +// of the protojson bodies written by the agent context push. +// +// operatingSystem and directory annotate the instruction header and are +// omitted when empty. Only OK resources of a prompt body kind contribute; +// other statuses, body kinds, and malformed bodies are skipped. malformed +// counts OK resources whose body failed to decode, so the caller can surface +// an otherwise silent drop. The header is emitted only when at least one +// instruction file has content, so a skill-only pin produces no instruction +// block, matching the per-turn path. +func contextResourcesToPrompt( + resources []database.ChatContextResource, + operatingSystem, directory string, +) (instruction string, skills []chattool.SkillMeta, malformed int) { + var contextFileParts []codersdk.ChatMessagePart + for _, r := range resources { + if r.Status != database.WorkspaceAgentContextResourceStatusOk { + continue + } + switch r.BodyKind { + case database.WorkspaceAgentContextBodyKindInstructionFile: + body, decoded := decodeInstructionFileBody(r.Body) + if !decoded { + malformed++ + continue + } + content := SanitizePromptText(string(body.GetContent())) + if content == "" { + continue + } + contextFileParts = append(contextFileParts, codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeContextFile, + ContextFilePath: r.Source, + ContextFileContent: content, + }) + case database.WorkspaceAgentContextBodyKindSkill: + body, decoded := decodeSkillMetaBody(r.Body) + if !decoded { + malformed++ + continue + } + if body.GetName() == "" { + continue + } + // source is the skill directory. MetaFile is left empty so + // chattool falls back to DefaultSkillMetaFile ("SKILL.md"). + // SkillMetaBody carries no meta file name, so a non-default + // CODER_AGENT_EXP_SKILL_META_FILE is not preserved on this + // path, unlike the per-turn discovery path. + skills = append(skills, chattool.SkillMeta{ + Name: body.GetName(), + Description: body.GetDescription(), + Dir: r.Source, + }) + } + } + + if len(contextFileParts) == 0 { + return "", skills, malformed + } + return formatSystemInstructions(operatingSystem, directory, contextFileParts), skills, malformed +} diff --git a/coderd/x/chatd/context_prompt_internal_test.go b/coderd/x/chatd/context_prompt_internal_test.go new file mode 100644 index 0000000000000..b381366570fec --- /dev/null +++ b/coderd/x/chatd/context_prompt_internal_test.go @@ -0,0 +1,544 @@ +package chatd + +import ( + "context" + "database/sql" + "encoding/json" + "testing" + + "github.com/google/uuid" + "github.com/sqlc-dev/pqtype" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "golang.org/x/xerrors" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + + "cdr.dev/slog/v3" + "cdr.dev/slog/v3/sloggers/slogtest" + agentproto "github.com/coder/coder/v2/agent/proto" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbmock" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +func mustMarshalContextBody(t *testing.T, msg proto.Message) json.RawMessage { + t.Helper() + raw, err := protojson.Marshal(msg) + require.NoError(t, err) + return raw +} + +func instructionResource(t *testing.T, source, content string, status database.WorkspaceAgentContextResourceStatus) database.ChatContextResource { + t.Helper() + return database.ChatContextResource{ + Source: source, + BodyKind: database.WorkspaceAgentContextBodyKindInstructionFile, + Body: mustMarshalContextBody(t, &agentproto.InstructionFileBody{Content: []byte(content)}), + Status: status, + } +} + +func skillResource(t *testing.T, source, name, description string, status database.WorkspaceAgentContextResourceStatus) database.ChatContextResource { + t.Helper() + return database.ChatContextResource{ + Source: source, + BodyKind: database.WorkspaceAgentContextBodyKindSkill, + Body: mustMarshalContextBody(t, &agentproto.SkillMetaBody{ + Meta: []byte("# " + name), + Name: name, + Description: description, + }), + Status: status, + } +} + +func TestContextResourcesToPrompt(t *testing.T) { + t.Parallel() + + t.Run("InstructionFilesBuildWorkspaceContext", func(t *testing.T) { + t.Parallel() + + resources := []database.ChatContextResource{ + instructionResource(t, "/home/coder/AGENTS.md", "be helpful", database.WorkspaceAgentContextResourceStatusOk), + } + instruction, skills, _ := contextResourcesToPrompt(resources, "linux", "/home/coder") + + require.Empty(t, skills) + require.Contains(t, instruction, "") + require.Contains(t, instruction, "Operating System: linux") + require.Contains(t, instruction, "Working Directory: /home/coder") + require.Contains(t, instruction, "Source: /home/coder/AGENTS.md") + require.Contains(t, instruction, "be helpful") + require.Contains(t, instruction, "") + }) + + t.Run("SkillsBuildMeta", func(t *testing.T) { + t.Parallel() + + resources := []database.ChatContextResource{ + skillResource(t, "/home/coder/.coder/skills/deploy", "deploy", "Deploy the app", database.WorkspaceAgentContextResourceStatusOk), + } + instruction, skills, _ := contextResourcesToPrompt(resources, "linux", "/home/coder") + + // Skill-only pins emit no instruction header. + require.Empty(t, instruction) + require.Len(t, skills, 1) + require.Equal(t, "deploy", skills[0].Name) + require.Equal(t, "Deploy the app", skills[0].Description) + require.Equal(t, "/home/coder/.coder/skills/deploy", skills[0].Dir) + // MetaFile is left empty so chattool defaults to SKILL.md. + require.Empty(t, skills[0].MetaFile) + }) + + t.Run("SkipsNonOKStatus", func(t *testing.T) { + t.Parallel() + + resources := []database.ChatContextResource{ + instructionResource(t, "/home/coder/AGENTS.md", "be helpful", database.WorkspaceAgentContextResourceStatusInvalid), + skillResource(t, "/home/coder/.coder/skills/deploy", "deploy", "Deploy the app", database.WorkspaceAgentContextResourceStatusOversize), + } + instruction, skills, _ := contextResourcesToPrompt(resources, "linux", "/home/coder") + + require.Empty(t, instruction) + require.Empty(t, skills) + }) + + t.Run("SkipsUnknownBodyKinds", func(t *testing.T) { + t.Parallel() + + resources := []database.ChatContextResource{ + { + Source: ".mcp.json", + BodyKind: database.WorkspaceAgentContextBodyKindMcpConfig, + Body: mustMarshalContextBody(t, &agentproto.MCPConfigBody{}), + Status: database.WorkspaceAgentContextResourceStatusOk, + }, + { + Source: "playwright", + BodyKind: database.WorkspaceAgentContextBodyKindMcpServer, + Body: mustMarshalContextBody(t, &agentproto.MCPServerBody{ServerName: "playwright"}), + Status: database.WorkspaceAgentContextResourceStatusOk, + }, + } + instruction, skills, _ := contextResourcesToPrompt(resources, "linux", "/home/coder") + + require.Empty(t, instruction) + require.Empty(t, skills) + }) + + t.Run("SkipsMalformedBody", func(t *testing.T) { + t.Parallel() + + resources := []database.ChatContextResource{ + { + Source: "/home/coder/AGENTS.md", + BodyKind: database.WorkspaceAgentContextBodyKindInstructionFile, + Body: json.RawMessage(`{not valid json`), + Status: database.WorkspaceAgentContextResourceStatusOk, + }, + instructionResource(t, "/home/coder/CLAUDE.md", "good content", database.WorkspaceAgentContextResourceStatusOk), + } + instruction, skills, malformed := contextResourcesToPrompt(resources, "linux", "/home/coder") + + require.Empty(t, skills) + require.Equal(t, 1, malformed) + require.NotContains(t, instruction, "/home/coder/AGENTS.md") + require.Contains(t, instruction, "Source: /home/coder/CLAUDE.md") + require.Contains(t, instruction, "good content") + }) + + t.Run("SkipsMalformedSkillBody", func(t *testing.T) { + t.Parallel() + + resources := []database.ChatContextResource{ + { + Source: "/home/coder/.coder/skills/broken", + BodyKind: database.WorkspaceAgentContextBodyKindSkill, + Body: json.RawMessage(`{not valid json`), + Status: database.WorkspaceAgentContextResourceStatusOk, + }, + skillResource(t, "/home/coder/.coder/skills/deploy", "deploy", "Deploy the app", database.WorkspaceAgentContextResourceStatusOk), + } + instruction, skills, malformed := contextResourcesToPrompt(resources, "linux", "/home/coder") + + require.Empty(t, instruction) + require.Equal(t, 1, malformed) + require.Len(t, skills, 1) + require.Equal(t, "deploy", skills[0].Name) + }) + + t.Run("SkipsEmptyNameSkill", func(t *testing.T) { + t.Parallel() + + // Defensive boundary on the agent's own marshaling: an OK skill with an + // empty name contributes nothing and is not counted as malformed. + resources := []database.ChatContextResource{ + skillResource(t, "/home/coder/.coder/skills/nameless", "", "no name", database.WorkspaceAgentContextResourceStatusOk), + } + instruction, skills, malformed := contextResourcesToPrompt(resources, "linux", "/home/coder") + + require.Empty(t, instruction) + require.Empty(t, skills) + require.Zero(t, malformed) + }) + + t.Run("SkipsEmptyInstructionContent", func(t *testing.T) { + t.Parallel() + + // Whitespace-only content sanitizes to empty, so the instruction file + // contributes no context-file part, emits no header, and is not counted + // as malformed. + resources := []database.ChatContextResource{ + instructionResource(t, "/home/coder/AGENTS.md", " \n\t ", database.WorkspaceAgentContextResourceStatusOk), + } + instruction, skills, malformed := contextResourcesToPrompt(resources, "linux", "/home/coder") + + require.Empty(t, instruction) + require.Empty(t, skills) + require.Zero(t, malformed) + }) + + t.Run("EmptyInput", func(t *testing.T) { + t.Parallel() + + instruction, skills, _ := contextResourcesToPrompt(nil, "linux", "/home/coder") + require.Empty(t, instruction) + require.Empty(t, skills) + }) + + t.Run("OmitsOSDirWhenAgentUnresolved", func(t *testing.T) { + t.Parallel() + + resources := []database.ChatContextResource{ + instructionResource(t, "/home/coder/AGENTS.md", "be helpful", database.WorkspaceAgentContextResourceStatusOk), + } + instruction, _, _ := contextResourcesToPrompt(resources, "", "") + + require.Contains(t, instruction, "") + require.Contains(t, instruction, "Source: /home/coder/AGENTS.md") + require.Contains(t, instruction, "be helpful") + require.NotContains(t, instruction, "Operating System:") + require.NotContains(t, instruction, "Working Directory:") + }) +} + +func newPinServer(t *testing.T, db database.Store) *Server { + t.Helper() + return &Server{ + db: db, + logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug), + } +} + +func TestPinnedWorkspaceContext(t *testing.T) { + t.Parallel() + + t.Run("ListError", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + chatID := uuid.New() + db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chatID). + Return(nil, xerrors.New("boom")) + server := newPinServer(t, db) + + _, _, ok, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, database.WorkspaceAgent{}) + require.Error(t, err) + require.False(t, ok) + }) + + t.Run("NoRowsFallsBack", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + chatID := uuid.New() + db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chatID). + Return([]database.ChatContextResource{}, nil) + server := newPinServer(t, db) + + instruction, skills, ok, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, database.WorkspaceAgent{}) + require.NoError(t, err) + require.False(t, ok) + require.Empty(t, instruction) + require.Empty(t, skills) + }) + + t.Run("RowsPresent", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + chatID := uuid.New() + db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chatID). + Return([]database.ChatContextResource{ + instructionResource(t, "/home/coder/AGENTS.md", "be helpful", database.WorkspaceAgentContextResourceStatusOk), + skillResource(t, "/home/coder/.coder/skills/deploy", "deploy", "Deploy the app", database.WorkspaceAgentContextResourceStatusOk), + }, nil) + server := newPinServer(t, db) + + agent := database.WorkspaceAgent{OperatingSystem: "linux", ExpandedDirectory: "/home/coder"} + instruction, skills, ok, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, agent) + require.NoError(t, err) + require.True(t, ok) + require.Contains(t, instruction, "Operating System: linux") + require.Contains(t, instruction, "Source: /home/coder/AGENTS.md") + require.Contains(t, instruction, "be helpful") + require.Len(t, skills, 1) + require.Equal(t, "deploy", skills[0].Name) + }) + + t.Run("RowsPresentUnresolvedAgent", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + chatID := uuid.New() + db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chatID). + Return([]database.ChatContextResource{ + instructionResource(t, "/home/coder/AGENTS.md", "be helpful", database.WorkspaceAgentContextResourceStatusOk), + }, nil) + server := newPinServer(t, db) + + // Zero-value agent: the pin still resolves, just without the + // OS/directory header. + instruction, _, ok, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, database.WorkspaceAgent{}) + require.NoError(t, err) + require.True(t, ok) + require.Contains(t, instruction, "Source: /home/coder/AGENTS.md") + require.NotContains(t, instruction, "Operating System:") + }) +} + +// TestPinnedWorkspaceContextFromHydratedPin exercises the resolver end to end +// against a real Postgres pin: an agent's pushed context is hydrated into a +// chat's chat_context_resources, then pinnedWorkspaceContext reads that copy. +func TestPinnedWorkspaceContextFromHydratedPin(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + + user := dbgen.User(t, db, database.User{}) + org := dbgen.Organization(t, db, database.Organization{}) + tv := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + tmpl := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + ActiveVersionID: tv.ID, + CreatedBy: user.ID, + }) + ws := dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: user.ID, + OrganizationID: org.ID, + TemplateID: tmpl.ID, + }) + pj := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{ + OrganizationID: org.ID, + CompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now()}, + }) + dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + WorkspaceID: ws.ID, + TemplateVersionID: tv.ID, + JobID: pj.ID, + Transition: database.WorkspaceTransitionStart, + }) + res := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{ + Transition: database.WorkspaceTransitionStart, + JobID: pj.ID, + }) + agent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ + ResourceID: res.ID, + OperatingSystem: "linux", + Directory: "/home/coder/ws", + }) + model := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{}) + + hash := []byte{0x01, 0x02, 0x03} + seedAgentContext(ctx, t, db, agent.ID, "/home/coder/ws/AGENTS.md", hash, + database.WorkspaceAgentContextBodyKindInstructionFile, + mustMarshalContextBody(t, &agentproto.InstructionFileBody{Content: []byte("follow the rules")})) + seedAgentContext(ctx, t, db, agent.ID, "/home/coder/ws/.coder/skills/deploy", hash, + database.WorkspaceAgentContextBodyKindSkill, + mustMarshalContextBody(t, &agentproto.SkillMetaBody{ + Meta: []byte("# deploy"), + Name: "deploy", + Description: "Deploy the app", + })) + + chat := dbgen.Chat(t, db, database.Chat{ + OwnerID: user.ID, + OrganizationID: org.ID, + LastModelConfigID: model.ID, + WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true}, + AgentID: uuid.NullUUID{UUID: agent.ID, Valid: true}, + Status: database.ChatStatusWaiting, + }) + require.NoError(t, db.HydrateAgentChatsContext(ctx, database.HydrateAgentChatsContextParams{ + AgentID: agent.ID, + AggregateHash: hash, + })) + rows, err := db.ListChatContextResourcesByChatID(ctx, chat.ID) + require.NoError(t, err) + require.Len(t, rows, 2, "the pin holds the agent's instruction file and skill") + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + server := &Server{db: db, logger: logger} + + instruction, skills, ok, err := server.pinnedWorkspaceContext(ctx, chat, agent) + require.NoError(t, err) + require.True(t, ok) + require.Contains(t, instruction, "Operating System: linux") + require.Contains(t, instruction, "Working Directory: /home/coder/ws") + require.Contains(t, instruction, "Source: /home/coder/ws/AGENTS.md") + require.Contains(t, instruction, "follow the rules") + require.Len(t, skills, 1) + require.Equal(t, "deploy", skills[0].Name) + require.Equal(t, "Deploy the app", skills[0].Description) + require.Equal(t, "/home/coder/ws/.coder/skills/deploy", skills[0].Dir) + + // A chat created after hydration keeps a NULL pinned hash and no pinned + // rows, so the pin resolves to ok=false and the caller falls back to the + // per-turn history path. + unpinnedChat := dbgen.Chat(t, db, database.Chat{ + OwnerID: user.ID, + OrganizationID: org.ID, + LastModelConfigID: model.ID, + WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true}, + AgentID: uuid.NullUUID{UUID: agent.ID, Valid: true}, + Status: database.ChatStatusWaiting, + }) + _, _, ok, err = server.pinnedWorkspaceContext(ctx, unpinnedChat, agent) + require.NoError(t, err) + require.False(t, ok) +} + +func historyContextMessage(t *testing.T, agentID uuid.UUID) database.ChatMessage { + t.Helper() + parts := []codersdk.ChatMessagePart{ + { + Type: codersdk.ChatMessagePartTypeContextFile, + ContextFileAgentID: uuid.NullUUID{UUID: agentID, Valid: true}, + ContextFilePath: "/home/coder/AGENTS.md", + ContextFileContent: "history content", + ContextFileOS: "linux", + ContextFileDirectory: "/home/coder", + }, + { + Type: codersdk.ChatMessagePartTypeSkill, + ContextFileAgentID: uuid.NullUUID{UUID: agentID, Valid: true}, + SkillName: "history-skill", + SkillDescription: "from history", + }, + } + raw, err := json.Marshal(parts) + require.NoError(t, err) + return database.ChatMessage{Content: pqtype.NullRawMessage{RawMessage: raw, Valid: true}} +} + +// TestResolveTurnWorkspaceContext covers the dispatch that prepareGeneration +// wires up: the pinned copy when the chat has pinned rows, otherwise the +// per-turn history-derived parts, and nothing for a non-workspace chat. +func TestResolveTurnWorkspaceContext(t *testing.T) { + t.Parallel() + + workspaceChat := func() database.Chat { + return database.Chat{ID: uuid.New(), WorkspaceID: uuid.NullUUID{UUID: uuid.New(), Valid: true}} + } + + t.Run("NonWorkspaceChatYieldsNothing", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + server := newPinServer(t, db) + + instruction, skills, err := server.resolveTurnWorkspaceContext(context.Background(), database.Chat{ID: uuid.New()}, database.WorkspaceAgent{}, nil) + require.NoError(t, err) + require.Empty(t, instruction) + require.Empty(t, skills) + }) + + t.Run("PinnedPathWins", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + chat := workspaceChat() + agentID := uuid.New() + db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chat.ID). + Return([]database.ChatContextResource{ + instructionResource(t, "/home/coder/AGENTS.md", "pinned content", database.WorkspaceAgentContextResourceStatusOk), + skillResource(t, "/home/coder/.coder/skills/deploy", "deploy", "Deploy the app", database.WorkspaceAgentContextResourceStatusOk), + }, nil) + server := newPinServer(t, db) + + // History rows are present too; the pinned path must take precedence. + promptRows := []database.ChatMessage{historyContextMessage(t, agentID)} + instruction, skills, err := server.resolveTurnWorkspaceContext(context.Background(), chat, database.WorkspaceAgent{OperatingSystem: "linux"}, promptRows) + require.NoError(t, err) + require.Contains(t, instruction, "pinned content") + require.NotContains(t, instruction, "history content") + require.Len(t, skills, 1) + require.Equal(t, "deploy", skills[0].Name) + }) + + t.Run("HistoryFallbackWhenNoPin", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + chat := workspaceChat() + // No pinned rows: the resolver falls back to the per-turn history path. + db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chat.ID). + Return([]database.ChatContextResource{}, nil) + server := newPinServer(t, db) + + agentID := uuid.New() + promptRows := []database.ChatMessage{historyContextMessage(t, agentID)} + instruction, skills, err := server.resolveTurnWorkspaceContext(context.Background(), chat, database.WorkspaceAgent{}, promptRows) + require.NoError(t, err) + require.Contains(t, instruction, "history content") + require.Len(t, skills, 1) + require.Equal(t, "history-skill", skills[0].Name) + }) + + t.Run("NoContextWhenHistoryEmpty", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + chat := workspaceChat() + // No pinned rows and no history parts: the turn carries no context. + db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chat.ID). + Return([]database.ChatContextResource{}, nil) + server := newPinServer(t, db) + + instruction, skills, err := server.resolveTurnWorkspaceContext(context.Background(), chat, database.WorkspaceAgent{}, nil) + require.NoError(t, err) + require.Empty(t, instruction) + require.Empty(t, skills) + }) + + t.Run("PropagatesPinReadError", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + chat := workspaceChat() + db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chat.ID). + Return(nil, xerrors.New("boom")) + server := newPinServer(t, db) + + _, _, err := server.resolveTurnWorkspaceContext(context.Background(), chat, database.WorkspaceAgent{}, nil) + require.Error(t, err) + }) +} diff --git a/coderd/x/chatd/generation_preparer.go b/coderd/x/chatd/generation_preparer.go index a0aec403ba279..f8c71d2b209fb 100644 --- a/coderd/x/chatd/generation_preparer.go +++ b/coderd/x/chatd/generation_preparer.go @@ -215,8 +215,6 @@ func (server *Server) prepareGeneration( resolvedUserPrompt string ) - persistedSkills := skillsFromParts(promptRows) - hasContextFiles := false if chat.WorkspaceID.Valid { // Resolve the workspace agent so the chat row's AgentID and // BuildID bindings are up to date before the chatworker @@ -225,9 +223,14 @@ func (server *Server) prepareGeneration( // the bound agent has changed, so this is a cheap metadata // refresh, not a workspace dial. It must not insert chat // history; only metadata is mutated here. - _, _ = workspaceCtx.getWorkspaceAgent(ctx) - _, found := contextFileAgentID(promptRows) - hasContextFiles = found + agent, _ := workspaceCtx.getWorkspaceAgent(ctx) + + var resolveErr error + instruction, workspaceSkills, resolveErr = server.resolveTurnWorkspaceContext(ctx, chat, agent, promptRows) + if resolveErr != nil { + cleanup() + return generationPrepared{}, resolveErr + } } var g2 errgroup.Group @@ -239,10 +242,6 @@ func (server *Server) prepareGeneration( } return nil }) - if hasContextFiles { - instruction = instructionFromContextFiles(promptRows) - workspaceSkills = persistedSkills - } g2.Go(func() error { personalSkills = server.fetchPersonalSkillMetadata(ctx, chat.OwnerID, logger) return nil diff --git a/codersdk/agentsdk/agentsdk.go b/codersdk/agentsdk/agentsdk.go index 815f175240925..27ca1135651f8 100644 --- a/codersdk/agentsdk/agentsdk.go +++ b/codersdk/agentsdk/agentsdk.go @@ -1021,6 +1021,13 @@ type ClearChatContextResponse struct { ChatID uuid.UUID `json:"chat_id"` } +// RefreshChatContextResponse is the response for refreshing chat context. +type RefreshChatContextResponse struct { + // Refreshed is the number of drifted chats that were re-pinned to the + // agent's latest context snapshot. + Refreshed int `json:"refreshed"` +} + // AddChatContext adds context-file and skill parts to an active chat. func (c *Client) AddChatContext(ctx context.Context, req AddChatContextRequest) (AddChatContextResponse, error) { res, err := c.SDK.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/me/experimental/chat-context", req) @@ -1052,3 +1059,22 @@ func (c *Client) ClearChatContext(ctx context.Context, req ClearChatContextReque var resp ClearChatContextResponse return resp, json.NewDecoder(res.Body).Decode(&resp) } + +// RefreshChatContext re-pins every drifted chat bound to this agent to the +// agent's latest context snapshot, clearing their drift markers. It backs +// the in-workspace `coder exp chat context refresh` (no chat argument), +// which authenticates with the agent token rather than a user session. +func (c *Client) RefreshChatContext(ctx context.Context) (RefreshChatContextResponse, error) { + res, err := c.SDK.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/me/experimental/chat-context/refresh", nil) + if err != nil { + return RefreshChatContextResponse{}, xerrors.Errorf("execute request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return RefreshChatContextResponse{}, codersdk.ReadBodyAsError(res) + } + + var resp RefreshChatContextResponse + return resp, json.NewDecoder(res.Body).Decode(&resp) +} diff --git a/codersdk/chats.go b/codersdk/chats.go index 0005b5f1d1608..b9311098171ec 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -168,6 +168,106 @@ type ChatContext struct { // Error is the snapshot-level error copied from the pinned snapshot // (empty when healthy). Error string `json:"error,omitempty"` + // Resources is the chat's pinned context (instruction files and + // skills) the prompt is built from, metadata only (no bodies). It is + // populated only on the single-chat GET response; list and watch + // payloads leave it nil to stay lightweight. + Resources []ChatContextResource `json:"resources,omitempty"` + // Changes lists how the pinned context differs from the agent's latest + // snapshot, by source. It is populated only on the single-chat GET + // response and only while the chat is dirty; otherwise nil. + Changes []ChatContextResourceChange `json:"changes,omitempty"` +} + +// ChatContextResourceKind classifies a pinned context resource the prompt +// uses. Only the kinds that contribute to the prompt are reported. +type ChatContextResourceKind string + +const ( + ChatContextResourceKindInstructionFile ChatContextResourceKind = "instruction_file" + ChatContextResourceKindSkill ChatContextResourceKind = "skill" + ChatContextResourceKindMCPConfig ChatContextResourceKind = "mcp_config" + ChatContextResourceKindMCPServer ChatContextResourceKind = "mcp_server" +) + +// ChatContextResource is one pinned workspace-context resource the chat's +// prompt is built from. It is metadata only; bodies are omitted. Reported +// only on the single-chat GET response. +type ChatContextResource struct { + // Source is the resource locator: the canonical file path for an + // instruction file, the skill directory for a skill, the file path for + // an MCP config, or the server name for an MCP server. + Source string `json:"source"` + Kind ChatContextResourceKind `json:"kind"` + // SizeBytes is the original payload size in bytes. + SizeBytes int64 `json:"size_bytes"` + // SkillName and SkillDescription are populated only for skill kinds. + SkillName string `json:"skill_name,omitempty"` + SkillDescription string `json:"skill_description,omitempty"` + // McpTools lists the tools exposed by an MCP server. Populated only for + // the mcp_server kind; nil otherwise. + McpTools []ChatContextMCPTool `json:"mcp_tools,omitempty"` + // Status is the resource's health. Non-ok resources (invalid, unreadable, + // oversize, excluded) are still reported so the UI can surface why a + // resource was dropped from the prompt instead of silently omitting it; + // their body-specific fields (skill name, MCP tools) are empty. + Status ChatContextResourceStatus `json:"status"` + // Error explains a non-ok Status; empty when healthy. May also carry a + // non-fatal warning when Status is ok. + Error string `json:"error,omitempty"` +} + +// ChatContextResourceStatus is the health of a pinned context resource, +// mirroring the agent resolver's per-resource status. +type ChatContextResourceStatus string + +const ( + ChatContextResourceStatusOK ChatContextResourceStatus = "ok" + ChatContextResourceStatusOversize ChatContextResourceStatus = "oversize" + ChatContextResourceStatusUnreadable ChatContextResourceStatus = "unreadable" + ChatContextResourceStatusInvalid ChatContextResourceStatus = "invalid" + ChatContextResourceStatusExcluded ChatContextResourceStatus = "excluded" +) + +// ChatContextMCPTool is one tool exposed by a pinned MCP server, reported on +// the single-chat GET response. Metadata only; the input schema is omitted. +type ChatContextMCPTool struct { + // Name is the tool name with the "__" prefix the agent adds + // stripped, so it reads as the server exposes it. + Name string `json:"name"` + // Description is the tool's human-readable summary; may be empty. + Description string `json:"description,omitempty"` +} + +// ChatContextResourceChangeStatus classifies how a source differs between the +// chat's pinned context and the agent's latest snapshot. +type ChatContextResourceChangeStatus string + +const ( + ChatContextResourceChangeStatusAdded ChatContextResourceChangeStatus = "added" + ChatContextResourceChangeStatusRemoved ChatContextResourceChangeStatus = "removed" + ChatContextResourceChangeStatusModified ChatContextResourceChangeStatus = "modified" +) + +// ChatContextResourceChange is one source-level difference between the chat's +// pinned context and the agent's latest snapshot. Reported only on the +// single-chat GET response while the chat is dirty. +type ChatContextResourceChange struct { + // Source is the resource locator that differs. + Source string `json:"source"` + Kind ChatContextResourceKind `json:"kind"` + Status ChatContextResourceChangeStatus `json:"status"` + // OldContent and NewContent carry the sanitized instruction-file bodies + // for the pinned and snapshot sides, capped for display. Removed changes + // fill OldContent only, added changes fill NewContent only, and modified + // changes fill both. Empty for skills. + OldContent string `json:"old_content,omitempty"` + NewContent string `json:"new_content,omitempty"` + // SkillName and SkillDescription identify a changed skill: the snapshot + // side for added/modified, the pinned side for removed. Empty for + // instruction files. + SkillName string `json:"skill_name,omitempty"` + SkillDescription string `json:"skill_description,omitempty"` } // ChatFileMetadata contains lightweight metadata about a file diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index f3597c44627ca..eb5fdefe33a0c 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -37,9 +37,37 @@ Experimental: this endpoint is subject to change. ], "client_type": "ui", "context": { + "changes": [ + { + "kind": "instruction_file", + "new_content": "string", + "old_content": "string", + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "added" + } + ], "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "error": "string", + "kind": "instruction_file", + "mcp_tools": [ + { + "description": "string", + "name": "string" + } + ], + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok" + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -186,130 +214,149 @@ Experimental: this endpoint is subject to change. Status Code **200** -| Name | Type | Required | Restrictions | Description | -|-----------------------------------|------------------------------------------------------------------------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `[array item]` | array | false | | | -| `» agent_id` | string(uuid) | false | | | -| `» archived` | boolean | false | | | -| `» build_id` | string(uuid) | false | | | -| `» children` | [codersdk.Chat](schemas.md#codersdkchat) | false | | Children holds child (subagent) chats nested under this root chat. Always initialized to an empty slice so the JSON field is present as []. Child chats cannot create their own subagents, so nesting depth is capped at 1 and this slice is always empty for child chats. | -| `» client_type` | [codersdk.ChatClientType](schemas.md#codersdkchatclienttype) | false | | | -| `» context` | [codersdk.ChatContext](schemas.md#codersdkchatcontext) | false | | Context reports the chat's pinned workspace-context state and whether it has drifted from the agent's latest pushed snapshot. Nil when the chat has no pinned context yet. | -| `»» dirty` | boolean | false | | Dirty is true when the agent's latest snapshot hash differs from the chat's pinned hash. | -| `»» dirty_since` | string(date-time) | false | | Dirty since is when drift was first detected; nil when not dirty. | -| `»» error` | string | false | | Error is the snapshot-level error copied from the pinned snapshot (empty when healthy). | -| `» created_at` | string(date-time) | false | | | -| `» diff_status` | [codersdk.ChatDiffStatus](schemas.md#codersdkchatdiffstatus) | false | | | -| `»» additions` | integer | false | | | -| `»» approved` | boolean | false | | | -| `»» author_avatar_url` | string | false | | | -| `»» author_login` | string | false | | | -| `»» base_branch` | string | false | | | -| `»» changed_files` | integer | false | | | -| `»» changes_requested` | boolean | false | | | -| `»» chat_id` | string(uuid) | false | | | -| `»» commits` | integer | false | | | -| `»» deletions` | integer | false | | | -| `»» head_branch` | string | false | | | -| `»» pr_number` | integer | false | | | -| `»» pull_request_draft` | boolean | false | | | -| `»» pull_request_state` | string | false | | | -| `»» pull_request_title` | string | false | | | -| `»» refreshed_at` | string(date-time) | false | | | -| `»» reviewer_count` | integer | false | | | -| `»» stale_at` | string(date-time) | false | | | -| `»» url` | string | false | | | -| `» files` | array | false | | | -| `»» created_at` | string(date-time) | false | | | -| `»» id` | string(uuid) | false | | | -| `»» mime_type` | string | false | | | -| `»» name` | string | false | | | -| `»» organization_id` | string(uuid) | false | | | -| `»» owner_id` | string(uuid) | false | | | -| `» has_unread` | boolean | false | | Has unread is true when assistant messages exist beyond the owner's read cursor, which updates on stream connect and disconnect. | -| `» id` | string(uuid) | false | | | -| `» labels` | object | false | | | -| `»» [any property]` | string | false | | | -| `» last_error` | [codersdk.ChatError](schemas.md#codersdkchaterror) | false | | | -| `»» detail` | string | false | | Detail is optional provider-specific context shown alongside the normalized error message when available. | -| `»» kind` | [codersdk.ChatErrorKind](schemas.md#codersdkchaterrorkind) | false | | Kind classifies the error for consistent client rendering. | -| `»» message` | string | false | | Message is the normalized, user-facing error message. | -| `»» provider` | string | false | | Provider identifies the upstream model provider when known. | -| `»» retryable` | boolean | false | | Retryable reports whether the underlying error is transient. | -| `»» status_code` | integer | false | | Status code is the best-effort upstream HTTP status code. | -| `» last_injected_context` | array | false | | Last injected context holds the most recently persisted injected context parts (AGENTS.md files and skills). It is updated only when context changes, on first workspace attach or agent change. | -| `»» args` | array | false | | | -| `»» args_delta` | string | false | | | -| `»» completed_at` | string(date-time) | false | | Completed at is the time a reasoning part finished streaming, so reasoning duration can be computed as completed_at minus created_at. For interrupted reasoning, this is the interruption time. Absent when reasoning timestamp data was not recorded (e.g. messages persisted before this feature was added). | -| `»» content` | string | false | | The code content from the diff that was commented on. | -| `»» context_file_agent_id` | [uuid.NullUUID](schemas.md#uuidnulluuid) | false | | Context file agent ID is the workspace agent that provided this context file. Used to detect when the agent changes (e.g. workspace rebuilt) so instruction files can be re-persisted with fresh content. | -| `»»» uuid` | string | false | | | -| `»»» valid` | boolean | false | | Valid is true if UUID is not NULL | -| `»» context_file_content` | string | false | | Context file content holds the file content sent to the LLM. Internal only: stripped before API responses to keep payloads small. The backend reads it when building the prompt via partsToMessageParts. | -| `»» context_file_directory` | string | false | | Context file directory is the working directory of the workspace agent. Internal only: same purpose as ContextFileOS. | -| `»» context_file_os` | string | false | | Context file os is the operating system of the workspace agent. Internal only: used during prompt expansion so the LLM knows the OS even on turns where InsertSystem is not called. | -| `»» context_file_path` | string | false | | Context file path is the absolute path of a file loaded into the LLM context (e.g. an AGENTS.md instruction file). | -| `»» context_file_skill_meta_file` | string | false | | Context file skill meta file is the basename of the skill meta file (e.g. "SKILL.md") at the time of persistence. Internal only: restored on subsequent turns so the read_skill tool uses the correct filename even when the agent configured a non-default value. | -| `»» context_file_truncated` | boolean | false | | Context file truncated indicates the file exceeded the 64KiB instruction file limit and was truncated. | -| `»» created_at` | string(date-time) | false | | Created at is the timestamp this part carries. The semantics depend on the part type: for tool-call and tool-result parts it is the time the call was emitted or the result was produced (tool duration is the result's created_at minus the call's created_at); for reasoning parts it is the time reasoning started streaming. | -| `»» data` | array | false | | | -| `»» end_line` | integer | false | | | -| `»» file_id` | [uuid.NullUUID](schemas.md#uuidnulluuid) | false | | | -| `»»» uuid` | string | false | | | -| `»»» valid` | boolean | false | | Valid is true if UUID is not NULL | -| `»» file_name` | string | false | | | -| `»» is_error` | boolean | false | | | -| `»» is_media` | boolean | false | | | -| `»» mcp_server_config_id` | [uuid.NullUUID](schemas.md#uuidnulluuid) | false | | | -| `»»» uuid` | string | false | | | -| `»»» valid` | boolean | false | | Valid is true if UUID is not NULL | -| `»» media_type` | string | false | | | -| `»» name` | string | false | | | -| `»» parsed_commands` | array | false | | Parsed commands holds parsed programs from an execute tool call's shell command, one entry per simple command in source order. Each entry is [program] or [program, arg] where arg is the first non-flag positional argument. Program names are normalized to their base name (e.g. /usr/bin/go becomes go). Only populated when ToolName is "execute" and the command parses successfully; nil otherwise. | -| `»» provider_executed` | boolean | false | | Provider executed indicates the tool call was executed by the provider (e.g. Anthropic computer use). | -| `»» provider_metadata` | array | false | | Provider metadata holds provider-specific response metadata (e.g. Anthropic cache control hints) as raw JSON. Internal only: stripped by db2sdk before API responses. | -| `»» result` | array | false | | | -| `»» result_delta` | string | false | | | -| `»» result_reset` | boolean | false | | | -| `»» signature` | string | false | | | -| `»» skill_description` | string | false | | Skill description is the short description from the skill's SKILL.md frontmatter. | -| `»» skill_dir` | string | false | | Skill dir is the absolute path to the skill directory inside the workspace filesystem. Internal only: used by read_skill/read_skill_file tools to locate skill files. | -| `»» skill_name` | string | false | | Skill name is the kebab-case name of a discovered skill from the workspace's .agents/skills/ directory. | -| `»» source_id` | string | false | | | -| `»» start_line` | integer | false | | | -| `»» text` | string | false | | | -| `»» title` | string | false | | | -| `»» tool_call_id` | string | false | | | -| `»» tool_name` | string | false | | | -| `»» type` | [codersdk.ChatMessagePartType](schemas.md#codersdkchatmessageparttype) | false | | | -| `»» url` | string | false | | | -| `» last_model_config_id` | string(uuid) | false | | | -| `» last_turn_summary` | string | false | | | -| `» mcp_server_ids` | array | false | | | -| `» organization_id` | string(uuid) | false | | | -| `» owner_id` | string(uuid) | false | | | -| `» owner_name` | string | false | | | -| `» owner_username` | string | false | | | -| `» parent_chat_id` | string(uuid) | false | | | -| `» pin_order` | integer | false | | | -| `» plan_mode` | [codersdk.ChatPlanMode](schemas.md#codersdkchatplanmode) | false | | | -| `» root_chat_id` | string(uuid) | false | | | -| `» shared` | boolean | false | | Shared is true when this chat's root chat has explicit user or group ACL entries. | -| `» status` | [codersdk.ChatStatus](schemas.md#codersdkchatstatus) | false | | | -| `» title` | string | false | | | -| `» updated_at` | string(date-time) | false | | | -| `» warnings` | array | false | | | -| `» workspace_id` | string(uuid) | false | | | +| Name | Type | Required | Restrictions | Description | +|-----------------------------------|------------------------------------------------------------------------------------------------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `[array item]` | array | false | | | +| `» agent_id` | string(uuid) | false | | | +| `» archived` | boolean | false | | | +| `» build_id` | string(uuid) | false | | | +| `» children` | [codersdk.Chat](schemas.md#codersdkchat) | false | | Children holds child (subagent) chats nested under this root chat. Always initialized to an empty slice so the JSON field is present as []. Child chats cannot create their own subagents, so nesting depth is capped at 1 and this slice is always empty for child chats. | +| `» client_type` | [codersdk.ChatClientType](schemas.md#codersdkchatclienttype) | false | | | +| `» context` | [codersdk.ChatContext](schemas.md#codersdkchatcontext) | false | | Context reports the chat's pinned workspace-context state and whether it has drifted from the agent's latest pushed snapshot. Nil when the chat has no pinned context yet. | +| `»» changes` | array | false | | Changes lists how the pinned context differs from the agent's latest snapshot, by source. It is populated only on the single-chat GET response and only while the chat is dirty; otherwise nil. | +| `»»» kind` | [codersdk.ChatContextResourceKind](schemas.md#codersdkchatcontextresourcekind) | false | | | +| `»»» new_content` | string | false | | | +| `»»» old_content` | string | false | | Old content and NewContent carry the sanitized instruction-file bodies for the pinned and snapshot sides, capped for display. Removed changes fill OldContent only, added changes fill NewContent only, and modified changes fill both. Empty for skills. | +| `»»» skill_description` | string | false | | | +| `»»» skill_name` | string | false | | Skill name and SkillDescription identify a changed skill: the snapshot side for added/modified, the pinned side for removed. Empty for instruction files. | +| `»»» source` | string | false | | Source is the resource locator that differs. | +| `»»» status` | [codersdk.ChatContextResourceChangeStatus](schemas.md#codersdkchatcontextresourcechangestatus) | false | | | +| `»» dirty` | boolean | false | | Dirty is true when the agent's latest snapshot hash differs from the chat's pinned hash. | +| `»» dirty_since` | string(date-time) | false | | Dirty since is when drift was first detected; nil when not dirty. | +| `»» error` | string | false | | Error is the snapshot-level error copied from the pinned snapshot (empty when healthy). | +| `»» resources` | array | false | | Resources is the chat's pinned context (instruction files and skills) the prompt is built from, metadata only (no bodies). It is populated only on the single-chat GET response; list and watch payloads leave it nil to stay lightweight. | +| `»»» error` | string | false | | Error explains a non-ok Status; empty when healthy. May also carry a non-fatal warning when Status is ok. | +| `»»» kind` | [codersdk.ChatContextResourceKind](schemas.md#codersdkchatcontextresourcekind) | false | | | +| `»»» mcp_tools` | array | false | | Mcp tools lists the tools exposed by an MCP server. Populated only for the mcp_server kind; nil otherwise. | +| `»»»» description` | string | false | | Description is the tool's human-readable summary; may be empty. | +| `»»»» name` | string | false | | Name is the tool name with the "__" prefix the agent adds stripped, so it reads as the server exposes it. | +| `»»» size_bytes` | integer | false | | Size bytes is the original payload size in bytes. | +| `»»» skill_description` | string | false | | | +| `»»» skill_name` | string | false | | Skill name and SkillDescription are populated only for skill kinds. | +| `»»» source` | string | false | | Source is the resource locator: the canonical file path for an instruction file, the skill directory for a skill, the file path for an MCP config, or the server name for an MCP server. | +| `»»» status` | [codersdk.ChatContextResourceStatus](schemas.md#codersdkchatcontextresourcestatus) | false | | Status is the resource's health. Non-ok resources (invalid, unreadable, oversize, excluded) are still reported so the UI can surface why a resource was dropped from the prompt instead of silently omitting it; their body-specific fields (skill name, MCP tools) are empty. | +| `» created_at` | string(date-time) | false | | | +| `» diff_status` | [codersdk.ChatDiffStatus](schemas.md#codersdkchatdiffstatus) | false | | | +| `»» additions` | integer | false | | | +| `»» approved` | boolean | false | | | +| `»» author_avatar_url` | string | false | | | +| `»» author_login` | string | false | | | +| `»» base_branch` | string | false | | | +| `»» changed_files` | integer | false | | | +| `»» changes_requested` | boolean | false | | | +| `»» chat_id` | string(uuid) | false | | | +| `»» commits` | integer | false | | | +| `»» deletions` | integer | false | | | +| `»» head_branch` | string | false | | | +| `»» pr_number` | integer | false | | | +| `»» pull_request_draft` | boolean | false | | | +| `»» pull_request_state` | string | false | | | +| `»» pull_request_title` | string | false | | | +| `»» refreshed_at` | string(date-time) | false | | | +| `»» reviewer_count` | integer | false | | | +| `»» stale_at` | string(date-time) | false | | | +| `»» url` | string | false | | | +| `» files` | array | false | | | +| `»» created_at` | string(date-time) | false | | | +| `»» id` | string(uuid) | false | | | +| `»» mime_type` | string | false | | | +| `»» name` | string | false | | | +| `»» organization_id` | string(uuid) | false | | | +| `»» owner_id` | string(uuid) | false | | | +| `» has_unread` | boolean | false | | Has unread is true when assistant messages exist beyond the owner's read cursor, which updates on stream connect and disconnect. | +| `» id` | string(uuid) | false | | | +| `» labels` | object | false | | | +| `»» [any property]` | string | false | | | +| `» last_error` | [codersdk.ChatError](schemas.md#codersdkchaterror) | false | | | +| `»» detail` | string | false | | Detail is optional provider-specific context shown alongside the normalized error message when available. | +| `»» kind` | [codersdk.ChatErrorKind](schemas.md#codersdkchaterrorkind) | false | | Kind classifies the error for consistent client rendering. | +| `»» message` | string | false | | Message is the normalized, user-facing error message. | +| `»» provider` | string | false | | Provider identifies the upstream model provider when known. | +| `»» retryable` | boolean | false | | Retryable reports whether the underlying error is transient. | +| `»» status_code` | integer | false | | Status code is the best-effort upstream HTTP status code. | +| `» last_injected_context` | array | false | | Last injected context holds the most recently persisted injected context parts (AGENTS.md files and skills). It is updated only when context changes, on first workspace attach or agent change. | +| `»» args` | array | false | | | +| `»» args_delta` | string | false | | | +| `»» completed_at` | string(date-time) | false | | Completed at is the time a reasoning part finished streaming, so reasoning duration can be computed as completed_at minus created_at. For interrupted reasoning, this is the interruption time. Absent when reasoning timestamp data was not recorded (e.g. messages persisted before this feature was added). | +| `»» content` | string | false | | The code content from the diff that was commented on. | +| `»» context_file_agent_id` | [uuid.NullUUID](schemas.md#uuidnulluuid) | false | | Context file agent ID is the workspace agent that provided this context file. Used to detect when the agent changes (e.g. workspace rebuilt) so instruction files can be re-persisted with fresh content. | +| `»»» uuid` | string | false | | | +| `»»» valid` | boolean | false | | Valid is true if UUID is not NULL | +| `»» context_file_content` | string | false | | Context file content holds the file content sent to the LLM. Internal only: stripped before API responses to keep payloads small. The backend reads it when building the prompt via partsToMessageParts. | +| `»» context_file_directory` | string | false | | Context file directory is the working directory of the workspace agent. Internal only: same purpose as ContextFileOS. | +| `»» context_file_os` | string | false | | Context file os is the operating system of the workspace agent. Internal only: used during prompt expansion so the LLM knows the OS even on turns where InsertSystem is not called. | +| `»» context_file_path` | string | false | | Context file path is the absolute path of a file loaded into the LLM context (e.g. an AGENTS.md instruction file). | +| `»» context_file_skill_meta_file` | string | false | | Context file skill meta file is the basename of the skill meta file (e.g. "SKILL.md") at the time of persistence. Internal only: restored on subsequent turns so the read_skill tool uses the correct filename even when the agent configured a non-default value. | +| `»» context_file_truncated` | boolean | false | | Context file truncated indicates the file exceeded the 64KiB instruction file limit and was truncated. | +| `»» created_at` | string(date-time) | false | | Created at is the timestamp this part carries. The semantics depend on the part type: for tool-call and tool-result parts it is the time the call was emitted or the result was produced (tool duration is the result's created_at minus the call's created_at); for reasoning parts it is the time reasoning started streaming. | +| `»» data` | array | false | | | +| `»» end_line` | integer | false | | | +| `»» file_id` | [uuid.NullUUID](schemas.md#uuidnulluuid) | false | | | +| `»»» uuid` | string | false | | | +| `»»» valid` | boolean | false | | Valid is true if UUID is not NULL | +| `»» file_name` | string | false | | | +| `»» is_error` | boolean | false | | | +| `»» is_media` | boolean | false | | | +| `»» mcp_server_config_id` | [uuid.NullUUID](schemas.md#uuidnulluuid) | false | | | +| `»»» uuid` | string | false | | | +| `»»» valid` | boolean | false | | Valid is true if UUID is not NULL | +| `»» media_type` | string | false | | | +| `»» name` | string | false | | | +| `»» parsed_commands` | array | false | | Parsed commands holds parsed programs from an execute tool call's shell command, one entry per simple command in source order. Each entry is [program] or [program, arg] where arg is the first non-flag positional argument. Program names are normalized to their base name (e.g. /usr/bin/go becomes go). Only populated when ToolName is "execute" and the command parses successfully; nil otherwise. | +| `»» provider_executed` | boolean | false | | Provider executed indicates the tool call was executed by the provider (e.g. Anthropic computer use). | +| `»» provider_metadata` | array | false | | Provider metadata holds provider-specific response metadata (e.g. Anthropic cache control hints) as raw JSON. Internal only: stripped by db2sdk before API responses. | +| `»» result` | array | false | | | +| `»» result_delta` | string | false | | | +| `»» result_reset` | boolean | false | | | +| `»» signature` | string | false | | | +| `»» skill_description` | string | false | | Skill description is the short description from the skill's SKILL.md frontmatter. | +| `»» skill_dir` | string | false | | Skill dir is the absolute path to the skill directory inside the workspace filesystem. Internal only: used by read_skill/read_skill_file tools to locate skill files. | +| `»» skill_name` | string | false | | Skill name is the kebab-case name of a discovered skill from the workspace's .agents/skills/ directory. | +| `»» source_id` | string | false | | | +| `»» start_line` | integer | false | | | +| `»» text` | string | false | | | +| `»» title` | string | false | | | +| `»» tool_call_id` | string | false | | | +| `»» tool_name` | string | false | | | +| `»» type` | [codersdk.ChatMessagePartType](schemas.md#codersdkchatmessageparttype) | false | | | +| `»» url` | string | false | | | +| `» last_model_config_id` | string(uuid) | false | | | +| `» last_turn_summary` | string | false | | | +| `» mcp_server_ids` | array | false | | | +| `» organization_id` | string(uuid) | false | | | +| `» owner_id` | string(uuid) | false | | | +| `» owner_name` | string | false | | | +| `» owner_username` | string | false | | | +| `» parent_chat_id` | string(uuid) | false | | | +| `» pin_order` | integer | false | | | +| `» plan_mode` | [codersdk.ChatPlanMode](schemas.md#codersdkchatplanmode) | false | | | +| `» root_chat_id` | string(uuid) | false | | | +| `» shared` | boolean | false | | Shared is true when this chat's root chat has explicit user or group ACL entries. | +| `» status` | [codersdk.ChatStatus](schemas.md#codersdkchatstatus) | false | | | +| `» title` | string | false | | | +| `» updated_at` | string(date-time) | false | | | +| `» warnings` | array | false | | | +| `» workspace_id` | string(uuid) | false | | | #### Enumerated Values -| Property | Value(s) | -|---------------|-------------------------------------------------------------------------------------------------------------------------------------------------| -| `client_type` | `api`, `ui` | -| `kind` | `auth`, `config`, `generic`, `missing_key`, `overloaded`, `provider_disabled`, `rate_limit`, `stream_silence_timeout`, `timeout`, `usage_limit` | -| `type` | `context-file`, `file`, `file-reference`, `reasoning`, `skill`, `source`, `text`, `tool-call`, `tool-result` | -| `plan_mode` | `plan` | -| `status` | `completed`, `error`, `interrupting`, `paused`, `pending`, `requires_action`, `running`, `waiting` | +| Property | Value(s) | +|---------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `client_type` | `api`, `ui` | +| `kind` | `auth`, `config`, `generic`, `instruction_file`, `mcp_config`, `mcp_server`, `missing_key`, `overloaded`, `provider_disabled`, `rate_limit`, `skill`, `stream_silence_timeout`, `timeout`, `usage_limit` | +| `status` | `added`, `completed`, `error`, `excluded`, `interrupting`, `invalid`, `modified`, `ok`, `oversize`, `paused`, `pending`, `removed`, `requires_action`, `running`, `unreadable`, `waiting` | +| `type` | `context-file`, `file`, `file-reference`, `reasoning`, `skill`, `source`, `text`, `tool-call`, `tool-result` | +| `plan_mode` | `plan` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -392,9 +439,37 @@ Experimental: this endpoint is subject to change. "children": [], "client_type": "ui", "context": { + "changes": [ + { + "kind": "instruction_file", + "new_content": "string", + "old_content": "string", + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "added" + } + ], "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "error": "string", + "kind": "instruction_file", + "mcp_tools": [ + { + "description": "string", + "name": "string" + } + ], + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok" + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -531,9 +606,37 @@ Experimental: this endpoint is subject to change. ], "client_type": "ui", "context": { + "changes": [ + { + "kind": "instruction_file", + "new_content": "string", + "old_content": "string", + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "added" + } + ], "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "error": "string", + "kind": "instruction_file", + "mcp_tools": [ + { + "description": "string", + "name": "string" + } + ], + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok" + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -821,9 +924,37 @@ Experimental: this endpoint is subject to change. ], "client_type": "ui", "context": { + "changes": [ + { + "kind": "instruction_file", + "new_content": "string", + "old_content": "string", + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "added" + } + ], "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "error": "string", + "kind": "instruction_file", + "mcp_tools": [ + { + "description": "string", + "name": "string" + } + ], + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok" + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -1014,9 +1145,37 @@ Experimental: this endpoint is subject to change. "children": [], "client_type": "ui", "context": { + "changes": [ + { + "kind": "instruction_file", + "new_content": "string", + "old_content": "string", + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "added" + } + ], "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "error": "string", + "kind": "instruction_file", + "mcp_tools": [ + { + "description": "string", + "name": "string" + } + ], + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok" + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -1153,9 +1312,37 @@ Experimental: this endpoint is subject to change. ], "client_type": "ui", "context": { + "changes": [ + { + "kind": "instruction_file", + "new_content": "string", + "old_content": "string", + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "added" + } + ], "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "error": "string", + "kind": "instruction_file", + "mcp_tools": [ + { + "description": "string", + "name": "string" + } + ], + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok" + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -1383,9 +1570,37 @@ Experimental: this endpoint is subject to change. "children": [], "client_type": "ui", "context": { + "changes": [ + { + "kind": "instruction_file", + "new_content": "string", + "old_content": "string", + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "added" + } + ], "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "error": "string", + "kind": "instruction_file", + "mcp_tools": [ + { + "description": "string", + "name": "string" + } + ], + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok" + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -1522,9 +1737,37 @@ Experimental: this endpoint is subject to change. ], "client_type": "ui", "context": { + "changes": [ + { + "kind": "instruction_file", + "new_content": "string", + "old_content": "string", + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "added" + } + ], "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "error": "string", + "kind": "instruction_file", + "mcp_tools": [ + { + "description": "string", + "name": "string" + } + ], + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok" + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -1750,9 +1993,37 @@ Experimental: this endpoint is subject to change. "children": [], "client_type": "ui", "context": { + "changes": [ + { + "kind": "instruction_file", + "new_content": "string", + "old_content": "string", + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "added" + } + ], "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "error": "string", + "kind": "instruction_file", + "mcp_tools": [ + { + "description": "string", + "name": "string" + } + ], + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok" + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -1889,9 +2160,37 @@ Experimental: this endpoint is subject to change. ], "client_type": "ui", "context": { + "changes": [ + { + "kind": "instruction_file", + "new_content": "string", + "old_content": "string", + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "added" + } + ], "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "error": "string", + "kind": "instruction_file", + "mcp_tools": [ + { + "description": "string", + "name": "string" + } + ], + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok" + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -2684,9 +2983,37 @@ Experimental: this endpoint is subject to change. "children": [], "client_type": "ui", "context": { + "changes": [ + { + "kind": "instruction_file", + "new_content": "string", + "old_content": "string", + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "added" + } + ], "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "error": "string", + "kind": "instruction_file", + "mcp_tools": [ + { + "description": "string", + "name": "string" + } + ], + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok" + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -2823,9 +3150,37 @@ Experimental: this endpoint is subject to change. ], "client_type": "ui", "context": { + "changes": [ + { + "kind": "instruction_file", + "new_content": "string", + "old_content": "string", + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "added" + } + ], "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "error": "string", + "kind": "instruction_file", + "mcp_tools": [ + { + "description": "string", + "name": "string" + } + ], + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok" + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -3376,9 +3731,37 @@ Experimental: this endpoint is subject to change. "children": [], "client_type": "ui", "context": { + "changes": [ + { + "kind": "instruction_file", + "new_content": "string", + "old_content": "string", + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "added" + } + ], "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "error": "string", + "kind": "instruction_file", + "mcp_tools": [ + { + "description": "string", + "name": "string" + } + ], + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok" + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -3515,9 +3898,37 @@ Experimental: this endpoint is subject to change. ], "client_type": "ui", "context": { + "changes": [ + { + "kind": "instruction_file", + "new_content": "string", + "old_content": "string", + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "added" + } + ], "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "error": "string", + "kind": "instruction_file", + "mcp_tools": [ + { + "description": "string", + "name": "string" + } + ], + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok" + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 8998babc87da7..ba5b59848bd83 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1926,9 +1926,37 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "children": [], "client_type": "ui", "context": { + "changes": [ + { + "kind": "instruction_file", + "new_content": "string", + "old_content": "string", + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "added" + } + ], "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "error": "string", + "kind": "instruction_file", + "mcp_tools": [ + { + "description": "string", + "name": "string" + } + ], + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok" + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -2065,9 +2093,37 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ], "client_type": "ui", "context": { + "changes": [ + { + "kind": "instruction_file", + "new_content": "string", + "old_content": "string", + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "added" + } + ], "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "error": "string", + "kind": "instruction_file", + "mcp_tools": [ + { + "description": "string", + "name": "string" + } + ], + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok" + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -2342,19 +2398,166 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ```json { + "changes": [ + { + "kind": "instruction_file", + "new_content": "string", + "old_content": "string", + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "added" + } + ], "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "error": "string", + "kind": "instruction_file", + "mcp_tools": [ + { + "description": "string", + "name": "string" + } + ], + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok" + } + ] } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -|---------------|---------|----------|--------------|------------------------------------------------------------------------------------------| -| `dirty` | boolean | false | | Dirty is true when the agent's latest snapshot hash differs from the chat's pinned hash. | -| `dirty_since` | string | false | | Dirty since is when drift was first detected; nil when not dirty. | -| `error` | string | false | | Error is the snapshot-level error copied from the pinned snapshot (empty when healthy). | +| Name | Type | Required | Restrictions | Description | +|---------------|-----------------------------------------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `changes` | array of [codersdk.ChatContextResourceChange](#codersdkchatcontextresourcechange) | false | | Changes lists how the pinned context differs from the agent's latest snapshot, by source. It is populated only on the single-chat GET response and only while the chat is dirty; otherwise nil. | +| `dirty` | boolean | false | | Dirty is true when the agent's latest snapshot hash differs from the chat's pinned hash. | +| `dirty_since` | string | false | | Dirty since is when drift was first detected; nil when not dirty. | +| `error` | string | false | | Error is the snapshot-level error copied from the pinned snapshot (empty when healthy). | +| `resources` | array of [codersdk.ChatContextResource](#codersdkchatcontextresource) | false | | Resources is the chat's pinned context (instruction files and skills) the prompt is built from, metadata only (no bodies). It is populated only on the single-chat GET response; list and watch payloads leave it nil to stay lightweight. | + +## codersdk.ChatContextMCPTool + +```json +{ + "description": "string", + "name": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|---------------|--------|----------|--------------|-------------------------------------------------------------------------------------------------------------------| +| `description` | string | false | | Description is the tool's human-readable summary; may be empty. | +| `name` | string | false | | Name is the tool name with the "__" prefix the agent adds stripped, so it reads as the server exposes it. | + +## codersdk.ChatContextResource + +```json +{ + "error": "string", + "kind": "instruction_file", + "mcp_tools": [ + { + "description": "string", + "name": "string" + } + ], + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|---------------------|--------------------------------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `error` | string | false | | Error explains a non-ok Status; empty when healthy. May also carry a non-fatal warning when Status is ok. | +| `kind` | [codersdk.ChatContextResourceKind](#codersdkchatcontextresourcekind) | false | | | +| `mcp_tools` | array of [codersdk.ChatContextMCPTool](#codersdkchatcontextmcptool) | false | | Mcp tools lists the tools exposed by an MCP server. Populated only for the mcp_server kind; nil otherwise. | +| `size_bytes` | integer | false | | Size bytes is the original payload size in bytes. | +| `skill_description` | string | false | | | +| `skill_name` | string | false | | Skill name and SkillDescription are populated only for skill kinds. | +| `source` | string | false | | Source is the resource locator: the canonical file path for an instruction file, the skill directory for a skill, the file path for an MCP config, or the server name for an MCP server. | +| `status` | [codersdk.ChatContextResourceStatus](#codersdkchatcontextresourcestatus) | false | | Status is the resource's health. Non-ok resources (invalid, unreadable, oversize, excluded) are still reported so the UI can surface why a resource was dropped from the prompt instead of silently omitting it; their body-specific fields (skill name, MCP tools) are empty. | + +## codersdk.ChatContextResourceChange + +```json +{ + "kind": "instruction_file", + "new_content": "string", + "old_content": "string", + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "added" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|---------------------|--------------------------------------------------------------------------------------|----------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `kind` | [codersdk.ChatContextResourceKind](#codersdkchatcontextresourcekind) | false | | | +| `new_content` | string | false | | | +| `old_content` | string | false | | Old content and NewContent carry the sanitized instruction-file bodies for the pinned and snapshot sides, capped for display. Removed changes fill OldContent only, added changes fill NewContent only, and modified changes fill both. Empty for skills. | +| `skill_description` | string | false | | | +| `skill_name` | string | false | | Skill name and SkillDescription identify a changed skill: the snapshot side for added/modified, the pinned side for removed. Empty for instruction files. | +| `source` | string | false | | Source is the resource locator that differs. | +| `status` | [codersdk.ChatContextResourceChangeStatus](#codersdkchatcontextresourcechangestatus) | false | | | + +## codersdk.ChatContextResourceChangeStatus + +```json +"added" +``` + +### Properties + +#### Enumerated Values + +| Value(s) | +|--------------------------------| +| `added`, `modified`, `removed` | + +## codersdk.ChatContextResourceKind + +```json +"instruction_file" +``` + +### Properties + +#### Enumerated Values + +| Value(s) | +|---------------------------------------------------------| +| `instruction_file`, `mcp_config`, `mcp_server`, `skill` | + +## codersdk.ChatContextResourceStatus + +```json +"ok" +``` + +### Properties + +#### Enumerated Values + +| Value(s) | +|-------------------------------------------------------| +| `excluded`, `invalid`, `ok`, `oversize`, `unreadable` | ## codersdk.ChatDiffContents @@ -3778,9 +3981,37 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ], "client_type": "ui", "context": { + "changes": [ + { + "kind": "instruction_file", + "new_content": "string", + "old_content": "string", + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "added" + } + ], "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "error": "string", + "kind": "instruction_file", + "mcp_tools": [ + { + "description": "string", + "name": "string" + } + ], + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok" + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 34eafbf592b68..1476ac2283e97 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -3377,6 +3377,17 @@ class ExperimentalApiMethods { return response.data; }; + /** + * Re-pins the chat to its agent's latest context snapshot and clears + * the dirty marker. Returns the updated chat. + */ + refreshChatContext = async (chatId: string): Promise => { + const response = await this.axios.put( + `/api/experimental/chats/${chatId}/context`, + ); + return response.data; + }; + deleteChatQueuedMessage = async ( chatId: string, queuedMessageId: number, diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index 81890268dec55..493d5058e4e60 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -2062,6 +2062,63 @@ describe("updateChildInParentCache", () => { }); describe("mergeWatchedChatSummary", () => { + it("applies context_dirty flags while preserving the pinned resource list", () => { + const cachedChat = makeChat("chat-1", { + updated_at: "2025-01-01T00:00:00.000Z", + context: { + dirty: false, + resources: [ + { + source: "/AGENTS.md", + kind: "instruction_file", + size_bytes: 10, + status: "ok", + }, + ], + }, + }); + const watchedChat = makeChat("chat-1", { + // Drift is tracked outside updated_at, so an older event timestamp + // still applies the dirty flags. + updated_at: "2024-12-31T00:00:00.000Z", + context: { dirty: true, dirty_since: "2025-01-02T00:00:00.000Z" }, + }); + + expect( + mergeWatchedChatSummary(cachedChat, watchedChat, { + eventKind: "context_dirty", + }).context, + ).toEqual({ + dirty: true, + dirty_since: "2025-01-02T00:00:00.000Z", + // The lightweight watch payload omits resources; the merge keeps the + // pinned list a prior single-chat GET populated. + resources: [ + { source: "/AGENTS.md", kind: "instruction_file", size_bytes: 10 }, + ], + }); + }); + + it("leaves context untouched for non-context events", () => { + const context = { dirty: true, dirty_since: "2025-01-02T00:00:00.000Z" }; + const cachedChat = makeChat("chat-1", { + status: "pending", + updated_at: "2025-01-01T00:00:00.000Z", + context, + }); + const watchedChat = makeChat("chat-1", { + status: "running", + updated_at: "2025-01-01T00:05:00.000Z", + context: { dirty: false }, + }); + + expect( + mergeWatchedChatSummary(cachedChat, watchedChat, { + eventKind: "status_change", + }).context, + ).toBe(context); + }); + it("merges fresh status updates without clobbering a newer title snapshot", () => { const cachedChat = makeChat("chat-1", { status: "pending", diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 28a9c62aa7c5c..b18620417f8b4 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -310,6 +310,7 @@ export const mergeWatchedChatSummary = ( const isStatusEvent = eventKind === "status_change"; const isSummaryEvent = eventKind === "summary_change"; const isDiffStatusEvent = eventKind === "diff_status_change"; + const isContextDirtyEvent = eventKind === "context_dirty"; const updatedAtComparison = compareUpdatedAtInstants( cachedChat.updated_at, watchedChat.updated_at, @@ -325,6 +326,15 @@ export const mergeWatchedChatSummary = ( const nextDiffStatus = isDiffStatusEvent ? watchedChat.diff_status : cachedChat.diff_status; + // Context drift is tracked outside chats.updated_at (it is driven by + // agent context pushes), so apply context_dirty payloads regardless of + // the summary timestamp. Merge rather than replace so the pinned + // resources/changes a single-chat GET populated are preserved while the + // dirty flags update; the open chat refetches the full detail. + const nextContext = + isContextDirtyEvent && watchedChat.context + ? { ...cachedChat.context, ...watchedChat.context } + : cachedChat.context; const nextWorkspaceId = isFreshEnough ? (watchedChat.workspace_id ?? cachedChat.workspace_id) : cachedChat.workspace_id; @@ -358,7 +368,8 @@ export const mergeWatchedChatSummary = ( nextLastModelConfigId === cachedChat.last_model_config_id && nextLastTurnSummary === cachedChat.last_turn_summary && nextHasUnread === cachedChat.has_unread && - nextUpdatedAt === cachedChat.updated_at + nextUpdatedAt === cachedChat.updated_at && + nextContext === cachedChat.context ) { return cachedChat; } @@ -374,6 +385,7 @@ export const mergeWatchedChatSummary = ( last_turn_summary: nextLastTurnSummary, has_unread: nextHasUnread, updated_at: nextUpdatedAt, + context: nextContext, }; }; @@ -1344,6 +1356,39 @@ export const interruptChat = (queryClient: QueryClient, chatId: string) => ({ }, }); +/** + * Re-pins the chat to its agent's latest context snapshot, clearing the + * dirty marker. On success the returned chat (carrying the freshly pinned + * resources) is written into the open-chat cache, and the lightweight + * context flags are propagated across the list caches so the dirty + * indicator clears in the sidebar too. + */ +export const refreshChatContext = ( + queryClient: QueryClient, + chatId: string, +) => ({ + mutationFn: () => API.experimental.refreshChatContext(chatId), + onSuccess: (updatedChat: TypesGen.Chat) => { + queryClient.setQueryData(chatKey(chatId), (cached) => + cached ? { ...cached, context: updatedChat.context } : updatedChat, + ); + const applyContext = (chat: TypesGen.Chat): TypesGen.Chat => + chat.id === chatId ? { ...chat, context: updatedChat.context } : chat; + updateInfiniteChatsCache(queryClient, (chats) => { + let changed = false; + const next = chats.map((chat) => { + const updated = applyContext(chat); + if (updated !== chat) { + changed = true; + } + return updated; + }); + return changed ? next : chats; + }); + updateChildInParentCache(queryClient, applyContext, chatId); + }, +}); + export const deleteChatQueuedMessage = ( queryClient: QueryClient, chatId: string, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 9b580dfec6cc7..8bf4aae6081bc 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1614,6 +1614,19 @@ export interface ChatContext { * (empty when healthy). */ readonly error?: string; + /** + * Resources is the chat's pinned context (instruction files and + * skills) the prompt is built from, metadata only (no bodies). It is + * populated only on the single-chat GET response; list and watch + * payloads leave it nil to stay lightweight. + */ + readonly resources?: readonly ChatContextResource[]; + /** + * Changes lists how the pinned context differs from the agent's latest + * snapshot, by source. It is populated only on the single-chat GET + * response and only while the chat is dirty; otherwise nil. + */ + readonly changes?: readonly ChatContextResourceChange[]; } // From codersdk/chats.go @@ -1638,6 +1651,131 @@ export interface ChatContextFilePart { readonly context_file_agent_id?: string; } +// From codersdk/chats.go +/** + * ChatContextMCPTool is one tool exposed by a pinned MCP server, reported on + * the single-chat GET response. Metadata only; the input schema is omitted. + */ +export interface ChatContextMCPTool { + /** + * Name is the tool name with the "__" prefix the agent adds + * stripped, so it reads as the server exposes it. + */ + readonly name: string; + /** + * Description is the tool's human-readable summary; may be empty. + */ + readonly description?: string; +} + +// From codersdk/chats.go +/** + * ChatContextResource is one pinned workspace-context resource the chat's + * prompt is built from. It is metadata only; bodies are omitted. Reported + * only on the single-chat GET response. + */ +export interface ChatContextResource { + /** + * Source is the resource locator: the canonical file path for an + * instruction file, the skill directory for a skill, the file path for + * an MCP config, or the server name for an MCP server. + */ + readonly source: string; + readonly kind: ChatContextResourceKind; + /** + * SizeBytes is the original payload size in bytes. + */ + readonly size_bytes: number; + /** + * SkillName and SkillDescription are populated only for skill kinds. + */ + readonly skill_name?: string; + readonly skill_description?: string; + /** + * McpTools lists the tools exposed by an MCP server. Populated only for + * the mcp_server kind; nil otherwise. + */ + readonly mcp_tools?: readonly ChatContextMCPTool[]; + /** + * Status is the resource's health. Non-ok resources (invalid, unreadable, + * oversize, excluded) are still reported so the UI can surface why a + * resource was dropped from the prompt instead of silently omitting it; + * their body-specific fields (skill name, MCP tools) are empty. + */ + readonly status: ChatContextResourceStatus; + /** + * Error explains a non-ok Status; empty when healthy. May also carry a + * non-fatal warning when Status is ok. + */ + readonly error?: string; +} + +// From codersdk/chats.go +/** + * ChatContextResourceChange is one source-level difference between the chat's + * pinned context and the agent's latest snapshot. Reported only on the + * single-chat GET response while the chat is dirty. + */ +export interface ChatContextResourceChange { + /** + * Source is the resource locator that differs. + */ + readonly source: string; + readonly kind: ChatContextResourceKind; + readonly status: ChatContextResourceChangeStatus; + /** + * OldContent and NewContent carry the sanitized instruction-file bodies + * for the pinned and snapshot sides, capped for display. Removed changes + * fill OldContent only, added changes fill NewContent only, and modified + * changes fill both. Empty for skills. + */ + readonly old_content?: string; + readonly new_content?: string; + /** + * SkillName and SkillDescription identify a changed skill: the snapshot + * side for added/modified, the pinned side for removed. Empty for + * instruction files. + */ + readonly skill_name?: string; + readonly skill_description?: string; +} + +// From codersdk/chats.go +export type ChatContextResourceChangeStatus = "added" | "modified" | "removed"; + +export const ChatContextResourceChangeStatuses: ChatContextResourceChangeStatus[] = + ["added", "modified", "removed"]; + +// From codersdk/chats.go +export type ChatContextResourceKind = + | "instruction_file" + | "mcp_config" + | "mcp_server" + | "skill"; + +export const ChatContextResourceKinds: ChatContextResourceKind[] = [ + "instruction_file", + "mcp_config", + "mcp_server", + "skill", +]; + +// From codersdk/chats.go +export type ChatContextResourceStatus = + | "excluded" + | "invalid" + | "ok" + | "oversize" + | "unreadable"; + +export const ChatContextResourceStatuses: ChatContextResourceStatus[] = [ + "excluded", + "invalid", + "ok", + "oversize", + "unreadable", +]; + // From codersdk/chats.go /** * ChatCostChatBreakdown contains per-root-chat cost aggregation. diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 3c6f85e725db2..80aa853169096 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -1674,6 +1674,7 @@ const AgentChatPage: FC = () => { onMCPSelectionChange={handleMCPSelectionChange} onMCPAuthComplete={handleMCPAuthComplete} lastInjectedContext={chatQuery.data?.last_injected_context} + chatContext={chatQuery.data?.context} /> ); }; diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index bf23e0b4fa390..46b7879bf28f5 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -216,6 +216,7 @@ interface AgentChatPageViewProps { desktopChatId?: string; lastInjectedContext?: readonly TypesGen.ChatMessagePart[]; + chatContext?: TypesGen.ChatContext; } const UnavailableTabMessage: FC<{ message: string }> = ({ message }) => ( @@ -373,6 +374,7 @@ export const AgentChatPageView: FC = ({ onMCPAuthComplete, desktopChatId, lastInjectedContext, + chatContext, }) => { const queryClient = useQueryClient(); const { proxy } = useProxy(); @@ -964,6 +966,7 @@ export const AgentChatPageView: FC = ({ onMCPSelectionChange={onMCPSelectionChange} onMCPAuthComplete={onMCPAuthComplete} lastInjectedContext={lastInjectedContext} + chatContext={chatContext} workspace={workspace} workspaceAgent={workspaceAgent} chatId={agentId} diff --git a/site/src/pages/AgentsPage/AgentsPage.tsx b/site/src/pages/AgentsPage/AgentsPage.tsx index 0584a6bf6488d..e13879dbd92db 100644 --- a/site/src/pages/AgentsPage/AgentsPage.tsx +++ b/site/src/pages/AgentsPage/AgentsPage.tsx @@ -645,6 +645,18 @@ const AgentsPage: FC = () => { if (shouldInvalidateFilteredChatList(updatedChat, chatEvent.kind)) { void invalidateChatListQueries(queryClient); } + if (chatEvent.kind === "context_dirty") { + // The watch payload carries only the lightweight + // context flags (the merge above applies them); + // refetch the open chat to pull the pinned + // resources and the change set the single-chat GET + // computes. Only the active chat has an observer, + // so other chats are merely marked stale. + void queryClient.invalidateQueries({ + queryKey: chatKey(updatedChat.id), + exact: true, + }); + } } }); return ws; diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index ea6f59dc5fdfc..809e829c6ff3d 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -162,6 +162,11 @@ interface AgentChatInputProps { // Pass `null` to render fallback values (e.g. when limit is unknown). // Omit entirely to hide the indicator. contextUsage?: AgentContextUsage | null; + // Re-pins the chat to the workspace's latest context snapshot, + // surfaced by the context indicator when the pinned context has + // drifted. + onRefreshContext?: () => void; + isRefreshingContext?: boolean; attachments?: readonly File[]; onAttach?: (files: File[]) => void; onRemoveAttachment?: (attachment: number | File) => void; @@ -367,6 +372,8 @@ export const AgentChatInput: FC = ({ onCancelHistoryEdit, userPromptHistory = [], contextUsage, + onRefreshContext, + isRefreshingContext, attachments = [], onAttach, onRemoveAttachment, @@ -1537,7 +1544,11 @@ export const AgentChatInput: FC = ({ )} {contextUsage !== undefined && ( - + )} {isStreaming && onInterrupt && ( + {onRefreshContext && ( + + )} + + + + ); +}; diff --git a/site/src/pages/AgentsPage/components/ContextUsageIndicator.stories.tsx b/site/src/pages/AgentsPage/components/ContextUsageIndicator.stories.tsx new file mode 100644 index 0000000000000..02b00e679a47a --- /dev/null +++ b/site/src/pages/AgentsPage/components/ContextUsageIndicator.stories.tsx @@ -0,0 +1,154 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, userEvent, waitFor, within } from "storybook/test"; +import { + MockChatContextClean, + MockChatContextDirty, + MockLastInjectedContextEmptyFile, +} from "#/testHelpers/chatEntities"; +import { ContextUsageIndicator } from "./ContextUsageIndicator"; + +const meta: Meta = { + title: "pages/AgentsPage/ContextUsageIndicator", + component: ContextUsageIndicator, + args: { + onRefreshContext: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +// Clean pin: the ring carries no change marker and the popover lists the +// pinned resources. +export const Clean: Story = { + args: { + usage: { + usedTokens: 12_000, + contextLimitTokens: 200_000, + context: MockChatContextClean, + }, + }, + play: async ({ canvasElement }) => { + const button = within(canvasElement).getByRole("button"); + expect(button.getAttribute("aria-label") ?? "").not.toContain( + "Context changed", + ); + + await userEvent.hover(button); + const body = within(document.body); + await waitFor(() => expect(body.getByText("Context files")).toBeVisible()); + // The list is driven by the pinned resources. + expect(body.getByText("AGENTS.md")).toBeVisible(); + expect(body.getByText("deploy")).toBeVisible(); + // MCP configs are listed by file basename and servers by name. + expect(body.getByText("MCP")).toBeVisible(); + expect(body.getByText(".mcp.json")).toBeVisible(); + expect(body.getByText("github")).toBeVisible(); + // MCP server tools are listed under their server. + expect(body.getByText("search_issues")).toBeVisible(); + expect(body.getByText("create_issue")).toBeVisible(); + // Invalid resources are surfaced as issues with their error, not + // silently dropped. + expect(body.getByText("Issues")).toBeVisible(); + expect( + body.getByText( + 'front-matter name "coder-review" does not match directory "moo"', + ), + ).toBeVisible(); + // A clean pin offers no refresh affordance. + expect(body.queryByRole("button", { name: "Refresh context" })).toBeNull(); + }, +}; + +// Drifted pin: the ring announces a change, and the popover surfaces refresh +// and a way into the changes dialog. +export const Dirty: Story = { + args: { + usage: { + usedTokens: 12_000, + contextLimitTokens: 200_000, + context: MockChatContextDirty, + }, + }, + play: async ({ canvasElement, args }) => { + const button = within(canvasElement).getByRole("button"); + expect(button.getAttribute("aria-label") ?? "").toContain( + "Context changed", + ); + + await userEvent.hover(button); + const body = within(document.body); + await waitFor(() => + expect(body.getByText("Context changed")).toBeVisible(), + ); + + // Refresh from the popover invokes the handler. + await userEvent.click( + body.getByRole("button", { name: "Refresh context" }), + ); + expect(args.onRefreshContext).toHaveBeenCalledTimes(1); + + // "View changes" opens the diff dialog. + await userEvent.click(body.getByRole("button", { name: "View changes" })); + await waitFor(() => + expect(body.getByText("Context changes")).toBeVisible(), + ); + // The modified skill is listed by name in the dialog. + expect(body.getByText("Deploy the app to production.")).toBeVisible(); + }, +}; + +// Regression: a dirty pin whose pinned resources have not loaded falls back to +// the agent's injected context, which can carry an empty context-file marker. +// The popover must skip it rather than render a nameless "Context files" row, +// while still surfacing the drift affordances. +export const DirtyEmptyInjectedContext: Story = { + args: { + usage: { + usedTokens: 12_000, + contextLimitTokens: 200_000, + lastInjectedContext: MockLastInjectedContextEmptyFile, + context: { + dirty: true, + changes: MockChatContextDirty.changes, + }, + }, + }, + play: async ({ canvasElement }) => { + const button = within(canvasElement).getByRole("button"); + await userEvent.hover(button); + const body = within(document.body); + // The drift affordances still render. + await waitFor(() => + expect(body.getByText("Context changed")).toBeVisible(), + ); + expect(body.getByRole("button", { name: "Refresh context" })).toBeVisible(); + // The empty injected marker must not produce a nameless file list. + expect(body.queryByText("Context files")).toBeNull(); + }, +}; + +// Snapshot-level error: the ring shows a distinct error treatment and the +// popover surfaces the error message. +export const SnapshotError: Story = { + args: { + usage: { + usedTokens: 12_000, + contextLimitTokens: 200_000, + context: { + dirty: false, + error: "failed to read AGENTS.md: permission denied", + resources: MockChatContextClean.resources, + }, + }, + }, + play: async ({ canvasElement }) => { + const button = within(canvasElement).getByRole("button"); + await userEvent.hover(button); + const body = within(document.body); + await waitFor(() => expect(body.getByText("Context error")).toBeVisible()); + expect( + body.getByText("failed to read AGENTS.md: permission denied"), + ).toBeVisible(); + }, +}; diff --git a/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx b/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx index 3f36656751d2b..3b4127ea7acc7 100644 --- a/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx +++ b/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx @@ -1,11 +1,25 @@ -import { FileIcon, ZapIcon } from "lucide-react"; +import { + FileIcon, + PlugIcon, + TriangleAlertIcon, + WrenchIcon, + ZapIcon, +} from "lucide-react"; import { type FC, useRef, useState } from "react"; -import type { ChatMessagePart } from "#/api/typesGenerated"; +import type { + ChatContext, + ChatContextMCPTool, + ChatContextResourceKind, + ChatContextResourceStatus, + ChatMessagePart, +} from "#/api/typesGenerated"; +import { Button } from "#/components/Button/Button"; import { Popover, PopoverContent, PopoverTrigger, } from "#/components/Popover/Popover"; +import { Spinner } from "#/components/Spinner/Spinner"; import { Tooltip, TooltipContent, @@ -15,6 +29,7 @@ import { import { cn } from "#/utils/cn"; import { isMobileViewport } from "#/utils/mobile"; import { getPathBasename } from "../utils/path"; +import { ContextChangesDialog } from "./ContextChangesDialog"; import { SvgRingProgress } from "./SvgRingProgress"; export interface AgentContextUsage { @@ -25,12 +40,47 @@ export interface AgentContextUsage { readonly cacheReadTokens?: number; readonly cacheCreationTokens?: number; readonly reasoningTokens?: number; - // Percentage (0–100) at which the context will be compacted. + // Percentage (0-100) at which the context will be compacted. readonly compressionThreshold?: number; - // Last injected context parts (AGENTS.md files and skills). + // Last injected context parts (AGENTS.md files and skills). Used as a + // fallback to list the context when the chat's pinned resources have not + // loaded yet. readonly lastInjectedContext?: readonly ChatMessagePart[]; + // Pinned workspace-context state: the resources the chat is built from and + // whether they have drifted from the agent's latest snapshot. + readonly context?: ChatContext; } +// Normalized popover entries, sourced from either the chat's pinned context +// resources or, as a fallback, the last injected context parts. +type ContextFileItem = { readonly path: string; readonly truncated?: boolean }; +type ContextSkillItem = { + readonly name: string; + readonly description?: string; +}; +type ContextMcpItem = { + readonly name: string; + readonly source: string; + readonly tools: readonly ChatContextMCPTool[]; +}; +// A pinned resource the agent could not use, surfaced with its error so the +// failure is visible instead of silent. +type ContextIssueItem = { + readonly name: string; + readonly kind: ChatContextResourceKind; + readonly status: ChatContextResourceStatus; + readonly error: string; + readonly source: string; +}; + +// Human-readable label per resource kind, used in the issues list. +const RESOURCE_KIND_LABELS: Record = { + instruction_file: "file", + skill: "skill", + mcp_config: "MCP config", + mcp_server: "MCP server", +}; + const hasFiniteTokenValue = (value: number | undefined): value is number => typeof value === "number" && Number.isFinite(value) && value >= 0; @@ -72,10 +122,13 @@ const RING_STROKE = 2.5; // the user time to move into the popover content. const HOVER_CLOSE_DELAY_MS = 150; -export const ContextUsageIndicator: FC<{ usage: AgentContextUsage | null }> = ({ - usage, -}) => { +export const ContextUsageIndicator: FC<{ + usage: AgentContextUsage | null; + onRefreshContext?: () => void; + isRefreshingContext?: boolean; +}> = ({ usage, onRefreshContext, isRefreshingContext }) => { const [open, setOpen] = useState(false); + const [changesOpen, setChangesOpen] = useState(false); const closeTimerRef = useRef | null>(null); const cancelClose = () => { @@ -117,21 +170,121 @@ export const ContextUsageIndicator: FC<{ usage: AgentContextUsage | null }> = ({ ? Math.min(Math.max(percentUsed, 0), 100) : 100; const toneClassName = getIndicatorToneClassName(percentUsed); + + const context = usage?.context; + const isDirty = context?.dirty ?? false; + const contextError = context?.error ?? ""; + const hasContextError = contextError !== ""; + const changes = context?.changes ?? []; + const pinnedResources = context?.resources; + + // Drive the listed context from the chat's pinned resources, falling back + // to the last injected context parts while the pin has not loaded. + const usePinned = (pinnedResources?.length ?? 0) > 0; + const fileItems: readonly ContextFileItem[] = ( + usePinned + ? (pinnedResources ?? []) + .filter( + (resource) => + resource.kind === "instruction_file" && resource.status === "ok", + ) + .map((resource) => ({ path: resource.source })) + : (usage?.lastInjectedContext ?? []) + .filter((part) => part.type === "context-file") + .map((part) => ({ + path: part.context_file_path, + truncated: part.context_file_truncated, + })) + ) + // Drop entries with no usable path. The injected-context fallback can + // carry an empty context-file marker, which would otherwise render as a + // nameless "Context files" row. + .filter((file) => file.path.trim().length > 0); + const skillItems: readonly ContextSkillItem[] = ( + usePinned + ? (pinnedResources ?? []) + .filter( + (resource) => resource.kind === "skill" && resource.status === "ok", + ) + .map((resource) => ({ + name: resource.skill_name || getPathBasename(resource.source), + description: resource.skill_description, + })) + : (usage?.lastInjectedContext ?? []) + .filter((part) => part.type === "skill") + .map((part) => ({ + name: part.skill_name, + description: part.skill_description, + })) + ) + // Drop entries with no usable name so an empty skill marker never renders + // as a blank row. + .filter((skill) => skill.name.trim().length > 0); + // MCP configs/servers are only ever surfaced from the chat's pinned + // resources; there is no injected-context fallback for them. An MCP server's + // source is its server name, while an MCP config's source is its file path. + const mcpItems: readonly ContextMcpItem[] = ( + usePinned + ? (pinnedResources ?? []) + .filter( + (resource) => + (resource.kind === "mcp_config" || + resource.kind === "mcp_server") && + resource.status === "ok", + ) + .map((resource) => ({ + name: + resource.kind === "mcp_server" + ? resource.source + : getPathBasename(resource.source), + source: resource.source, + tools: resource.mcp_tools ?? [], + })) + : [] + ) + // Drop entries with no usable name so an empty MCP marker never renders as + // a blank row. + .filter((mcp) => mcp.name.trim().length > 0); + // Pinned resources the agent could not use (invalid skill, unreadable or + // oversize file) are surfaced as issues with their error so the failure is + // visible rather than a silent omission. Pinned-only; the injected-context + // fallback has no status. + const issueItems: readonly ContextIssueItem[] = ( + usePinned ? (pinnedResources ?? []) : [] + ) + .filter((resource) => resource.status !== "ok") + .map((resource) => ({ + name: + resource.skill_name || + getPathBasename(resource.source) || + resource.source, + kind: resource.kind, + status: resource.status, + error: resource.error ?? "", + source: resource.source, + })) + .filter((issue) => issue.name.trim().length > 0); + const hasContextList = + fileItems.length > 0 || + skillItems.length > 0 || + mcpItems.length > 0 || + issueItems.length > 0; + const ariaLabel = hasPercent - ? `Context usage ${percentLabel}. ${formatTokenCount(usedTokens)} of ${formatTokenCount(contextLimitTokens)} tokens used.` - : "Context usage"; + ? `Context usage ${percentLabel}. ${formatTokenCount(usedTokens)} of ${formatTokenCount(contextLimitTokens)} tokens used.${isDirty ? " Context changed." : ""}` + : isDirty + ? "Context usage. Context changed." + : "Context usage"; - // Extract context files and skills from lastInjectedContext. - const contextFiles = - usage?.lastInjectedContext?.filter((p) => p.type === "context-file") ?? []; - const skills = - usage?.lastInjectedContext?.filter((p) => p.type === "skill") ?? []; - const hasInjectedContext = contextFiles.length > 0 || skills.length > 0; + const openChanges = () => { + setChangesOpen(true); + setOpen(false); + }; const panelContent = (
{hasPercent - ? `${percentLabel} – ${formatTokenCountCompact(usedTokens)} / ${formatTokenCountCompact(contextLimitTokens)} context used` + ? `${percentLabel} - ${formatTokenCountCompact(usedTokens)} / ${formatTokenCountCompact(contextLimitTokens)} context used` : "Context usage unavailable"} {hasPercent && usage?.compressionThreshold !== undefined && @@ -140,56 +293,49 @@ export const ContextUsageIndicator: FC<{ usage: AgentContextUsage | null }> = ({ {`Compacts at ${usage.compressionThreshold}%`}
)} - {hasInjectedContext && ( + {hasContextList && (
- {contextFiles.length > 0 && ( + {fileItems.length > 0 && (
Context files - {contextFiles.map((part) => { - if (part.type !== "context-file") return null; - return ( -
- - - {getPathBasename(part.context_file_path)} + {fileItems.map((file) => ( +
+ + + {getPathBasename(file.path)} + + {file.truncated && ( + + (truncated) - {part.context_file_truncated && ( - - (truncated) - - )} -
- ); - })} + )} +
+ ))}
)} - {skills.length > 0 && ( + {skillItems.length > 0 && (
Skills - {skills.map((part) => { - if (part.type !== "skill") return null; + {skillItems.map((skill) => { const row = (
- {part.skill_name} + {skill.name}
); - if (!part.skill_description) { - return
{row}
; + if (!skill.description) { + return
{row}
; } return ( - +
{row}
@@ -198,7 +344,7 @@ export const ContextUsageIndicator: FC<{ usage: AgentContextUsage | null }> = ({ sideOffset={4} className="max-w-48 text-xs" > - {part.skill_description} + {skill.description}
); @@ -206,6 +352,119 @@ export const ContextUsageIndicator: FC<{ usage: AgentContextUsage | null }> = ({
)} + {mcpItems.length > 0 && ( +
+ MCP + + {mcpItems.map((mcp) => ( +
+
+ + {mcp.name} +
+ {mcp.tools.length > 0 && ( +
+ {mcp.tools.map((tool) => { + const row = ( +
+ + {tool.name} +
+ ); + if (!tool.description) { + return
{row}
; + } + return ( + + +
{row}
+
+ + {tool.description} + +
+ ); + })} +
+ )} +
+ ))} +
+
+ )} + {issueItems.length > 0 && ( +
+ + + Issues + + {issueItems.map((issue) => ( +
+ + {issue.name}{" "} + + ({RESOURCE_KIND_LABELS[issue.kind]}: {issue.status}) + + + {issue.error && ( + + {issue.error} + + )} +
+ ))} +
+ )} +
+ )} + {(isDirty || hasContextError) && ( +
+ {hasContextError ? ( + + + Context error + + ) : ( + + + Context changed + + )} + {hasContextError ? ( + {contextError} + ) : ( + + The workspace context changed since this chat was pinned. + + )} +
+ {changes.length > 0 && ( + + )} + {onRefreshContext && ( + + )} +
)} @@ -225,44 +484,71 @@ export const ContextUsageIndicator: FC<{ usage: AgentContextUsage | null }> = ({ progressClassName="stroke-current" className={cn("size-icon-sm", toneClassName)} /> + {(isDirty || hasContextError) && ( + + )} ); + const changesDialog = ( + + ); + // On mobile, a tap toggles the popover. On desktop, hover opens // it like a dropdown menu and skill descriptions appear as // nested tooltips to the right (same pattern as ModelSelector). if (isMobileViewport()) { return ( - - {triggerButton} + <> + + {triggerButton} + + {panelContent} + + + {changesDialog} + + ); + } + + return ( + <> + + +
+ {triggerButton} +
+
e.preventDefault()} > {panelContent}
- ); - } - - return ( - - -
- {triggerButton} -
-
- e.preventDefault()} - > - {panelContent} - -
+ {changesDialog} + ); }; diff --git a/site/src/testHelpers/chatEntities.ts b/site/src/testHelpers/chatEntities.ts index cba8d717591cc..2827eb3641fe2 100644 --- a/site/src/testHelpers/chatEntities.ts +++ b/site/src/testHelpers/chatEntities.ts @@ -1,6 +1,10 @@ import type { Chat, + ChatContext, + ChatContextResource, + ChatContextResourceChange, ChatMessage, + ChatMessagePart, ChatQueuedMessage, MCPServerConfig, } from "#/api/typesGenerated"; @@ -30,6 +34,95 @@ export const MockChat: Chat = { children: [], }; +// Pinned workspace-context resources the prompt is built from. +const MockChatContextResources: ChatContextResource[] = [ + { + source: "/home/coder/AGENTS.md", + kind: "instruction_file", + size_bytes: 248, + status: "ok", + }, + { + source: "/home/coder/.coder/skills/deploy", + kind: "skill", + size_bytes: 96, + status: "ok", + skill_name: "deploy", + skill_description: "Deploy the app to staging.", + }, + { + source: "/home/coder/.mcp.json", + kind: "mcp_config", + size_bytes: 184, + status: "ok", + }, + { + source: "github", + kind: "mcp_server", + size_bytes: 512, + status: "ok", + mcp_tools: [ + { + name: "search_issues", + description: "Search issues and pull requests.", + }, + { name: "create_issue", description: "Open a new issue." }, + ], + }, + { + // An invalid skill the agent rejected: surfaced as an issue with its + // error rather than silently dropped. + source: "/home/coder/test/.agents/skills/moo", + kind: "skill", + size_bytes: 356, + status: "invalid", + error: 'front-matter name "coder-review" does not match directory "moo"', + }, +]; + +// Per-source differences between the pinned context and the latest snapshot. +const MockChatContextChanges: ChatContextResourceChange[] = [ + { + source: "/home/coder/AGENTS.md", + kind: "instruction_file", + status: "modified", + old_content: "# AGENTS\n\nBe concise.\n", + new_content: "# AGENTS\n\nBe concise and cite sources.\n", + }, + { + source: "/home/coder/docs/CONTEXT.md", + kind: "instruction_file", + status: "added", + new_content: "# Context\n\nProject overview.\n", + }, + { + source: "/home/coder/.coder/skills/deploy", + kind: "skill", + status: "modified", + skill_name: "deploy", + skill_description: "Deploy the app to production.", + }, +]; + +export const MockChatContextClean: ChatContext = { + dirty: false, + resources: MockChatContextResources, +}; + +export const MockChatContextDirty: ChatContext = { + dirty: true, + dirty_since: "2024-01-02T00:00:00Z", + resources: MockChatContextResources, + changes: MockChatContextChanges, +}; + +// Injected-context fallback whose only context-file marker has no path. The +// agent emits this empty placeholder for skill-only additions; the context +// indicator must skip it rather than render a nameless "Context files" row. +export const MockLastInjectedContextEmptyFile: readonly ChatMessagePart[] = [ + { type: "context-file", context_file_path: "" }, +]; + export const MockMCPServerConfig: MCPServerConfig = { id: "mcp-1", display_name: "MCP Server",