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:
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user