refactor: remove legacy live-read and injected-history chat context paths (#26585)

This PR makes the agent-pushed pinned snapshot
(`chat_context_resources`) the sole source of workspace context for
chats, completing the "Release 5" cleanup. It removes legacy mechanisms
now superseded by the snapshot that agents push over dRPC
(`PushContextState`) and refresh via `chat-context/refresh`.

Removed:

- **Live-read at turn time.** MCP tool discovery, skill live-body reads,
and the instruction/skill history fallback that dialed the workspace on
every turn.
- **Context injected as message history.** The
`persist_workspace_context` generation action and its decision-loop
guard.
- **The legacy write path.** `POST`/`DELETE
/api/v2/workspaceagents/me/experimental/chat-context`, the agentsdk
`AddChatContext`/`ClearChatContext` methods, and the CLI one-shot
writer.
- **The `chats.last_injected_context` column** and all of its plumbing
(migration `000529`, queries, `db2sdk`, `dbauthz`, audit table, and the
frontend `ContextUsageIndicator` fallback).

Subagent context inheritance no longer copies parent context messages;
children now hydrate the parent's pinned `chat_context_resources` on
create, which yields an identical pin for the same workspace and agent.

What stays (still served by the live agent connection, not the
snapshot): `read_skill_file` supporting-file reads, `read_skill`
supporting-file listing, and MCP tool execution.

> [!NOTE]
> Migration `000529` drops `chats.last_injected_context` and recreates
the `chats_expanded` view without it. The down migration restores both.

<details>
<summary>Decision log (D1-D5)</summary>

- **D1 (subagent inheritance):** Re-point inheritance from the legacy
message copy to a pinned hydrate. Children call
`hydrateChatContextOnCreate` instead of copying parent context messages.
- **D2 (`persist_workspace_context`):** Remove the generation action
entirely along with the decision-loop guard it existed to satisfy, since
context is never injected into history anymore.
- **D3 (legacy HTTP + CLI):** Remove the experimental `chat-context`
POST/DELETE endpoints, the agentsdk methods, and the CLI one-shot. The
dRPC push + `chat-context/refresh` replace them.
- **D4 (frontend fallback):** Remove the `last_injected_context`
fallback in `ContextUsageIndicator`; pinned `resources` are the sole
source.
- **D5 (sequencing):** Ship as a single PR rather than a stacked pair.

</details>

---
Coder Agents generated on behalf of @kylecarbs.
This commit is contained in:
Kyle Carberry
2026-06-22 19:26:34 -06:00
committed by GitHub
parent ce190b3e62
commit cd56ab9e33
59 changed files with 916 additions and 8617 deletions
+1 -130
View File
@@ -3,7 +3,6 @@ package cli
import (
"context"
"fmt"
"os"
"path"
"path/filepath"
"strings"
@@ -11,11 +10,9 @@ import (
"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"
)
@@ -54,7 +51,6 @@ func (r *RootCmd) chatContextCommand() *serpent.Command {
r.chatContextAddCommand(&socketPath),
r.chatContextRemoveCommand(&socketPath),
r.chatContextRefreshCommand(&socketPath),
r.chatContextClearCommand(),
},
Options: serpent.OptionSet{{
Flag: "socket-path",
@@ -203,8 +199,6 @@ func (*RootCmd) chatContextShowCommand(socketPath *string) *serpent.Command {
}
func (*RootCmd) chatContextAddCommand(socketPath *string) *serpent.Command {
var chatID string
agentAuth := &AgentAuth{}
cmd := &serpent.Command{
Use: "add <path>",
Short: "Register a workspace context source",
@@ -214,21 +208,13 @@ func (*RootCmd) chatContextAddCommand(socketPath *string) *serpent.Command {
".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.",
"the workspace.",
Middleware: serpent.RequireNArgs(1),
Handler: func(inv *serpent.Invocation) error {
ctx := inv.Context()
ctx, stop := inv.SignalNotifyContext(ctx, StopSignals...)
defer stop()
// Legacy one-shot inject into a single chat.
if chatID != "" {
return addChatContextOneShot(ctx, inv, agentAuth, inv.Args[0], chatID)
}
// Source registration (default).
path, err := resolveContextSourcePath(inv.Args[0])
if err != nil {
return err
@@ -246,62 +232,10 @@ func (*RootCmd) chatContextAddCommand(socketPath *string) *serpent.Command {
_, _ = 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 <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>",
@@ -405,66 +339,3 @@ func (r *RootCmd) chatContextRefreshCommand(socketPath *string) *serpent.Command
agentAuth.AttachOptions(cmd, false)
return cmd
}
func (*RootCmd) chatContextClearCommand() *serpent.Command {
var chatID string
agentAuth := &AgentAuth{}
cmd := &serpent.Command{
Use: "clear",
Short: "Clear context from an active chat",
Long: "Soft-delete all context-file and skill messages from an active chat. " +
"The next turn will re-fetch default context from the agent.",
Handler: func(inv *serpent.Invocation) error {
ctx := inv.Context()
ctx, stop := inv.SignalNotifyContext(ctx, StopSignals...)
defer stop()
client, err := agentAuth.CreateClient()
if err != nil {
return xerrors.Errorf("create agent client: %w", err)
}
resolvedChatID, err := parseChatID(chatID)
if err != nil {
return err
}
resp, err := client.ClearChatContext(ctx, agentsdk.ClearChatContextRequest{
ChatID: resolvedChatID,
})
if err != nil {
return xerrors.Errorf("clear chat context: %w", err)
}
if resp.ChatID == uuid.Nil {
_, _ = fmt.Fprintln(inv.Stdout, "No active chats to clear.")
} else {
_, _ = fmt.Fprintf(inv.Stdout, "Cleared context from chat %s\n", resp.ChatID)
}
return nil
},
Options: serpent.OptionSet{{
Name: "Chat ID",
Flag: "chat",
Env: "CODER_CHAT_ID",
Description: "Chat ID to clear context from. 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
}
// parseChatID returns the chat UUID from the flag value (which
// serpent already populates from --chat or CODER_CHAT_ID). Returns
// uuid.Nil if empty (the server will auto-detect).
func parseChatID(flagValue string) (uuid.UUID, error) {
if flagValue == "" {
return uuid.Nil, nil
}
parsed, err := uuid.Parse(flagValue)
if err != nil {
return uuid.Nil, xerrors.Errorf("invalid chat ID %q: %w", flagValue, err)
}
return parsed, nil
}
-27
View File
@@ -4,36 +4,9 @@ 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()