diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 242623f78c..ef4ea259a2 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -125,10 +125,6 @@ type Server struct { // keyed by chat ID and invalidated when the agent changes. workspaceMCPToolsCache sync.Map // uuid.UUID -> *cachedWorkspaceMCPTools - // skillsCache caches discovered skill metadata per chat so - // we avoid re-scanning .agents/skills/ on every turn. - skillsCache sync.Map // uuid.UUID -> *cachedSkills - usageTracker *workspacestats.UsageTracker clock quartz.Clock @@ -181,69 +177,21 @@ type cachedWorkspaceMCPTools struct { tools []workspacesdk.MCPToolInfo } -// cachedSkills stores discovered skill metadata from a workspace -// agent, keyed by the agent ID that provided them. -type cachedSkills struct { - agentID uuid.UUID - skills []chattool.SkillMeta -} - -// discoverWorkspaceSkills returns cached skill metadata for a chat -// or discovers them fresh using the provided agent connection. The -// result is cached per chat+agent so subsequent turns skip the -// filesystem scan. -func (p *Server) discoverWorkspaceSkills( - ctx context.Context, - chatID uuid.UUID, - agent database.WorkspaceAgent, - conn workspacesdk.AgentConn, - logger slog.Logger, -) []chattool.SkillMeta { - // Check cache first. - if cached, ok := p.skillsCache.Load(chatID); ok { - if entry, ok2 := cached.(*cachedSkills); ok2 { - if entry.agentID == agent.ID { - return entry.skills - } - } - } - - dir := agent.ExpandedDirectory - if dir == "" { - dir = agent.Directory - } - discovered, err := chattool.DiscoverSkills(ctx, conn, dir) - if err != nil { - logger.Warn(ctx, "failed to discover skills", - slog.Error(err)) - return nil - } - // Cache the result. Unlike MCP tools, an empty skills - // list is a valid stable state (the workspace simply has - // no skills), so we always cache. - p.skillsCache.Store(chatID, &cachedSkills{ - agentID: agent.ID, - skills: discovered, - }) - return discovered -} - -// loadCachedWorkspaceContext checks the MCP tools and skills caches -// for the given chat and agent. Returns non-nil tools when the MCP -// cache hits, which signals the caller to skip the slow discovery -// path. Skills may also be populated from the skills cache. +// loadCachedWorkspaceContext checks the MCP tools cache for the +// given chat and agent. Returns non-nil tools when the cache hits, +// which signals the caller to skip the slow MCP discovery path. func (p *Server) loadCachedWorkspaceContext( chatID uuid.UUID, agent database.WorkspaceAgent, getConn func(context.Context) (workspacesdk.AgentConn, error), -) ([]fantasy.AgentTool, []chattool.SkillMeta) { +) []fantasy.AgentTool { cached, ok := p.workspaceMCPToolsCache.Load(chatID) if !ok { - return nil, nil + return nil } entry, ok := cached.(*cachedWorkspaceMCPTools) if !ok || entry.agentID != agent.ID { - return nil, nil + return nil } var tools []fantasy.AgentTool @@ -251,14 +199,7 @@ func (p *Server) loadCachedWorkspaceContext( tools = append(tools, chattool.NewWorkspaceMCPTool(t, getConn)) } - var skills []chattool.SkillMeta - if sc, ok := p.skillsCache.Load(chatID); ok { - if se, ok := sc.(*cachedSkills); ok && se.agentID == agent.ID { - skills = se.skills - } - } - - return tools, skills + return tools } type turnWorkspaceContext struct { @@ -2679,7 +2620,6 @@ func (p *Server) cleanupStreamIfIdle(chatID uuid.UUID, state *chatStreamState) { if !state.buffering && len(state.subscribers) == 0 { p.chatStreams.Delete(chatID) p.workspaceMCPToolsCache.Delete(chatID) - p.skillsCache.Delete(chatID) } } @@ -3935,7 +3875,7 @@ func (p *Server) runChat( if needsInstructionPersist { g2.Go(func() error { var persistErr error - instruction, persistErr = p.persistInstructionFiles( + instruction, skills, persistErr = p.persistInstructionFiles( ctx, chat, modelConfig.ID, @@ -3956,10 +3896,12 @@ func (p *Server) runChat( return nil }) } else if hasContextFiles { - // On subsequent turns, extract the instruction text from - // the persisted context-file parts so it can be re-injected - // via InsertSystem after compaction drops those messages. + // On subsequent turns, extract the instruction text and + // skill index from persisted parts so they can be + // re-injected via InsertSystem after compaction drops + // those messages. No workspace dial needed. instruction = instructionFromContextFiles(messages) + skills = skillsFromParts(messages) } g2.Go(func() error { resolvedUserPrompt = p.resolveUserPrompt(ctx, chat.OwnerID) @@ -3983,7 +3925,7 @@ func (p *Server) runChat( // query on the common subsequent-turn path. agent, agentErr := workspaceCtx.getWorkspaceAgent(ctx) if agentErr == nil { - if workspaceMCPTools, skills = p.loadCachedWorkspaceContext( + if workspaceMCPTools = p.loadCachedWorkspaceContext( chat.ID, agent, workspaceCtx.getWorkspaceConn, ); workspaceMCPTools != nil { return nil @@ -4001,7 +3943,6 @@ func (p *Server) runChat( if agentErr != nil { if xerrors.Is(agentErr, errChatHasNoWorkspaceAgent) { p.workspaceMCPToolsCache.Delete(chat.ID) - p.skillsCache.Delete(chat.ID) return nil } logger.Warn(ctx, "failed to resolve workspace agent for MCP tools", @@ -4009,29 +3950,19 @@ func (p *Server) runChat( return nil } - // Discover skills and MCP tools using the - // same conn to avoid a second dial attempt. + // List workspace MCP tools via the agent conn. conn, connErr := workspaceCtx.getWorkspaceConn(workspaceMCPCtx) if connErr != nil { logger.Warn(ctx, "failed to get workspace conn for MCP tools", slog.Error(connErr)) return nil } - - agent, agentErr = workspaceCtx.getWorkspaceAgent(workspaceMCPCtx) - if agentErr == nil { - skills = p.discoverWorkspaceSkills( - workspaceMCPCtx, chat.ID, agent, conn, logger, - ) - } - toolsResp, listErr := conn.ListMCPTools(workspaceMCPCtx) if listErr != nil { logger.Warn(ctx, "failed to list workspace MCP tools", slog.Error(listErr)) return nil } - // Cache the result for subsequent turns. Skip // caching when the list is empty because the // agent's MCP Connect may not have finished yet; @@ -4909,25 +4840,26 @@ func contextFileAgentID(messages []database.ChatMessage) (uuid.UUID, bool) { return lastID, found } -// persistInstructionFiles reads instruction files from the workspace -// agent and persists them as context-file message parts. This is called -// once when a workspace is first attached to a chat. Returns the -// formatted instruction string for injection into the current turn's -// prompt. +// persistInstructionFiles reads instruction files and discovers +// skills from the workspace agent, persisting both as message +// parts. This is called once when a workspace is first attached +// to a chat (or when the agent changes). Returns the formatted +// instruction string and skill index for injection into the +// current turn's prompt. func (p *Server) persistInstructionFiles( ctx context.Context, chat database.Chat, modelConfigID uuid.UUID, getWorkspaceAgent func(context.Context) (database.WorkspaceAgent, error), getWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error), -) (string, error) { +) (string, []chattool.SkillMeta, error) { if !chat.WorkspaceID.Valid || getWorkspaceAgent == nil { - return "", nil + return "", nil, nil } agent, err := getWorkspaceAgent(ctx) if err != nil { - return "", nil + return "", nil, nil } directory := agent.ExpandedDirectory @@ -4970,20 +4902,47 @@ func (p *Server) persistInstructionFiles( } } + // Discover skills from the workspace while we have a + // connection. Errors are non-fatal — a chat without skills + // still works, it just won't list them in the prompt. + var discoveredSkills []chattool.SkillMeta + if workspaceConnOK { + conn, connErr := getWorkspaceConn(ctx) + if connErr == nil { + var discoverErr error + discoveredSkills, discoverErr = chattool.DiscoverSkills(ctx, conn, directory) + if discoverErr != nil { + p.logger.Debug(ctx, "failed to discover skills", + slog.F("chat_id", chat.ID), + slog.Error(discoverErr), + ) + } + } + } + if len(sections) == 0 { if !workspaceConnOK { - return "", nil + return "", nil, nil } - // Persist a sentinel so subsequent turns skip the - // workspace agent dial. + // Persist a sentinel (plus any discovered skill parts) + // so subsequent turns skip the workspace agent dial. parts := []codersdk.ChatMessagePart{{ Type: codersdk.ChatMessagePartTypeContextFile, ContextFilePath: "", ContextFileAgentID: uuid.NullUUID{UUID: agent.ID, Valid: true}, }} + for _, s := range discoveredSkills { + parts = append(parts, codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeSkill, + SkillName: s.Name, + SkillDescription: s.Description, + SkillDir: s.Dir, + ContextFileAgentID: uuid.NullUUID{UUID: agent.ID, Valid: true}, + }) + } content, err := chatprompt.MarshalParts(parts) if err != nil { - return "", nil + return "", nil, nil } msgParams := database.InsertChatMessagesParams{ //nolint:exhaustruct // Fields populated by appendChatMessage. ChatID: chat.ID, @@ -4996,11 +4955,12 @@ func (p *Server) persistInstructionFiles( chatprompt.CurrentContentVersion, )) _, _ = p.db.InsertChatMessages(ctx, msgParams) - return "", nil + return "", discoveredSkills, nil } - // Build context-file parts, one per instruction file. - parts := make([]codersdk.ChatMessagePart, 0, len(sections)) + // Build context-file parts (one per instruction file) and + // skill parts (one per discovered skill). + parts := make([]codersdk.ChatMessagePart, 0, len(sections)+len(discoveredSkills)) for _, s := range sections { parts = append(parts, codersdk.ChatMessagePart{ Type: codersdk.ChatMessagePartTypeContextFile, @@ -5012,10 +4972,19 @@ func (p *Server) persistInstructionFiles( ContextFileDirectory: directory, }) } + for _, s := range discoveredSkills { + parts = append(parts, codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeSkill, + SkillName: s.Name, + SkillDescription: s.Description, + SkillDir: s.Dir, + ContextFileAgentID: uuid.NullUUID{UUID: agent.ID, Valid: true}, + }) + } content, err := chatprompt.MarshalParts(parts) if err != nil { - return "", xerrors.Errorf("marshal context-file parts: %w", err) + return "", nil, xerrors.Errorf("marshal context-file parts: %w", err) } msgParams := database.InsertChatMessagesParams{ //nolint:exhaustruct // Fields populated by appendChatMessage. @@ -5029,13 +4998,13 @@ func (p *Server) persistInstructionFiles( chatprompt.CurrentContentVersion, )) if _, err := p.db.InsertChatMessages(ctx, msgParams); err != nil { - return "", xerrors.Errorf("persist instruction files: %w", err) + return "", nil, xerrors.Errorf("persist instruction files: %w", err) } - // Return the formatted instruction text so the caller can inject - // it into this turn's prompt (since the prompt was built before - // we persisted). - return formatSystemInstructions(agent.OperatingSystem, directory, sections), nil + // Return the formatted instruction text and discovered skills + // so the caller can inject them into this turn's prompt (since + // the prompt was built before we persisted). + return formatSystemInstructions(agent.OperatingSystem, directory, sections), discoveredSkills, nil } // resolveUserCompactionThreshold looks up the user's per-model diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index 81772fcb05..7632aa86ad 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -24,6 +24,7 @@ import ( coderdpubsub "github.com/coder/coder/v2/coderd/pubsub" "github.com/coder/coder/v2/coderd/x/chatd/chaterror" "github.com/coder/coder/v2/coderd/x/chatd/chattest" + "github.com/coder/coder/v2/coderd/x/chatd/chattool" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/workspacesdk" "github.com/coder/coder/v2/codersdk/workspacesdk/agentconnmock" @@ -489,9 +490,8 @@ func TestPersistInstructionFilesIncludesAgentMetadata(t *testing.T) { conn.EXPECT().LS(gomock.Any(), "", gomock.Any()).Return( workspacesdk.LSResponse{}, codersdk.NewTestError(404, "POST", "/api/v0/list-directory"), - ).Times(1) - conn.EXPECT().ReadFile( - gomock.Any(), + ).AnyTimes() + conn.EXPECT().ReadFile(gomock.Any(), "/home/coder/project/AGENTS.md", int64(0), int64(maxInstructionFileBytes+1), @@ -520,7 +520,7 @@ func TestPersistInstructionFilesIncludesAgentMetadata(t *testing.T) { } t.Cleanup(workspaceCtx.close) - instruction, err := server.persistInstructionFiles( + instruction, _, err := server.persistInstructionFiles( ctx, chat, uuid.New(), @@ -551,7 +551,7 @@ func TestPersistInstructionFilesSkipsSentinelWhenWorkspaceUnavailable(t *testing logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), } - instruction, err := server.persistInstructionFiles( + instruction, _, err := server.persistInstructionFiles( ctx, chat, uuid.New(), @@ -1515,6 +1515,156 @@ func requireFieldValue(t *testing.T, entry slog.SinkEntry, name string, expected t.Fatalf("field %q not found in log entry", name) } +func TestSkillsFromParts(t *testing.T) { + t.Parallel() + + t.Run("Empty", func(t *testing.T) { + t.Parallel() + got := skillsFromParts(nil) + require.Empty(t, got) + }) + + t.Run("NoSkillParts", func(t *testing.T) { + t.Parallel() + msgs := []database.ChatMessage{ + chatMessageWithParts([]codersdk.ChatMessagePart{ + {Type: codersdk.ChatMessagePartTypeText, Text: "hello"}, + }), + } + got := skillsFromParts(msgs) + require.Empty(t, got) + }) + + t.Run("SingleSkill", func(t *testing.T) { + t.Parallel() + msgs := []database.ChatMessage{ + chatMessageWithParts([]codersdk.ChatMessagePart{ + { + Type: codersdk.ChatMessagePartTypeSkill, + SkillName: "deep-review", + SkillDescription: "Multi-reviewer code review", + SkillDir: "/home/coder/.agents/skills/deep-review", + }, + }), + } + got := skillsFromParts(msgs) + require.Len(t, got, 1) + require.Equal(t, "deep-review", got[0].Name) + require.Equal(t, "Multi-reviewer code review", got[0].Description) + require.Equal(t, "/home/coder/.agents/skills/deep-review", got[0].Dir) + }) + + t.Run("MultipleSkillsAcrossMessages", func(t *testing.T) { + t.Parallel() + msgs := []database.ChatMessage{ + chatMessageWithParts([]codersdk.ChatMessagePart{ + { + Type: codersdk.ChatMessagePartTypeSkill, + SkillName: "pull-requests", + SkillDir: "/home/coder/.agents/skills/pull-requests", + }, + }), + chatMessageWithParts([]codersdk.ChatMessagePart{ + { + Type: codersdk.ChatMessagePartTypeSkill, + SkillName: "deep-review", + SkillDir: "/home/coder/.agents/skills/deep-review", + }, + }), + } + got := skillsFromParts(msgs) + require.Len(t, got, 2) + require.Equal(t, "pull-requests", got[0].Name) + require.Equal(t, "deep-review", got[1].Name) + }) + + t.Run("MixedPartTypes", func(t *testing.T) { + t.Parallel() + msgs := []database.ChatMessage{ + chatMessageWithParts([]codersdk.ChatMessagePart{ + { + Type: codersdk.ChatMessagePartTypeContextFile, + ContextFilePath: "/home/coder/.coder/AGENTS.md", + }, + { + Type: codersdk.ChatMessagePartTypeSkill, + SkillName: "refine-plan", + SkillDir: "/home/coder/.agents/skills/refine-plan", + }, + }), + // A text-only message should be skipped entirely. + chatMessageWithParts([]codersdk.ChatMessagePart{ + {Type: codersdk.ChatMessagePartTypeText, Text: "user turn"}, + }), + } + got := skillsFromParts(msgs) + require.Len(t, got, 1) + require.Equal(t, "refine-plan", got[0].Name) + require.Equal(t, "/home/coder/.agents/skills/refine-plan", got[0].Dir) + }) + + t.Run("OptionalDescriptionOmitted", func(t *testing.T) { + t.Parallel() + msgs := []database.ChatMessage{ + chatMessageWithParts([]codersdk.ChatMessagePart{ + { + Type: codersdk.ChatMessagePartTypeSkill, + SkillName: "refine-plan", + SkillDir: "/home/coder/.agents/skills/refine-plan", + }, + }), + } + got := skillsFromParts(msgs) + require.Len(t, got, 1) + require.Equal(t, "refine-plan", got[0].Name) + require.Empty(t, got[0].Description) + }) + + t.Run("InvalidJSON", func(t *testing.T) { + t.Parallel() + msgs := []database.ChatMessage{ + { + Content: pqtype.NullRawMessage{ + RawMessage: []byte(`not valid json with "skill" in it`), + Valid: true, + }, + }, + } + got := skillsFromParts(msgs) + require.Empty(t, got) + }) + + t.Run("RoundTrip", func(t *testing.T) { + // Simulate persist -> reconstruct cycle: marshal skill + // parts the same way persistInstructionFiles does, then + // verify skillsFromParts recovers the metadata. + t.Parallel() + want := []chattool.SkillMeta{ + {Name: "deep-review", Description: "Multi-reviewer review", Dir: "/skills/deep-review"}, + {Name: "pull-requests", Description: "", Dir: "/skills/pull-requests"}, + } + agentID := uuid.New() + var parts []codersdk.ChatMessagePart + for _, s := range want { + parts = append(parts, codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeSkill, + SkillName: s.Name, + SkillDescription: s.Description, + SkillDir: s.Dir, + ContextFileAgentID: uuid.NullUUID{UUID: agentID, Valid: true}, + }) + } + msgs := []database.ChatMessage{chatMessageWithParts(parts)} + got := skillsFromParts(msgs) + require.Len(t, got, len(want)) + for i, w := range want { + require.Equal(t, w.Name, got[i].Name) + require.Equal(t, w.Description, got[i].Description) + require.Equal(t, w.Dir, got[i].Dir) + } + }) +} + func TestContextFileAgentID(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/instruction.go b/coderd/x/chatd/instruction.go index 2a5087ad66..fe8da3d4e3 100644 --- a/coderd/x/chatd/instruction.go +++ b/coderd/x/chatd/instruction.go @@ -13,6 +13,7 @@ import ( "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/x/chatd/chattool" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/workspacesdk" ) @@ -202,6 +203,37 @@ func instructionFromContextFiles( return formatSystemInstructions(os, dir, sections) } +// skillsFromParts reconstructs skill metadata from persisted +// skill parts. This is analogous to instructionFromContextFiles +// so the skill index can be re-injected after compaction without +// re-dialing the workspace agent. +func skillsFromParts( + messages []database.ChatMessage, +) []chattool.SkillMeta { + var skills []chattool.SkillMeta + for _, msg := range messages { + if !msg.Content.Valid || + !bytes.Contains(msg.Content.RawMessage, []byte(`"skill"`)) { + continue + } + var parts []codersdk.ChatMessagePart + if err := json.Unmarshal(msg.Content.RawMessage, &parts); err != nil { + continue + } + for _, part := range parts { + if part.Type != codersdk.ChatMessagePartTypeSkill { + continue + } + skills = append(skills, chattool.SkillMeta{ + Name: part.SkillName, + Description: part.SkillDescription, + Dir: part.SkillDir, + }) + } + } + return skills +} + // pwdInstructionFilePath returns the absolute path to the AGENTS.md // file in the given working directory, or empty if directory is empty. func pwdInstructionFilePath(directory string) string { diff --git a/codersdk/chats.go b/codersdk/chats.go index 59e985bd54..1b19d7a82b 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -116,6 +116,7 @@ const ( ChatMessagePartTypeFile ChatMessagePartType = "file" ChatMessagePartTypeFileReference ChatMessagePartType = "file-reference" ChatMessagePartTypeContextFile ChatMessagePartType = "context-file" + ChatMessagePartTypeSkill ChatMessagePartType = "skill" ) // AllChatMessagePartTypes returns all known ChatMessagePartType values. @@ -129,6 +130,7 @@ func AllChatMessagePartTypes() []ChatMessagePartType { ChatMessagePartTypeFile, ChatMessagePartTypeFileReference, ChatMessagePartTypeContextFile, + ChatMessagePartTypeSkill, } } @@ -211,6 +213,16 @@ type ChatMessagePart struct { // workspace agent. Internal only: same purpose as // ContextFileOS. ContextFileDirectory string `json:"context_file_directory,omitempty" typescript:"-"` + // SkillName is the kebab-case name of a discovered skill + // from the workspace's .agents/skills/ directory. + SkillName string `json:"skill_name" variants:"skill"` + // SkillDescription is the short description from the skill's + // SKILL.md frontmatter. + SkillDescription string `json:"skill_description,omitempty" variants:"skill?"` + // SkillDir is the absolute path to the skill directory inside + // the workspace filesystem. Internal only: used by + // read_skill/read_skill_file tools to locate skill files. + SkillDir string `json:"skill_dir,omitempty" typescript:"-"` } // StripInternal removes internal-only fields that must not be @@ -227,6 +239,7 @@ func (p *ChatMessagePart) StripInternal() { p.ContextFileContent = "" p.ContextFileOS = "" p.ContextFileDirectory = "" + p.SkillDir = "" } // ChatMessageText builds a text chat message part. diff --git a/codersdk/chats_test.go b/codersdk/chats_test.go index 7066a52e2a..0d8c2f6a2f 100644 --- a/codersdk/chats_test.go +++ b/codersdk/chats_test.go @@ -238,6 +238,7 @@ func TestChatMessagePartVariantTags(t *testing.T) { "context_file_content": "internal only, stripped before API responses (typescript:\"-\")", "context_file_os": "internal only, used during prompt expansion (typescript:\"-\")", "context_file_directory": "internal only, used during prompt expansion (typescript:\"-\")", + "skill_dir": "internal only, used by read_skill tools (typescript:\"-\")", } knownTypes := make(map[codersdk.ChatMessagePartType]bool) for _, pt := range codersdk.AllChatMessagePartTypes() { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index c8d4aef518..38e1c9687c 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1498,7 +1498,8 @@ export type ChatMessagePart = | ChatSourcePart | ChatFilePart | ChatFileReferencePart - | ChatContextFilePart; + | ChatContextFilePart + | ChatSkillPart; // From codersdk/chats.go export type ChatMessagePartType = @@ -1506,6 +1507,7 @@ export type ChatMessagePartType = | "file" | "file-reference" | "reasoning" + | "skill" | "source" | "text" | "tool-call" @@ -1516,6 +1518,7 @@ export const ChatMessagePartTypes: ChatMessagePartType[] = [ "file", "file-reference", "reasoning", + "skill", "source", "text", "tool-call", @@ -1857,6 +1860,21 @@ export interface ChatReasoningPart { readonly text: string; } +// From codersdk/chats.go +export interface ChatSkillPart { + readonly type: "skill"; + /** + * SkillName is the kebab-case name of a discovered skill + * from the workspace's .agents/skills/ directory. + */ + readonly skill_name: string; + /** + * SkillDescription is the short description from the skill's + * SKILL.md frontmatter. + */ + readonly skill_description?: string; +} + // From codersdk/chats.go export interface ChatSourcePart { readonly type: "source"; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx index b2d98a2cf1..a0225b22ab 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx @@ -468,10 +468,13 @@ const ChatMessageItem = memo<{ return null; } - // Hide messages that consist entirely of context-file parts. - // These are metadata for the context indicator, not - // conversation content. - if (parts.length > 0 && parts.every((p) => p.type === "context-file")) { + // Hide messages that consist entirely of context-file + // and/or skill parts. These are metadata for the context + // indicator, not conversation content. + if ( + parts.length > 0 && + parts.every((p) => p.type === "context-file" || p.type === "skill") + ) { return null; } const hasRenderableContent = diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts index af0b3517e2..19c652da7e 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts @@ -222,6 +222,11 @@ export const parseMessageContent = ( // they are not rendered in the conversation timeline. break; } + case "skill": { + // Skill parts are metadata for the context indicator; + // they are not rendered in the conversation timeline. + break; + } default: { const _exhaustive: never = part; break; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts b/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts index 9a0ae7873b..d51b333240 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts @@ -179,6 +179,9 @@ export const applyMessagePartToStreamState = ( // context-file parts are metadata-only; no streaming // render needed. case "context-file": + // skill parts are metadata-only; no streaming render + // needed. + case "skill": return prev; default: { const _exhaustive: never = part;