mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
> This Pull Request was updated by Mux working on behalf of Mike. Adds workspace skills to the agent chat slash menu, sourced entirely from the chat's pinned context resources (the single-chat GET response the page already fetches), the same inventory `read_skill` resolves from. No new API endpoint is introduced. Personal entries insert `/name`, or `/personal/name` when the name collides with a workspace skill or the chat's pinned context has not resolved yet; workspace entries insert `/workspace/name`. Qualified aliases stay searchable even when the displayed trigger is bare. Before a chat binds a workspace (new chat form, or a selected but unbound workspace), the menu lists personal skills only. Sending a message invalidates the chat detail query, and chatd broadcasts a context watch event when a first-turn bind pins the chat, so the menu picks up newly pinned context without a reload. Makes `UpdateChatWorkspaceBinding` a no-op when the requested workspace/build/agent binding is unchanged, preserving `updated_at` so chat list ordering and watch events stay stable. Includes regression coverage for the no-op binding guard, pinned-context skill mapping, collision qualification, and skills menu behavior. Refs [CODAGT-474](https://linear.app/codercom/issue/CODAGT-474/ux-improvements-for-coder-agents) (skills autocompleting in the editor).
270 lines
11 KiB
Go
270 lines
11 KiB
Go
package chatd
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/require"
|
|
"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"
|
|
dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub"
|
|
coderdpubsub "github.com/coder/coder/v2/coderd/pubsub"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"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([]uuid.UUID{chat.ID}, 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},
|
|
})
|
|
})
|
|
}
|
|
|
|
// TestHydrateAndMarkChatsDirtyPublishesForHydratedAndDirtied covers the
|
|
// agent-push path: a chat hydrated by the push (first pin, no dirty marker)
|
|
// and a chat flipped to dirty must both get a context watch event, because
|
|
// watching clients refetch pinned resources only on those events.
|
|
func TestHydrateAndMarkChatsDirtyPublishesForHydratedAndDirtied(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
ctrl := gomock.NewController(t)
|
|
db := dbmock.NewMockStore(ctrl)
|
|
ps := dbpubsub.NewInMemory()
|
|
server := &Server{db: db, logger: slogtest.Make(t, nil), pubsub: ps}
|
|
|
|
ownerID := uuid.New()
|
|
agentID := uuid.New()
|
|
hash := []byte{0x01}
|
|
now := time.Now()
|
|
|
|
hydratedChat := database.Chat{ID: uuid.New(), OwnerID: ownerID, ContextAggregateHash: hash}
|
|
dirtiedChat := database.Chat{ID: uuid.New(), OwnerID: ownerID, ContextAggregateHash: []byte{0x99}}
|
|
|
|
events := make(chan codersdk.ChatWatchEvent, 2)
|
|
cancelSub, err := ps.SubscribeWithErr(
|
|
coderdpubsub.ChatWatchEventChannel(ownerID),
|
|
coderdpubsub.HandleChatWatchEvent(func(_ context.Context, payload codersdk.ChatWatchEvent, err error) {
|
|
require.NoError(t, err)
|
|
events <- payload
|
|
}),
|
|
)
|
|
require.NoError(t, err)
|
|
defer cancelSub()
|
|
|
|
db.EXPECT().HydrateAgentChatsContext(gomock.Any(), database.HydrateAgentChatsContextParams{
|
|
AgentID: agentID,
|
|
AggregateHash: hash,
|
|
}).Return([]uuid.UUID{hydratedChat.ID}, nil)
|
|
db.EXPECT().MarkChatsContextDirtyByAgent(gomock.Any(), database.MarkChatsContextDirtyByAgentParams{
|
|
AgentID: agentID,
|
|
AggregateHash: hash,
|
|
DirtySince: sql.NullTime{Time: now, Valid: true},
|
|
}).Return([]database.MarkChatsContextDirtyByAgentRow{{ID: dirtiedChat.ID, OwnerID: ownerID}}, nil)
|
|
db.EXPECT().GetChatByID(gomock.Any(), hydratedChat.ID).Return(hydratedChat, nil)
|
|
db.EXPECT().GetChatByID(gomock.Any(), dirtiedChat.ID).Return(dirtiedChat, nil)
|
|
|
|
publish, err := server.HydrateAndMarkChatsDirty(ctx, db, agentID, hash, "", now)
|
|
require.NoError(t, err)
|
|
publish()
|
|
|
|
gotChatIDs := make([]uuid.UUID, 0, 2)
|
|
for range 2 {
|
|
event := testutil.RequireReceive(ctx, t, events)
|
|
require.Equal(t, codersdk.ChatWatchEventKindContextDirty, event.Kind)
|
|
gotChatIDs = append(gotChatIDs, event.Chat.ID)
|
|
}
|
|
require.ElementsMatch(t, []uuid.UUID{hydratedChat.ID, dirtiedChat.ID}, gotChatIDs)
|
|
}
|
|
|
|
// 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)
|
|
ps := dbpubsub.NewInMemory()
|
|
server := &Server{db: db, logger: slogtest.Make(t, nil), pubsub: ps}
|
|
|
|
ownerID := uuid.New()
|
|
agentID := uuid.New()
|
|
chat := database.Chat{ID: uuid.New(), OwnerID: ownerID, AgentID: uuid.NullUUID{UUID: agentID, Valid: true}}
|
|
// A second unpinned chat bound to the same agent is hydrated by the
|
|
// same statement and must get its own watch event.
|
|
siblingChat := database.Chat{ID: uuid.New(), OwnerID: ownerID, AgentID: chat.AgentID}
|
|
snapshot := database.WorkspaceAgentContextSnapshot{
|
|
WorkspaceAgentID: agentID,
|
|
AggregateHash: []byte{0x0a, 0x0b},
|
|
}
|
|
pinnedChat := chat
|
|
pinnedChat.ContextAggregateHash = snapshot.AggregateHash
|
|
pinnedSibling := siblingChat
|
|
pinnedSibling.ContextAggregateHash = snapshot.AggregateHash
|
|
|
|
events := make(chan codersdk.ChatWatchEvent, 2)
|
|
cancelSub, err := ps.SubscribeWithErr(
|
|
coderdpubsub.ChatWatchEventChannel(ownerID),
|
|
coderdpubsub.HandleChatWatchEvent(func(_ context.Context, payload codersdk.ChatWatchEvent, err error) {
|
|
require.NoError(t, err)
|
|
events <- payload
|
|
}),
|
|
)
|
|
require.NoError(t, err)
|
|
defer cancelSub()
|
|
|
|
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([]uuid.UUID{chat.ID, siblingChat.ID}, nil)
|
|
db.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(pinnedChat, nil)
|
|
db.EXPECT().GetChatByID(gomock.Any(), siblingChat.ID).Return(pinnedSibling, nil)
|
|
|
|
server.ensureChatContextPinnedOnFirstTurn(ctx, chat)
|
|
|
|
// Watching clients cached both details without pinned resources, so
|
|
// every hydrated chat must broadcast a context event.
|
|
gotChatIDs := make([]uuid.UUID, 0, 2)
|
|
for range 2 {
|
|
event := testutil.RequireReceive(ctx, t, events)
|
|
require.Equal(t, codersdk.ChatWatchEventKindContextDirty, event.Kind)
|
|
gotChatIDs = append(gotChatIDs, event.Chat.ID)
|
|
}
|
|
require.ElementsMatch(t, []uuid.UUID{chat.ID, siblingChat.ID}, gotChatIDs)
|
|
})
|
|
|
|
t.Run("SkipsPublishWhenNoSnapshot", 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: nothing is stamped
|
|
// and no event is published (GetChatByID has no EXPECT, so a
|
|
// post-hydration read 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.ensureChatContextPinnedOnFirstTurn(ctx, database.Chat{
|
|
ID: uuid.New(),
|
|
AgentID: uuid.NullUUID{UUID: agentID, Valid: true},
|
|
})
|
|
})
|
|
|
|
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()})
|
|
})
|
|
}
|