mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
+343
-67
@@ -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)
|
||||
|
||||
@@ -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
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user