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
+1
View File
@@ -1796,6 +1796,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)
+63
View File
@@ -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")
+127
View File
@@ -269,3 +269,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")
}