mirror of
https://github.com/coder/coder.git
synced 2026-09-22 21:22:17 +08:00
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.
154 lines
5.9 KiB
Go
154 lines
5.9 KiB
Go
package chatd
|
|
|
|
import (
|
|
"database/sql"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"go.uber.org/mock/gomock"
|
|
|
|
"cdr.dev/slog/v3/sloggers/slogtest"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbmock"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
// TestHydrateChatContextOnCreate covers the create-time pinning path, which the
|
|
// end-to-end test cannot reach: chats there are inserted directly, bypassing
|
|
// CreateChat. It pins to the agent's latest snapshot via the NULL-guarded
|
|
// HydrateAgentChatsContext so a concurrent push is never clobbered, and is a
|
|
// best-effort no-op when there is no agent or no snapshot.
|
|
func TestHydrateChatContextOnCreate(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("PinsWhenSnapshotExists", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
ctrl := gomock.NewController(t)
|
|
db := dbmock.NewMockStore(ctrl)
|
|
server := &Server{db: db, logger: slogtest.Make(t, nil)}
|
|
|
|
agentID := uuid.New()
|
|
chat := database.Chat{ID: uuid.New(), AgentID: uuid.NullUUID{UUID: agentID, Valid: true}}
|
|
snapshot := database.WorkspaceAgentContextSnapshot{
|
|
WorkspaceAgentID: agentID,
|
|
AggregateHash: []byte{0x0a, 0x0b},
|
|
SnapshotError: "one source failed",
|
|
}
|
|
|
|
db.EXPECT().InTx(gomock.Any(), gomock.Any()).DoAndReturn(
|
|
func(f func(database.Store) error, _ *database.TxOptions) error { return f(db) })
|
|
db.EXPECT().GetLatestWorkspaceAgentContextSnapshot(gomock.Any(), agentID).
|
|
Return(snapshot, nil)
|
|
// The guarded agent-scoped stamp, not an unconditional SetChatContextSnapshot,
|
|
// so a concurrent push that already hydrated the chat wins.
|
|
db.EXPECT().HydrateAgentChatsContext(gomock.Any(), database.HydrateAgentChatsContextParams{
|
|
AgentID: agentID,
|
|
AggregateHash: snapshot.AggregateHash,
|
|
ContextError: snapshot.SnapshotError,
|
|
}).Return(nil)
|
|
|
|
server.hydrateChatContextOnCreate(ctx, chat)
|
|
})
|
|
|
|
t.Run("SkipsWhenAgentless", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
ctrl := gomock.NewController(t)
|
|
// No EXPECT calls: a chat with no agent must touch the database zero times.
|
|
db := dbmock.NewMockStore(ctrl)
|
|
server := &Server{db: db, logger: slogtest.Make(t, nil)}
|
|
|
|
server.hydrateChatContextOnCreate(ctx, database.Chat{ID: uuid.New()})
|
|
})
|
|
|
|
t.Run("SkipsWhenNoSnapshot", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
ctrl := gomock.NewController(t)
|
|
db := dbmock.NewMockStore(ctrl)
|
|
server := &Server{db: db, logger: slogtest.Make(t, nil)}
|
|
|
|
agentID := uuid.New()
|
|
// ErrNoRows means the agent has not pushed yet; no stamp is written
|
|
// (HydrateAgentChatsContext has no EXPECT, so a call would fail the test).
|
|
db.EXPECT().InTx(gomock.Any(), gomock.Any()).DoAndReturn(
|
|
func(f func(database.Store) error, _ *database.TxOptions) error { return f(db) })
|
|
db.EXPECT().GetLatestWorkspaceAgentContextSnapshot(gomock.Any(), agentID).
|
|
Return(database.WorkspaceAgentContextSnapshot{}, sql.ErrNoRows)
|
|
|
|
server.hydrateChatContextOnCreate(ctx, database.Chat{
|
|
ID: uuid.New(),
|
|
AgentID: uuid.NullUUID{UUID: agentID, Valid: true},
|
|
})
|
|
})
|
|
}
|
|
|
|
// TestEnsureChatContextPinnedOnFirstTurn covers the lazy-bind pinning path. An
|
|
// API-created chat carries no agent at create, binds its agent on the first
|
|
// turn, and must pin the agent's already-pushed snapshot then. This is the
|
|
// mechanism that lets a workspace created mid-turn have its context pinned on
|
|
// the next turn: the agent pushes its snapshot before the chat is bound to it,
|
|
// so HydrateAgentChatsContext on that push cannot reach the chat, and the
|
|
// rebind-only binding does not pin a first-time agent.
|
|
func TestEnsureChatContextPinnedOnFirstTurn(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("PinsWhenUnpinnedAndSnapshotExists", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
ctrl := gomock.NewController(t)
|
|
db := dbmock.NewMockStore(ctrl)
|
|
server := &Server{db: db, logger: slogtest.Make(t, nil)}
|
|
|
|
agentID := uuid.New()
|
|
chat := database.Chat{ID: uuid.New(), AgentID: uuid.NullUUID{UUID: agentID, Valid: true}}
|
|
snapshot := database.WorkspaceAgentContextSnapshot{
|
|
WorkspaceAgentID: agentID,
|
|
AggregateHash: []byte{0x0a, 0x0b},
|
|
}
|
|
|
|
db.EXPECT().InTx(gomock.Any(), gomock.Any()).DoAndReturn(
|
|
func(f func(database.Store) error, _ *database.TxOptions) error { return f(db) })
|
|
db.EXPECT().GetLatestWorkspaceAgentContextSnapshot(gomock.Any(), agentID).
|
|
Return(snapshot, nil)
|
|
// The guarded agent-scoped stamp, not an unconditional SetChatContextSnapshot,
|
|
// so a concurrent push that already hydrated the chat wins.
|
|
db.EXPECT().HydrateAgentChatsContext(gomock.Any(), database.HydrateAgentChatsContextParams{
|
|
AgentID: agentID,
|
|
AggregateHash: snapshot.AggregateHash,
|
|
ContextError: snapshot.SnapshotError,
|
|
}).Return(nil)
|
|
|
|
server.ensureChatContextPinnedOnFirstTurn(ctx, chat)
|
|
})
|
|
|
|
t.Run("SkipsWhenAlreadyPinned", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
ctrl := gomock.NewController(t)
|
|
// A non-NULL pinned hash means the chat is already pinned (or dirty
|
|
// awaiting refresh); the hook must touch the database zero times so it
|
|
// never clobbers existing bodies or a dirty chat's stale hash.
|
|
db := dbmock.NewMockStore(ctrl)
|
|
server := &Server{db: db, logger: slogtest.Make(t, nil)}
|
|
|
|
server.ensureChatContextPinnedOnFirstTurn(ctx, database.Chat{
|
|
ID: uuid.New(),
|
|
AgentID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
|
ContextAggregateHash: []byte{0x01},
|
|
})
|
|
})
|
|
|
|
t.Run("SkipsWhenAgentless", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
ctrl := gomock.NewController(t)
|
|
// No agent bound yet: the hook must touch the database zero times.
|
|
db := dbmock.NewMockStore(ctrl)
|
|
server := &Server{db: db, logger: slogtest.Make(t, nil)}
|
|
|
|
server.ensureChatContextPinnedOnFirstTurn(ctx, database.Chat{ID: uuid.New()})
|
|
})
|
|
}
|