feat: add chat context source CLI and agent-token refresh (#26577)

Adds the `coder exp chat context` CLI for managing workspace context
sources, plus the agent-token refresh endpoint the in-workspace refresh
relies on. Part of breaking the "Workspace Context Sources for Coder
Agents" RFC (#26466) into small, reviewable PRs.

## What this adds

**CLI (`coder exp chat context`)**, talking to the agent's local IPC
socket from inside the workspace:

- `list` lists the registered scan roots (built-in defaults are not
shown).
- `show <path>` shows a source and the resources the agent resolves from
it, including failures.
- `add <path>` registers a path as an additional context source. With
`--chat`, it keeps the legacy one-shot behavior (read context from the
path once and inject it into a single chat).
- `remove <path>` unregisters a source.
- `refresh [<chat>]` re-pins chat context to the agent's latest
snapshot.

**Agent-token refresh path** for the no-argument `refresh`:

- `refresh <chat>` uses the existing user-facing
`ExperimentalClient.RefreshChatContext` (already on main) and works from
anywhere.
- `refresh` with no argument runs inside the workspace: it re-resolves
the agent's sources over the context socket (catching freshly-cloned
repos and startup-script writes), then asks the agent, authenticating
with its own token, to re-pin every drifted chat. No `coder login`
required.
- This adds `agentsdk.RefreshChatContext` and `POST
/api/v2/workspaceagents/me/experimental/chat-context/refresh`
(`workspaceAgentRefreshChatContext`), mirroring the existing clear
endpoint's agent-token auth model.

## Testing

- `go test ./cli` (`TestExpChatContextAdd`, `TestParseChatID`,
`TestResolveContextSourcePath`)
- `go test ./coderd/x/chatd -run TestChatContextRefreshFromAgentToken`
(end-to-end: echo-provisioned agent pushes a snapshot, drifts a bound
chat, the agent-token refresh re-pins it, and an agent-less chat stays
untouched)
- `go build ./...`, `go vet`, `golangci-lint`, `make gen` (no generated
changes; experimental commands are excluded from CLI golden/doc
generation)

<details>
<summary>Design notes</summary>

This is **Split 4** of #26466. Split sequence:

1. #26558 - prompt pin consumption (merged)
2. #26570 - `codersdk` context resource types (merged)
3. #26573 - the context indicator UI (merged)
4. **This PR** - the CLI + agent-token refresh.
5. The context diff (`changes`, `ChatContextResourceChange`, the changes
dialog, `buildContentPatch`) - last.

Key points:

- The agent-local context subsystem (`agent/agentsocket` IPC for source
CRUD, snapshot, resync), the user-facing
`ExperimentalClient.RefreshChatContext`, and the per-chat
`chatd.RefreshChatContext` all already exist on main, so this split is
the CLI surface plus the small agent-token refresh endpoint that fans
out per-chat refresh across an agent's drifted chats.
- `add <path>` resolves relative paths to absolute before handing them
to the agent (which requires canonical paths) but preserves a leading
`~` for the agent to expand against its own home.
`TestResolveContextSourcePath` covers this.
- The agent endpoint is annotated `@x-apidocgen {"skip": true}`,
matching the other agent-token chat-context endpoints.
- No diff/changes rendering is involved; that lands in the final split.

</details>

*This PR was created by Coder Agents on behalf of @kylecarbs.*
This commit is contained in:
Kyle Carberry
2026-06-22 12:15:27 -06:00
committed by GitHub
parent ee3572ab9a
commit 966dd89537
7 changed files with 652 additions and 86 deletions
+343 -67
View File
@@ -1,14 +1,20 @@
package cli
import (
"context"
"fmt"
"os"
"path"
"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,102 +34,372 @@ 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),
}},
}
}
func (*RootCmd) chatContextAddCommand() *serpent.Command {
var (
dir string
chatID string
// 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. A path that
// is already absolute on the agent's POSIX filesystem (a leading /) is cleaned
// and passed through; filepath.Abs is host-OS specific and would mangle such a
// path on a Windows CLI host, so it is reserved for resolving relative paths
// 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
}
if strings.HasPrefix(p, "/") {
return path.Clean(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) 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 <path>",
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 <path>",
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/<name>/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 <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()
if err != nil {
return xerrors.Errorf("create agent client: %w", err)
}
resolvedDir := dir
if resolvedDir == "" {
resolvedDir, err = os.Getwd()
if err != nil {
return xerrors.Errorf("get working directory: %w", err)
}
}
resolvedDir, err = filepath.Abs(resolvedDir)
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("%q is not a directory", resolvedDir)
}
parts := agentcontextconfig.ContextPartsFromDir(resolvedDir)
if len(parts) == 0 {
_, _ = fmt.Fprintln(inv.Stderr, "No context files or skills found in "+resolvedDir)
return nil
}
// Resolve chat ID from flag or auto-detect.
resolvedChatID, err := parseChatID(chatID)
// Source registration (default).
path, err := resolveContextSourcePath(inv.Args[0])
if err != nil {
return err
}
resp, err := client.AddChatContext(ctx, agentsdk.AddChatContextRequest{
ChatID: resolvedChatID,
Parts: parts,
})
client, err := dialAgentContextSocket(ctx, *socketPath)
if err != nil {
return xerrors.Errorf("add chat context: %w", err)
return err
}
defer client.Close()
_, _ = fmt.Fprintf(inv.Stdout, "Added %d context part(s) to chat %s\n", resp.Count, resp.ChatID)
src, err := client.AddContextSource(ctx, path)
if err != nil {
return xerrors.Errorf("add context source: %w", err)
}
_, _ = fmt.Fprintf(inv.Stdout, "Registered context source %s\n", src.Path)
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),
},
Options: serpent.OptionSet{{
Name: "Chat ID",
Flag: "chat",
Env: "CODER_CHAT_ID",
Description: "Inject context from <path> 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, dir, chatID string) error {
client, err := agentAuth.CreateClient()
if err != nil {
return xerrors.Errorf("create agent client: %w", err)
}
resolvedDir, err := filepath.Abs(dir)
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 <path>",
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 err
}
defer client.Close()
path, err := resolveContextSourcePath(inv.Args[0])
if err != nil {
return err
}
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 [<chat>]",
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 " +
"<chat> 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()
// With a <chat> 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
}
// 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 xerrors.Errorf("connect to agent context socket "+
"(run inside the workspace, or pass a <chat> ID): %w", err)
}
defer sock.Close()
snap, err := sock.ResyncContext(ctx)
if err != nil {
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)
}
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
},
}
agentAuth.AttachOptions(cmd, false)
+77
View File
@@ -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)
})
}
+15 -19
View File
@@ -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 <path>, 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")
})
}