mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
feat: load organization skills in chats (#30613)
## Stack Closes [CODAGT-1341](https://linear.app/codercom/issue/CODAGT-1341/support-organization-level-skills-in-coder-agents). This stack adds organization skills to Coder Agents: admins publish a skill once per organization, share it with groups or members, and chats in that organization load it next to personal and workspace skills. It replaces the 21-PR draft stack #30584 to #30604 with five PRs, bottom to top: 1. #30609 data model: one migration, queries, RBAC and dbauthz 2. #30610 organization skill API routes and the scope-neutral SDK 3. #30611 sharing ACL routes 4. #30612 settings UI: shared skills table, admin page, member section 5. **#30613** chats: chatd loading, `/` menu, docs (this PR) ## What changes This PR makes chats use organization skills: chatd adds the skills shared with the chat owner in the chat's organization to the skill index, `read_skill` loads them, the `/` menu offers them, and the docs describe the feature. **chatd** (`coderd/x/skills`, `coderd/x/chatd`): - New source `skills.SourceOrganization` (`"org"`). `MergeSkills(personal, organization, workspace)` sorts by name, then source. A name in one source keeps its bare alias; a name in more than one source gets a qualified alias in every source that has it (`personal/<name>`, `org/<name>`, `workspace/<name>`), so no source overrides another. `Lookup` returns `ErrSkillAmbiguous` for a bare name that matches several sources. - **Authorization (please review closely):** each turn, chatd reads organization skill metadata and bodies as the chat owner's real RBAC subject with their roles and groups (`chatOwnerSubject`), never as the chatd system actor, so per-skill ACLs apply. Disabled skills are skipped. A skill that is deleted, disabled, or no longer shared by the time `read_skill` runs is reported as not found. If the owner subject or the metadata can't be loaded, turn preparation fails with the error, as main now does for personal skills (#30492), instead of running the turn without organization skills. - `read_skill` loads personal and organization bodies through one `LoadStoredSkillBody` callback. An index description longer than 1,024 runes, the Agent Skills specification limit (`maxSkillIndexDescriptionRunes`), is cut to 1,023 runes plus an ellipsis so it stays within the limit; `read_skill` still returns the full skill. - Parse and lookup errors keep `%w` last on purpose: with `xerrors`, a `%w` that isn't last repeats the cause, so main's detail read "skill is too large: got N bytes, maximum is 65536 bytes: skill is too large". This stack returns the cause once (ThomasK33 asked about the order in review). **`/` menu** (`site/src/pages/AgentsPage`): the chat page fetches the chat organization's enabled skills (`organizationSkills(...)` from #30612's queries) next to personal ones. `utils/skillAliases.ts` (`resolveSkillTriggers`) follows `MergeSkills`: a personal or organization trigger is bare unless its name is in more than one source, then it becomes `/personal/<name>` or `/org/<name>`. While any list is unknown every trigger is qualified, because a qualified alias always resolves; workspace triggers are always qualified. Built-in `/clear` and `/compact` stay pending until every skill list is known, and a list with no data counts as unknown even after an error, so a failed fetch can't make a built-in run in place of a same-named skill. Disabled skills never shadow a built-in. **Docs:** `docs/ai-coder/agents/extending-agents.md` gains two sections, Organization skills (manage them, control who can use one, find the ones you can use) and How chats load stored skills (turning a skill on or off, skills with the same name). The admin page's organization picker shows only for a user who can view more than one organization's skills. The member "From your organizations" section shows one organization at a time with its own picker, starts on the default organization, and has Export all for the selected organization. The Enabled column is a switch for editors and a check or X otherwise. The settings table in `platform-controls/organizations.md` gains rows for organization and personal skills. ## Commits The first commit reproduces the reviewed chatd code from the old stack; the site and docs commits reapply the old layers on #30612's queries, and the rest are labeled trims or fixes. - `aa634eec94a` feat(coderd/x): load organization skills in chats. Reproduction of the old skill sources and chatd layers (old head `a1047f4986`). - `97945db4e86` test(coderd/x/skills): make `TestMergeSkills` (6 cases) and `TestLookup` (8 cases) table-driven; coverage is 100% before and after, with the same blocks. - `dde61cc5fe2` test(coderd/x/chatd): drop skill tests duplicated by the organization skills integration test `TestOrganizationSkillsFollowChatOwnerAccess`; the covered block set of the touched code is identical (348 of 506 blocks). - `aad14b2dd05` feat(site/src/pages/AgentsPage): show organization skills in the slash menu. The old alias helper and slash menu layers, applied cleanly on #30612's queries. - `712e272e140` test(site/src/pages/AgentsPage): trim duplicated slash menu tests and stories. Deletes `AgentChatPage.test.tsx` (177 lines; its assertions live in `submitChatTurn.test.ts` and `ChatMessageInput.test.tsx`), two duplicate `ChatMessageInput` cases, six of 17 `SkillsTriggerMenu` stories, and one disabled-skill fixture row from a `ChatMessageInput` story. - `232535e1011` docs(docs/ai-coder/agents): document organization skills. From the old docs layer, with the View line updated for #30612's read-only dialog and Download. - `62dbbd2dfd3` refactor(site/src/pages/AgentsPage): address frontend review on the slash menu. - `7349eac4389` Merge the updated settings-ui layer into chats. Brings in the #30610 and #30612 fixes; the merge has no changes of its own. - `0f84a30ec69` Merge the updated settings-ui layer into chats again, for #30612's read-only View fix; no changes of its own. - `fb37822f985` fix(coderd/x/chatd/chattool): keep truncated skill index descriptions within 1,024 runes. The cut now happens at 1,023 runes so the ellipsis fits; the existing `skill_test.go` assertion now expects 1,023 runes plus the ellipsis. - `1543666b584` docs(docs/ai-coder/agents): state the 1,023-character cut before the skill index ellipsis. - `96f1da47c9a` and `13f03d095ff` merge the updated settings-ui layer for #30612's cache and Skills link fixes; no changes of their own. - `b03c14c86de` and `41cc078b85a` merge the updated settings-ui layer; `b03c14c86de` moves the `AgentChatPage.tsx` and `ChatMessageInput.tsx` call sites, and their tests and stories, to #30612's split `organizationSkills(...)` query. - `1a32d05c9bc` test(site/src/pages/AgentsPage/components/ChatMessageInput): restore per-source loading stories for the slash menu. - `830b0466982` fix(coderd/x/chatd): fail turn preparation when organization skills cannot load. Main now returns database errors from turn preparation (#30492); organization skills still logged and dropped the failure, so a failed read silently hid them from the model. The new `TestFetchOrganizationSkillMetadataListFailure` covers the returned error. - `84c135f8801` and `a6f8f63ad13` merge the updated settings-ui layer, which brings in main; they resolve `generation_preparer.go` against main's turn preparation error handling. - `19b7880840f` merges #30612's member page test change; no changes of its own. - `c74d597f3c5` merges #30612's read-only status change; no changes of its own. - `f89c232c081` merges #30612's skills test changes; no changes of its own. - `17d5b9bfbc5` docs(docs/ai-coder/agents): describe the member skills organization picker and read-only Enabled status. - `f97b1710f71` merges the updated settings-ui layer (main at `e7b76872098`); no changes of its own. ## Size Production +558/-213 (docs +116/-6), tests +895/-262; total +1453/-475. This is above the 500-line reviewer-friendly limit on purpose: Mike asked for 4 to 5 PRs instead of the 21 small ones this stack replaces. ## Replaces #30593 (skill sources), #30594 (chatd), #30601 (alias helper), #30602 (slash menu), #30604 (docs). ## Validation - Go: all 21 packages under `coderd/x/skills` and `coderd/x/chatd` pass on `62dbbd2dfd3`, plus the coverage comparisons above; `coderd/x/chatd/chattool` passes again on `13f03d095ff` after the truncation fix. - Site: `pnpm run lint` and Biome clean; Vitest 102 tests; Storybook Vitest 49 + 4 tests. `pnpm run lint` reran clean on `13f03d095ff`. - On `a6f8f63ad13`, this PR's head before the test-only `19b7880840f` merge: `go test` passes for `coderd/x/skills`, `coderd/x/chatd/chattool`, and the `coderd/x/chatd` skill tests (`-run 'OrganizationSkill|Skill'`); 38 Vitest files covering skills and the slash menu pass, 542 tests with 2 skipped. - Docs: markdownlint 0 findings, Vale 0 findings. - On `17d5b9bfbc5`: Vale clean, markdownlint and `make pre-commit` pass. On `f97b1710f71`: `go build`, the `coderd/x/skills` and `chattool` tests, `pnpm check` and `tsc` pass. - Remote UAT round 7 through Coder Agents: PASS ([chat](https://dogfood.cdr.dev/agents/3426d4d3-2139-4ae1-8e0c-72ddeff413a8)). - Full run on `62dbbd2dfd3`, including an in-place upgrade from main `50c785cc6d7`: personal skills kept their IDs, names, content hashes and timestamps, came up enabled, and worked in chats (`read_skill`) and the `/` menu; create, edit, download and delete worked after the upgrade. The admin page, sharing, the Enabled toggle, the member section, the `/` menu, chats reading organization skills as the owner, limits, audit, authorization and scoped keys, and the docs all passed. - Delta run on `7349eac4389`: an update-only key gets 404 on PATCH with no content in the response; saving permissions refreshes Updated without a reload; smoke of the admin page, auditor View and Download, the `/` menu and `read_skill`. - Delta run on `0f84a30ec69` for `aaa356f0fd5`: read-only View, as an org auditor and as a member, shows no Name hint, focuses no field and starts on Close, and Escape or Close returns focus to the row menu button; Add skill and Edit still show the hint and focus the import field, and a saved edit persisted; smoke of the admin page, the `/` menu organization group and `read_skill`. Screen-reader behavior was not tested. - Delta run on `13f03d095ff` for `698dd83946d`, `f82573cd1c2`, `0e55137de7b` and `fb37822f985`: for an organization admin of org B who is a plain member of org A, the sidebar Skills link drops `?org=` for org A on seven AI settings pages and opens org B's skills, keeps `?org=` for org B, and a typed `/ai/settings/skills?org=` for org A still shows the permission error. A skill deleted and recreated with the same name loads a fresh sharing list showing only Everyone, with no stale grant. A 1,500-character description appears in the chat's skill index as 1,023 characters plus an ellipsis, while `read_skill` returns all of it. Smoke of the admin toggle, the member section, the `/` menu organization group and `read_skill`. - Low findings: the read-only View hint and field focus, fixed in `aaa356f0fd5` and verified by the delta run above. The member section's always-on disabled Enabled switch (also in the old stack) is replaced by a read-only status icon in #30612's `63bb42754e3`, which has no UAT run. Not fixed: `/` shows the `/personal/` prefix while organization skills fail to load (the unknown-list rule above); the shared Switch component's disabled styling (unchanged from main). A user who can read skills in only one organization sees no organization name on the Skills page, because the picker is hidden for a single organization, as on the MCP servers page on main. - Not tested: the workspace skills group (no running agent on the test deployment), the 100-per-user personal cap, and behavior without a Premium license. - Remote UAT round 8 through Coder Agents: PASS on `b03c14c86de` (same chat). The remote grader marked the unknown `read_skill` item FAIL only because the prompt asked for the literal phrase "skill not found"; the result names the skill once and says "not found" once, the same format as main, so the runner graded it PASS on intent. - Member page: starts on the default organization, shows one organization's table with the read-only note once, Export all downloads only the selected organization's skills, the picker is hidden with one organization, and an organization without enabled skills isn't listed. - Personal and admin tables: create, edit, toggle and delete each refetch the list; reopening the editor shows the new content even with the detail request delayed; a toggle changes state once, with no flicker. A skill deleted and recreated with the same name shows only Everyone in sharing. - The sidebar Skills link keeps `?org=` only for an organization whose skills the user can read. A create over 64 KiB returns 400 with the detail "got 70000 bytes, maximum is 65536 bytes: skill is too large", the cause once. The `/` menu shows Commands, Personal skills and Organization skills, and invoking an organization skill loads its body through `read_skill`. - New low findings, not fixed: the member page picker's listbox is labeled "Suggestions" (likely from the shared `OrganizationAutocomplete`); a typed body over 64 KiB shows only the size counter and a disabled Create until Import; `/ai/settings?org=` redirects to the models page without `?org=`. The first and third were not checked against main. - `9002dc60382`, `1a32d05c9bc`, `830b0466982`, `ac3e62c83b7`, `63bb42754e3`, `82b2487ad17`, `17d5b9bfbc5` and the main merges came after `b03c14c86de`; they have tests but no UAT run. - Every UAT round ran the migration as `000615_skills`. The heads ship the same up and down SQL as `000621_skills`, so the upgrade path from main's 000620 to 000621 has no UAT run. > Xum opened this PR on behalf of @ibetitsmike.
This commit is contained in:
@@ -3676,10 +3676,12 @@ func workspaceSkillsForResolution(workspaceSkills []chattool.SkillMeta) []skills
|
||||
|
||||
func mergeTurnSkills(
|
||||
personalSkills []skillspkg.Skill,
|
||||
organizationSkills []skillspkg.Skill,
|
||||
workspaceSkills []chattool.SkillMeta,
|
||||
) []skillspkg.ResolvedSkill {
|
||||
return skillspkg.MergeSkills(
|
||||
personalSkills,
|
||||
organizationSkills,
|
||||
workspaceSkillsForResolution(workspaceSkills),
|
||||
)
|
||||
}
|
||||
@@ -3793,6 +3795,9 @@ func (p *Server) fetchPersonalSkillMetadata(
|
||||
|
||||
personalSkills := make([]skillspkg.Skill, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if !row.Enabled {
|
||||
continue
|
||||
}
|
||||
personalSkills = append(personalSkills, skillspkg.Skill{
|
||||
Name: row.Name,
|
||||
Description: row.Description,
|
||||
@@ -3825,6 +3830,9 @@ func (p *Server) loadPersonalSkillBody(
|
||||
)
|
||||
return skillspkg.ParsedSkill{}, xerrors.Errorf("load personal skill body: %w", err)
|
||||
}
|
||||
if !row.Enabled {
|
||||
return skillspkg.ParsedSkill{}, skillspkg.ErrSkillNotFound
|
||||
}
|
||||
|
||||
parsed, err := skillspkg.ParsePersonalSkillMarkdown([]byte(row.Content))
|
||||
if err != nil {
|
||||
@@ -3838,6 +3846,89 @@ func (p *Server) loadPersonalSkillBody(
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// chatOwnerSubject returns the chat owner's real RBAC subject, with their
|
||||
// roles and groups, so organization skill reads honor per-skill ACLs.
|
||||
// userSkillContext has no organization membership, so no grant matches it.
|
||||
func (p *Server) chatOwnerSubject(ctx context.Context, ownerID uuid.UUID) (rbac.Subject, error) {
|
||||
ownerCtx, err := p.callerModelConfigContext(ctx, ownerID)
|
||||
if err != nil {
|
||||
return rbac.Subject{}, xerrors.Errorf("load chat owner subject: %w", err)
|
||||
}
|
||||
actor, ok := dbauthz.ActorFromContext(ownerCtx)
|
||||
if !ok || actor.Type != rbac.SubjectTypeUser || actor.ID != ownerID.String() {
|
||||
return rbac.Subject{}, xerrors.New("chat owner subject is unavailable")
|
||||
}
|
||||
return actor, nil
|
||||
}
|
||||
|
||||
func (p *Server) fetchOrganizationSkillMetadata(
|
||||
ctx context.Context,
|
||||
owner rbac.Subject,
|
||||
organizationID uuid.UUID,
|
||||
) ([]skillspkg.Skill, error) {
|
||||
//nolint:gocritic // Org skill ACLs are evaluated against the chat owner.
|
||||
rows, err := p.db.ListOrganizationSkillMetadataByOrganizationID(dbauthz.As(ctx, owner), organizationID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("list organization skill metadata: %w", err)
|
||||
}
|
||||
|
||||
organizationSkills := make([]skillspkg.Skill, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if !row.Enabled {
|
||||
continue
|
||||
}
|
||||
organizationSkills = append(organizationSkills, skillspkg.Skill{
|
||||
Name: row.Name,
|
||||
Description: row.Description,
|
||||
Source: skillspkg.SourceOrganization,
|
||||
})
|
||||
}
|
||||
return organizationSkills, nil
|
||||
}
|
||||
|
||||
func (p *Server) loadOrganizationSkillBody(
|
||||
ctx context.Context,
|
||||
owner rbac.Subject,
|
||||
organizationID uuid.UUID,
|
||||
name string,
|
||||
) (skillspkg.ParsedSkill, error) {
|
||||
//nolint:gocritic // Org skill ACLs are evaluated against the chat owner.
|
||||
row, err := p.db.GetOrganizationSkillByOrganizationIDAndName(
|
||||
dbauthz.As(ctx, owner),
|
||||
database.GetOrganizationSkillByOrganizationIDAndNameParams{
|
||||
OrganizationID: organizationID,
|
||||
Name: name,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
// A skill that is no longer shared with the owner is not authorized.
|
||||
if errors.Is(err, sql.ErrNoRows) || dbauthz.IsNotAuthorizedError(err) {
|
||||
return skillspkg.ParsedSkill{}, skillspkg.ErrSkillNotFound
|
||||
}
|
||||
p.logger.Error(ctx, "load organization skill body failed",
|
||||
slog.F("organization_id", organizationID),
|
||||
slog.F("name", name),
|
||||
slog.Error(err),
|
||||
)
|
||||
return skillspkg.ParsedSkill{}, xerrors.Errorf("load organization skill body: %w", err)
|
||||
}
|
||||
if !row.Enabled {
|
||||
return skillspkg.ParsedSkill{}, skillspkg.ErrSkillNotFound
|
||||
}
|
||||
|
||||
parsed, err := skillspkg.ParsePersonalSkillMarkdown([]byte(row.Content))
|
||||
if err != nil {
|
||||
p.logger.Error(ctx, "parse organization skill body failed",
|
||||
slog.F("organization_id", organizationID),
|
||||
slog.F("name", name),
|
||||
slog.Error(err),
|
||||
)
|
||||
return skillspkg.ParsedSkill{}, xerrors.Errorf("parse organization skill body: %w", err)
|
||||
}
|
||||
parsed.Source = skillspkg.SourceOrganization
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func (p *Server) appendRootChatTools(
|
||||
ctx context.Context,
|
||||
tools []fantasy.AgentTool,
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"charm.land/fantasy"
|
||||
fantasyopenai "charm.land/fantasy/providers/openai"
|
||||
"github.com/google/uuid"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
@@ -28,6 +29,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
"github.com/coder/coder/v2/coderd/workspacestats"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatloop"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
|
||||
@@ -1991,6 +1993,7 @@ func TestPersonalSkillsInSystemPrompt(t *testing.T) {
|
||||
Source: skillspkg.SourcePersonal,
|
||||
}},
|
||||
nil,
|
||||
nil,
|
||||
),
|
||||
"",
|
||||
"",
|
||||
@@ -2013,6 +2016,7 @@ func TestPersonalAndWorkspaceSkillCollisionInSystemPrompt(t *testing.T) {
|
||||
Description: "Personal deployment process",
|
||||
Source: skillspkg.SourcePersonal,
|
||||
}},
|
||||
nil,
|
||||
[]chattool.SkillMeta{{
|
||||
Name: "deploy",
|
||||
Description: "Workspace deployment process",
|
||||
@@ -2081,6 +2085,7 @@ func TestFetchPersonalSkillMetadata(t *testing.T) {
|
||||
UserID: uuid.NullUUID{UUID: userID, Valid: true},
|
||||
Name: "personal-review",
|
||||
Description: "Personal review process",
|
||||
Enabled: true,
|
||||
}}, nil
|
||||
},
|
||||
)
|
||||
@@ -2110,6 +2115,21 @@ func TestFetchPersonalSkillMetadata(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestFetchOrganizationSkillMetadataListFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
server := &Server{db: db}
|
||||
organizationID := uuid.New()
|
||||
listErr := xerrors.New("boom")
|
||||
|
||||
db.EXPECT().ListOrganizationSkillMetadataByOrganizationID(gomock.Any(), organizationID).Return(nil, listErr)
|
||||
|
||||
_, err := server.fetchOrganizationSkillMetadata(context.Background(), rbac.Subject{ID: uuid.NewString()}, organizationID)
|
||||
require.ErrorIs(t, err, listErr)
|
||||
}
|
||||
|
||||
func TestLoadPersonalSkillBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -2133,6 +2153,7 @@ func TestLoadPersonalSkillBody(t *testing.T) {
|
||||
UserID: uuid.NullUUID{UUID: userID, Valid: true},
|
||||
Name: "personal-review",
|
||||
Content: "---\nname: personal-review\ndescription: Personal review process\n---\n\nUpdated instructions.\n",
|
||||
Enabled: true,
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
@@ -2169,6 +2190,24 @@ func TestLoadPersonalSkillBody(t *testing.T) {
|
||||
require.ErrorIs(t, err, skillspkg.ErrSkillNotFound)
|
||||
})
|
||||
|
||||
t.Run("DisabledSkill", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
server := &Server{db: db}
|
||||
userID := uuid.New()
|
||||
|
||||
db.EXPECT().GetUserSkillByUserIDAndName(gomock.Any(), gomock.Any()).Return(database.Skill{
|
||||
UserID: uuid.NullUUID{UUID: userID, Valid: true},
|
||||
Name: "disabled-skill",
|
||||
Content: "---\nname: disabled-skill\ndescription: Disabled\n---\n\nInstructions.\n",
|
||||
}, nil)
|
||||
|
||||
_, err := server.loadPersonalSkillBody(context.Background(), userID, "disabled-skill")
|
||||
require.ErrorIs(t, err, skillspkg.ErrSkillNotFound)
|
||||
})
|
||||
|
||||
t.Run("DatabaseError", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -2223,6 +2262,7 @@ func TestLoadPersonalSkillBody(t *testing.T) {
|
||||
UserID: uuid.NullUUID{UUID: userID, Valid: true},
|
||||
Name: "broken-skill",
|
||||
Content: "---\nname: broken-skill\ndescription: Broken\n---\n\n \n",
|
||||
Enabled: true,
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
@@ -2240,6 +2280,75 @@ func TestLoadPersonalSkillBody(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadOrganizationSkillBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
authzDB := dbauthz.New(db, rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry()), slogtest.Make(t, nil), nil)
|
||||
server := &Server{
|
||||
db: authzDB,
|
||||
logger: slogtest.Make(t, nil),
|
||||
modelConfigContext: func(ctx context.Context, ownerID uuid.UUID) (context.Context, error) {
|
||||
return callerModelConfigContext(ctx, authzDB, ownerID)
|
||||
},
|
||||
}
|
||||
|
||||
org := dbgen.Organization(t, db, database.Organization{})
|
||||
member := dbgen.User(t, db, database.User{})
|
||||
outsider := dbgen.User(t, db, database.User{})
|
||||
for _, user := range []database.User{member, outsider} {
|
||||
dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: user.ID, OrganizationID: org.ID})
|
||||
}
|
||||
team := dbgen.Group(t, db, database.Group{OrganizationID: org.ID})
|
||||
dbgen.GroupMember(t, db, database.GroupMemberTable{UserID: member.ID, GroupID: team.ID})
|
||||
read := []policy.Action{policy.ActionRead}
|
||||
dbgen.OrganizationSkill(t, db, database.Skill{
|
||||
OrganizationID: uuid.NullUUID{UUID: org.ID, Valid: true},
|
||||
Name: "team-review",
|
||||
Content: "---\nname: team-review\ndescription: Team review\n---\n\nTeam instructions.\n",
|
||||
GroupACL: database.ChatACL{team.ID.String(): {Permissions: read}},
|
||||
UserACL: database.ChatACL{},
|
||||
})
|
||||
dbgen.OrganizationSkill(t, db, database.Skill{
|
||||
OrganizationID: uuid.NullUUID{UUID: org.ID, Valid: true},
|
||||
Name: "disabled-review",
|
||||
GroupACL: database.ChatACL{org.ID.String(): {Permissions: read}},
|
||||
UserACL: database.ChatACL{},
|
||||
})
|
||||
_, err := db.UpdateOrganizationSkillByOrganizationIDAndName(ctx, database.UpdateOrganizationSkillByOrganizationIDAndNameParams{
|
||||
Enabled: sql.NullBool{Bool: false, Valid: true},
|
||||
OrganizationID: org.ID,
|
||||
Name: "disabled-review",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
subject := func(userID uuid.UUID) rbac.Subject {
|
||||
t.Helper()
|
||||
got, err := server.chatOwnerSubject(dbauthz.AsChatd(ctx), userID)
|
||||
require.NoError(t, err)
|
||||
return got
|
||||
}
|
||||
|
||||
got, err := server.loadOrganizationSkillBody(ctx, subject(member.ID), org.ID, "team-review")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, skillspkg.SourceOrganization, got.Source)
|
||||
require.Equal(t, "Team instructions.", got.Body)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
reader uuid.UUID
|
||||
skill string
|
||||
}{
|
||||
{name: "NotShared", reader: outsider.ID, skill: "team-review"},
|
||||
{name: "Disabled", reader: member.ID, skill: "disabled-review"},
|
||||
{name: "Missing", reader: member.ID, skill: "missing-review"},
|
||||
} {
|
||||
_, err := server.loadOrganizationSkillBody(ctx, subject(tc.reader), org.ID, tc.skill)
|
||||
require.ErrorIs(t, err, skillspkg.ErrSkillNotFound, tc.name)
|
||||
}
|
||||
}
|
||||
|
||||
func systemPromptText(t *testing.T, prompt []fantasy.Message) string {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"path"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"golang.org/x/xerrors"
|
||||
@@ -22,6 +23,11 @@ const (
|
||||
AvailableSkillsOpenTag = "<available-skills>"
|
||||
// AvailableSkillsCloseTag is the XML end tag for the skill index block.
|
||||
AvailableSkillsCloseTag = "</available-skills>"
|
||||
|
||||
// maxSkillIndexDescriptionRunes is the Agent Skills specification limit
|
||||
// for a description. Longer stored descriptions are only cut in the index
|
||||
// so it stays bounded; read_skill still returns the full skill.
|
||||
maxSkillIndexDescriptionRunes = 1024
|
||||
)
|
||||
|
||||
// SkillMeta is the frontmatter from a skill meta file discovered in a
|
||||
@@ -69,7 +75,7 @@ func FormatResolvedSkillIndex(resolved []skillspkg.ResolvedSkill) string {
|
||||
for _, s := range resolved {
|
||||
entries = append(entries, skillIndexEntry{
|
||||
Alias: s.Alias,
|
||||
Description: s.Description,
|
||||
Description: truncateSkillIndexDescription(s.Description),
|
||||
})
|
||||
if s.Source == skillspkg.SourceWorkspace {
|
||||
hasWorkspaceSkill = true
|
||||
@@ -84,6 +90,21 @@ func FormatResolvedSkillIndex(resolved []skillspkg.ResolvedSkill) string {
|
||||
})
|
||||
}
|
||||
|
||||
func truncateSkillIndexDescription(description string) string {
|
||||
if utf8.RuneCountInString(description) <= maxSkillIndexDescriptionRunes {
|
||||
return description
|
||||
}
|
||||
runes := 0
|
||||
for i := range description {
|
||||
// Reserve one rune for the ellipsis so the result stays within the limit.
|
||||
if runes == maxSkillIndexDescriptionRunes-1 {
|
||||
return description[:i] + "…"
|
||||
}
|
||||
runes++
|
||||
}
|
||||
return description
|
||||
}
|
||||
|
||||
type skillIndexEntry struct {
|
||||
Alias string
|
||||
Description string
|
||||
@@ -113,8 +134,8 @@ func renderSkillIndex(entries []skillIndexEntry, opts skillIndexFormatOptions) s
|
||||
}
|
||||
if opts.includeQualifiedAliasInstruction {
|
||||
_, _ = b.WriteString(
|
||||
"When a skill is listed as personal/name or workspace/name, " +
|
||||
"pass that qualified alias to read_skill.\n",
|
||||
"When a skill is listed with a source prefix, such as " +
|
||||
"personal/name, pass that qualified alias to read_skill.\n",
|
||||
)
|
||||
}
|
||||
_, _ = b.WriteString("\n")
|
||||
@@ -288,10 +309,13 @@ const DefaultSkillMetaFile = "SKILL.md"
|
||||
// ReadSkillOptions configures the read_skill and read_skill_file
|
||||
// tools.
|
||||
type ReadSkillOptions struct {
|
||||
GetWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error)
|
||||
GetSkills func() []SkillMeta
|
||||
ResolveAlias func(string) (skillspkg.ResolvedSkill, error)
|
||||
LoadPersonalSkillBody func(context.Context, string) (skillspkg.ParsedSkill, error)
|
||||
GetWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error)
|
||||
GetSkills func() []SkillMeta
|
||||
ResolveAlias func(string) (skillspkg.ResolvedSkill, error)
|
||||
// LoadStoredSkillBody loads the current body of a personal or
|
||||
// organization skill. It returns skillspkg.ErrSkillNotFound when the
|
||||
// skill is gone, disabled, or no longer readable by the chat owner.
|
||||
LoadStoredSkillBody func(context.Context, skillspkg.Skill) (skillspkg.ParsedSkill, error)
|
||||
}
|
||||
|
||||
// ReadSkillArgs are the parameters accepted by read_skill.
|
||||
@@ -322,19 +346,19 @@ func ReadSkill(options ReadSkillOptions) fantasy.AgentTool {
|
||||
}
|
||||
|
||||
switch resolved.Source {
|
||||
case skillspkg.SourcePersonal:
|
||||
if options.LoadPersonalSkillBody == nil {
|
||||
case skillspkg.SourcePersonal, skillspkg.SourceOrganization:
|
||||
if options.LoadStoredSkillBody == nil {
|
||||
return fantasy.NewTextErrorResponse(
|
||||
"personal skill loader is not configured",
|
||||
"stored skill loader is not configured",
|
||||
), nil
|
||||
}
|
||||
content, err := options.LoadPersonalSkillBody(ctx, resolved.Name)
|
||||
content, err := options.LoadStoredSkillBody(ctx, resolved.Skill)
|
||||
if err != nil {
|
||||
if xerrors.Is(err, skillspkg.ErrSkillNotFound) {
|
||||
return skillNotFoundResponse(args.Name), nil
|
||||
}
|
||||
return fantasy.NewTextErrorResponse(
|
||||
fmt.Sprintf("failed to load personal skill %q", args.Name),
|
||||
fmt.Sprintf("failed to load %s skill %q", resolved.Source, args.Name),
|
||||
), nil
|
||||
}
|
||||
return toolResponse(map[string]any{
|
||||
@@ -392,13 +416,11 @@ func ReadSkillFile(options ReadSkillOptions) fantasy.AgentTool {
|
||||
if err != nil {
|
||||
return skillResolveErrorResponse(args.Name, err), nil
|
||||
}
|
||||
if resolved.Source == skillspkg.SourcePersonal {
|
||||
return fantasy.NewTextErrorResponse(
|
||||
"read_skill_file is not supported for personal skills (no supporting files)",
|
||||
), nil
|
||||
}
|
||||
if resolved.Source != skillspkg.SourceWorkspace {
|
||||
return skillNotFoundResponse(args.Name), nil
|
||||
return fantasy.NewTextErrorResponse(fmt.Sprintf(
|
||||
"read_skill_file is not supported for %s skills (no supporting files)",
|
||||
resolved.Source,
|
||||
)), nil
|
||||
}
|
||||
|
||||
skill, ok := findSkill(options.GetSkills, resolved.Name)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -125,13 +126,34 @@ func TestFormatResolvedSkillIndex(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resolved := skillspkg.MergeSkills(
|
||||
[]skillspkg.Skill{{Name: "review", Description: "Personal", Source: skillspkg.SourcePersonal}},
|
||||
[]skillspkg.Skill{{Name: "review", Description: "Workspace", Source: skillspkg.SourceWorkspace}},
|
||||
[]skillspkg.Skill{{Name: "review", Description: "Personal"}},
|
||||
[]skillspkg.Skill{{Name: "review", Description: "Organization"}},
|
||||
[]skillspkg.Skill{{Name: "review", Description: "Workspace"}},
|
||||
)
|
||||
idx := chattool.FormatResolvedSkillIndex(resolved)
|
||||
assert.Contains(t, idx, "- personal/review: Personal")
|
||||
assert.Contains(t, idx, "- workspace/review: Workspace")
|
||||
assert.Contains(t, idx, "pass that qualified alias to read_skill")
|
||||
assert.Contains(t, idx, "- personal/review: Personal\n- org/review: Organization\n- workspace/review: Workspace\n")
|
||||
assert.Contains(t, idx, "\nWhen a skill is listed with a source prefix, such as personal/name, pass that qualified alias to read_skill.\n")
|
||||
})
|
||||
|
||||
t.Run("TruncatesLongDescriptionsOnRuneBoundary", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A 3-byte rune makes a byte-based cut at 1,024 split a rune.
|
||||
atLimit := strings.Repeat("界", 1024)
|
||||
idx := chattool.FormatResolvedSkillIndex(skillspkg.MergeSkills(
|
||||
[]skillspkg.Skill{
|
||||
{Name: "at-limit", Description: atLimit},
|
||||
{Name: "over-limit", Description: atLimit + "界tail"},
|
||||
{Name: "short", Description: "Short description"},
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
))
|
||||
assert.True(t, utf8.ValidString(idx))
|
||||
assert.Contains(t, idx, "- at-limit: "+atLimit+"\n")
|
||||
assert.Contains(t, idx, "- over-limit: "+strings.Repeat("界", 1023)+"…\n")
|
||||
assert.Contains(t, idx, "- short: Short description\n")
|
||||
assert.NotContains(t, idx, "tail")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -370,7 +392,7 @@ func TestReadSkillTool(t *testing.T) {
|
||||
Alias: "my-skill",
|
||||
}, nil
|
||||
},
|
||||
LoadPersonalSkillBody: func(context.Context, string) (skillspkg.ParsedSkill, error) {
|
||||
LoadStoredSkillBody: func(context.Context, skillspkg.Skill) (skillspkg.ParsedSkill, error) {
|
||||
return skillspkg.ParsedSkill{
|
||||
Skill: skillspkg.Skill{
|
||||
Name: "my-skill",
|
||||
@@ -412,8 +434,8 @@ func TestReadSkillTool(t *testing.T) {
|
||||
Alias: "personal/my-skill",
|
||||
}, nil
|
||||
},
|
||||
LoadPersonalSkillBody: func(_ context.Context, name string) (skillspkg.ParsedSkill, error) {
|
||||
loadedName = name
|
||||
LoadStoredSkillBody: func(_ context.Context, skill skillspkg.Skill) (skillspkg.ParsedSkill, error) {
|
||||
loadedName = skill.Name
|
||||
return skillspkg.ParsedSkill{
|
||||
Skill: skillspkg.Skill{
|
||||
Name: "my-skill",
|
||||
@@ -531,8 +553,8 @@ func TestReadSkillTool(t *testing.T) {
|
||||
},
|
||||
GetSkills: func() []chattool.SkillMeta { return workspaceSkills },
|
||||
ResolveAlias: resolveAlias,
|
||||
LoadPersonalSkillBody: func(_ context.Context, name string) (skillspkg.ParsedSkill, error) {
|
||||
require.Equal(t, "deploy", name)
|
||||
LoadStoredSkillBody: func(_ context.Context, skill skillspkg.Skill) (skillspkg.ParsedSkill, error) {
|
||||
require.Equal(t, "deploy", skill.Name)
|
||||
return skillspkg.ParsedSkill{
|
||||
Skill: skillspkg.Skill{
|
||||
Name: "deploy",
|
||||
@@ -585,7 +607,7 @@ func TestReadSkillTool(t *testing.T) {
|
||||
Alias: alias,
|
||||
}, nil
|
||||
},
|
||||
LoadPersonalSkillBody: func(context.Context, string) (skillspkg.ParsedSkill, error) {
|
||||
LoadStoredSkillBody: func(context.Context, skillspkg.Skill) (skillspkg.ParsedSkill, error) {
|
||||
return skillspkg.ParsedSkill{}, skillspkg.ErrSkillNotFound
|
||||
},
|
||||
})
|
||||
@@ -610,7 +632,7 @@ func TestReadSkillTool(t *testing.T) {
|
||||
Alias: alias,
|
||||
}, nil
|
||||
},
|
||||
LoadPersonalSkillBody: func(context.Context, string) (skillspkg.ParsedSkill, error) {
|
||||
LoadStoredSkillBody: func(context.Context, skillspkg.Skill) (skillspkg.ParsedSkill, error) {
|
||||
return skillspkg.ParsedSkill{}, xerrors.New("synthetic private storage failure")
|
||||
},
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatadvisor"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatloop"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
|
||||
@@ -328,6 +329,8 @@ func (server *Server) prepareGeneration(
|
||||
workspaceMCPTools []fantasy.AgentTool
|
||||
workspaceSkills []chattool.SkillMeta
|
||||
personalSkills []skillspkg.Skill
|
||||
orgSkills []skillspkg.Skill
|
||||
orgSkillReader rbac.Subject
|
||||
resolvedUserPrompt string
|
||||
projectInstructions string
|
||||
planPathBlock string
|
||||
@@ -436,6 +439,15 @@ func (server *Server) prepareGeneration(
|
||||
personalSkills, err = server.fetchPersonalSkillMetadata(ctx, chat.OwnerID)
|
||||
return err
|
||||
})
|
||||
g2.Go(func() error {
|
||||
var err error
|
||||
orgSkillReader, err = server.chatOwnerSubject(ctx, chat.OwnerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
orgSkills, err = server.fetchOrganizationSkillMetadata(ctx, orgSkillReader, chat.OrganizationID)
|
||||
return err
|
||||
})
|
||||
g2.Go(func() error {
|
||||
var err error
|
||||
resolvedUserPrompt, err = server.resolveUserPrompt(ctx, chat.OwnerID)
|
||||
@@ -552,7 +564,7 @@ func (server *Server) prepareGeneration(
|
||||
subagentInstruction = defaultSubagentInstruction
|
||||
}
|
||||
resolvedSkillsFor := func(workspaceSkills []chattool.SkillMeta) []skillspkg.ResolvedSkill {
|
||||
return mergeTurnSkills(personalSkills, workspaceSkills)
|
||||
return mergeTurnSkills(personalSkills, orgSkills, workspaceSkills)
|
||||
}
|
||||
resolveSkillAlias := func(alias string) (skillspkg.ResolvedSkill, error) {
|
||||
return skillspkg.Lookup(resolvedSkillsFor(workspaceSkills), alias)
|
||||
@@ -642,12 +654,15 @@ func (server *Server) prepareGeneration(
|
||||
return workspaceSkills
|
||||
},
|
||||
ResolveAlias: resolveSkillAlias,
|
||||
LoadPersonalSkillBody: func(ctx context.Context, name string) (skillspkg.ParsedSkill, error) {
|
||||
return server.loadPersonalSkillBody(ctx, chat.OwnerID, name)
|
||||
LoadStoredSkillBody: func(ctx context.Context, skill skillspkg.Skill) (skillspkg.ParsedSkill, error) {
|
||||
if skill.Source == skillspkg.SourceOrganization {
|
||||
return server.loadOrganizationSkillBody(ctx, orgSkillReader, chat.OrganizationID, skill.Name)
|
||||
}
|
||||
return server.loadPersonalSkillBody(ctx, chat.OwnerID, skill.Name)
|
||||
},
|
||||
}
|
||||
appendCurrentSkillTools := func(current []fantasy.AgentTool) ([]fantasy.AgentTool, bool) {
|
||||
if len(personalSkills) == 0 && len(workspaceSkills) == 0 {
|
||||
if len(personalSkills) == 0 && len(orgSkills) == 0 && len(workspaceSkills) == 0 {
|
||||
return current, false
|
||||
}
|
||||
updated := current
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
package chatd_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog/v3/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chattest"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
const readSkillsDirective = "read skills: "
|
||||
|
||||
// skillRecordingOpenAI records the system prompt and tool results of every
|
||||
// streamed call. A user message starting with readSkillsDirective makes the
|
||||
// model call read_skill once per comma-separated name.
|
||||
type skillRecordingOpenAI struct {
|
||||
mu sync.Mutex
|
||||
calls []skillRecordedCall
|
||||
}
|
||||
|
||||
type skillRecordedCall struct {
|
||||
system string
|
||||
toolResults []string
|
||||
}
|
||||
|
||||
func newSkillRecordingOpenAI(t *testing.T) (*skillRecordingOpenAI, string) {
|
||||
t.Helper()
|
||||
recorder := &skillRecordingOpenAI{}
|
||||
url := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
||||
if !req.Stream {
|
||||
return chattest.OpenAINonStreamingResponse("title")
|
||||
}
|
||||
var call skillRecordedCall
|
||||
for _, msg := range req.Messages {
|
||||
switch msg.Role {
|
||||
case "system":
|
||||
call.system += msg.Content + "\n"
|
||||
case "tool":
|
||||
call.toolResults = append(call.toolResults, msg.Content)
|
||||
}
|
||||
}
|
||||
recorder.mu.Lock()
|
||||
recorder.calls = append(recorder.calls, call)
|
||||
recorder.mu.Unlock()
|
||||
|
||||
last := req.Messages[len(req.Messages)-1]
|
||||
names, ok := strings.CutPrefix(last.Content, readSkillsDirective)
|
||||
if last.Role != "user" || !ok {
|
||||
return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("ok")...)
|
||||
}
|
||||
var chunk chattest.OpenAIChunk
|
||||
for i, name := range strings.Split(names, ",") {
|
||||
next := chattest.OpenAIToolCallChunk("read_skill", `{"name":"`+name+`"}`)
|
||||
if i == 0 {
|
||||
chunk = next
|
||||
continue
|
||||
}
|
||||
toolCall := next.Choices[0].ToolCalls[0]
|
||||
toolCall.Index = i
|
||||
chunk.Choices[0].ToolCalls = append(chunk.Choices[0].ToolCalls, toolCall)
|
||||
}
|
||||
return chattest.OpenAIStreamingResponse(chunk)
|
||||
})
|
||||
return recorder, url
|
||||
}
|
||||
|
||||
func (r *skillRecordingOpenAI) recorded() []skillRecordedCall {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]skillRecordedCall(nil), r.calls...)
|
||||
}
|
||||
|
||||
func TestOrganizationSkillsFollowChatOwnerAccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
recorder, openAIURL := newSkillRecordingOpenAI(t)
|
||||
member, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL)
|
||||
outsider := dbgen.User(t, db, database.User{})
|
||||
dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: outsider.ID, OrganizationID: org.ID})
|
||||
team := dbgen.Group(t, db, database.Group{OrganizationID: org.ID})
|
||||
dbgen.GroupMember(t, db, database.GroupMemberTable{UserID: member.ID, GroupID: team.ID})
|
||||
|
||||
everyone := database.ChatACL{org.ID.String(): {Permissions: []policy.Action{policy.ActionRead}}}
|
||||
teamOnly := dbgen.OrganizationSkill(t, db, database.Skill{
|
||||
OrganizationID: uuid.NullUUID{UUID: org.ID, Valid: true},
|
||||
Name: "team-review",
|
||||
Description: "Team review process",
|
||||
Content: "---\nname: team-review\ndescription: Team review process\n---\n\nTeam review instructions.\n",
|
||||
GroupACL: database.ChatACL{team.ID.String(): {Permissions: []policy.Action{policy.ActionRead}}},
|
||||
UserACL: database.ChatACL{},
|
||||
})
|
||||
dbgen.OrganizationSkill(t, db, database.Skill{
|
||||
OrganizationID: uuid.NullUUID{UUID: org.ID, Valid: true},
|
||||
Name: "org-wide",
|
||||
Description: "Org-wide process",
|
||||
GroupACL: everyone,
|
||||
UserACL: database.ChatACL{},
|
||||
})
|
||||
dbgen.OrganizationSkill(t, db, database.Skill{
|
||||
OrganizationID: uuid.NullUUID{UUID: org.ID, Valid: true},
|
||||
Name: "org-disabled",
|
||||
Description: "Disabled org process",
|
||||
GroupACL: everyone,
|
||||
UserACL: database.ChatACL{},
|
||||
})
|
||||
_, err := db.UpdateOrganizationSkillByOrganizationIDAndName(ctx, database.UpdateOrganizationSkillByOrganizationIDAndNameParams{
|
||||
Enabled: sql.NullBool{Bool: false, Valid: true},
|
||||
OrganizationID: org.ID,
|
||||
Name: "org-disabled",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
for _, name := range []string{"personal-on", "personal-off"} {
|
||||
_, err := db.InsertUserSkill(ctx, database.InsertUserSkillParams{
|
||||
ID: uuid.New(),
|
||||
UserID: member.ID,
|
||||
Name: name,
|
||||
Description: "Personal process " + name,
|
||||
Content: "---\nname: " + name + "\ndescription: Personal process " + name + "\n---\n\nPersonal instructions.\n",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
_, err = db.UpdateUserSkillByUserIDAndName(ctx, database.UpdateUserSkillByUserIDAndNameParams{
|
||||
Enabled: sql.NullBool{Bool: false, Valid: true},
|
||||
UserID: member.ID,
|
||||
Name: "personal-off",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// The dbauthz wrapper applies the per-skill ACL in production.
|
||||
authzDB := dbauthz.New(
|
||||
db,
|
||||
rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry()),
|
||||
slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}),
|
||||
coderdtest.AccessControlStorePointer(),
|
||||
)
|
||||
server := newActiveTestServer(t, authzDB, ps, func(cfg *chatd.Config) {
|
||||
cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, openAIURL))
|
||||
})
|
||||
|
||||
runTurn := func(t *testing.T, userID uuid.UUID, chatID uuid.UUID, text string) (uuid.UUID, []skillRecordedCall) {
|
||||
t.Helper()
|
||||
subject, _, err := httpmw.UserRBACSubject(dbauthz.AsSystemRestricted(ctx), db, userID, rbac.ScopeAll)
|
||||
require.NoError(t, err)
|
||||
userCtx := dbauthz.As(ctx, subject)
|
||||
before := len(recorder.recorded())
|
||||
content := []codersdk.ChatMessagePart{codersdk.ChatMessageText(text)}
|
||||
if chatID == uuid.Nil {
|
||||
chat, err := server.CreateChat(userCtx, chatd.CreateOptions{
|
||||
OrganizationID: org.ID,
|
||||
OwnerID: userID,
|
||||
Title: "org-skills",
|
||||
ModelConfigID: model.ID,
|
||||
InitialUserContent: content,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
chatID = chat.ID
|
||||
} else {
|
||||
_, err := server.SendMessage(userCtx, chatd.SendMessageOptions{
|
||||
ChatID: chatID,
|
||||
CreatedBy: userID,
|
||||
Content: content,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
waitForChatProcessed(ctx, t, db, chatID, server)
|
||||
chat, err := db.GetChatByID(ctx, chatID)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, database.ChatStatusError, chat.Status, "last_error=%q", chatLastErrorMessage(chat.LastError))
|
||||
return chatID, recorder.recorded()[before:]
|
||||
}
|
||||
|
||||
// A team member sees enabled personal and org skills, including the
|
||||
// team-only one, and can read it. Disabled skills are not offered.
|
||||
memberChatID, calls := runTurn(t, member.ID, uuid.Nil, readSkillsDirective+"team-review,org-disabled,personal-off")
|
||||
require.Len(t, calls, 2)
|
||||
require.Contains(t, calls[0].system, "- team-review: Team review process\n")
|
||||
require.Contains(t, calls[0].system, "- org-wide: Org-wide process\n")
|
||||
require.Contains(t, calls[0].system, "- personal-on: Personal process personal-on\n")
|
||||
require.NotContains(t, calls[0].system, "org-disabled")
|
||||
require.NotContains(t, calls[0].system, "personal-off")
|
||||
require.Len(t, calls[1].toolResults, 3)
|
||||
require.Contains(t, calls[1].toolResults[0], "Team review instructions.")
|
||||
require.Equal(t, `skill "org-disabled" not found`, calls[1].toolResults[1])
|
||||
require.Equal(t, `skill "personal-off" not found`, calls[1].toolResults[2])
|
||||
|
||||
// An org member outside the team, with no personal or workspace skills,
|
||||
// gets read_skill for the org-wide skill only.
|
||||
_, calls = runTurn(t, outsider.ID, uuid.Nil, readSkillsDirective+"org-wide,team-review")
|
||||
require.Len(t, calls, 2)
|
||||
require.Contains(t, calls[0].system, "- org-wide: Org-wide process\n")
|
||||
require.NotContains(t, calls[0].system, "team-review")
|
||||
require.Len(t, calls[1].toolResults, 2)
|
||||
require.Contains(t, calls[1].toolResults[0], "Fixture instructions.")
|
||||
require.Equal(t, `skill "team-review" not found`, calls[1].toolResults[1])
|
||||
|
||||
// Revoking the team grant takes effect on the member's next step.
|
||||
_, err = db.UpdateOrganizationSkillACLByID(ctx, database.UpdateOrganizationSkillACLByIDParams{
|
||||
ID: teamOnly.ID,
|
||||
GroupACL: database.ChatACL{},
|
||||
UserACL: database.ChatACL{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, calls = runTurn(t, member.ID, memberChatID, readSkillsDirective+"team-review")
|
||||
require.Len(t, calls, 2)
|
||||
require.NotContains(t, calls[0].system, "team-review")
|
||||
require.Contains(t, calls[0].system, "- org-wide: Org-wide process\n")
|
||||
require.Equal(t, `skill "team-review" not found`, calls[1].toolResults[len(calls[1].toolResults)-1])
|
||||
}
|
||||
+21
-18
@@ -1,5 +1,5 @@
|
||||
// Package skills defines the shared model for personal and workspace skills
|
||||
// used by chatd.
|
||||
// Package skills defines the shared model for personal, organization, and
|
||||
// workspace skills used by chatd.
|
||||
//
|
||||
// Glossary:
|
||||
//
|
||||
@@ -10,29 +10,32 @@
|
||||
// currently under .agents/skills by default.
|
||||
// - Skill source: The origin of a skill available to chatd, such as personal
|
||||
// storage or workspace filesystem discovery.
|
||||
// - Organization skill: An organization-owned skill stored by Coder and
|
||||
// shared with organization members through a per-skill ACL.
|
||||
// - Skill alias: A chat or tool lookup name for a skill. Bare aliases use the
|
||||
// skill name. Qualified aliases use personal/<name> or workspace/<name>.
|
||||
// skill name. Qualified aliases use personal/<name>, org/<name>, or
|
||||
// workspace/<name>.
|
||||
//
|
||||
// Decision:
|
||||
//
|
||||
// Personal skills are stored by Coder. For each chat turn, chatd fetches
|
||||
// personal skill metadata fresh, combines it with workspace skill metadata, and
|
||||
// injects the available skills into the existing skill prompt.
|
||||
// When chatd needs skill content, it resolves personal skills through the
|
||||
// Personal and organization skills are stored by Coder. For each chat turn,
|
||||
// chatd fetches their metadata fresh, combines it with workspace skill
|
||||
// metadata, and injects the available skills into the existing skill prompt.
|
||||
// When chatd needs skill content, it resolves stored skills through the
|
||||
// read_skill flow instead of syncing files into workspace filesystems.
|
||||
//
|
||||
// If a personal skill and workspace skill share the same kebab-case name, both
|
||||
// are exposed with qualified aliases: personal/<name> for the personal skill
|
||||
// and workspace/<name> for the workspace skill. One source must not silently
|
||||
// override the other.
|
||||
// If skills from more than one source share the same kebab-case name, each is
|
||||
// exposed with a qualified alias: personal/<name>, org/<name>, or
|
||||
// workspace/<name>. One source must not silently override another.
|
||||
//
|
||||
// Site admins can read and delete personal skill content. Personal skills are
|
||||
// user-authored instructions, not secret material. Audit records can include
|
||||
// raw Markdown content diffs alongside the actor, target user, and relevant
|
||||
// metadata.
|
||||
//
|
||||
// Personal skill edits affect the next chat turn. Old chat turns are not exact
|
||||
// snapshots of the personal skill state that existed when they ran.
|
||||
// Personal and organization skill edits affect the next chat turn. Old chat
|
||||
// turns are not exact snapshots of the stored skill state that existed when
|
||||
// they ran.
|
||||
//
|
||||
// The v1 design does not include CLI support, web UI support, supporting files,
|
||||
// organization-scoped personal skills, syncing personal skills into workspace
|
||||
@@ -40,11 +43,11 @@
|
||||
//
|
||||
// Consequences:
|
||||
//
|
||||
// Chatd can use personal and workspace skills through one prompt and one read
|
||||
// path, while storage remains owned by Coder instead of individual workspace
|
||||
// filesystems. Fresh metadata keeps skill changes responsive, but chat history
|
||||
// is less reproducible because old turns do not capture an exact copy of
|
||||
// personal skill content.
|
||||
// Chatd can use personal, organization, and workspace skills through one
|
||||
// prompt and one read path, while storage remains owned by Coder instead of
|
||||
// individual workspace filesystems. Fresh metadata keeps skill changes
|
||||
// responsive, but chat history is less reproducible because old turns do not
|
||||
// capture an exact copy of stored skill content.
|
||||
//
|
||||
// Explicit qualified aliases make ambiguous names visible to users and tools.
|
||||
// Admin access improves operability and abuse handling, but it creates a
|
||||
|
||||
+48
-53
@@ -32,6 +32,8 @@ type Source string
|
||||
const (
|
||||
// SourcePersonal identifies a user-owned, DB-backed skill.
|
||||
SourcePersonal Source = "personal"
|
||||
// SourceOrganization identifies an organization-owned, DB-backed skill.
|
||||
SourceOrganization Source = "org"
|
||||
// SourceWorkspace identifies a filesystem-discovered workspace skill.
|
||||
SourceWorkspace Source = "workspace"
|
||||
)
|
||||
@@ -81,52 +83,52 @@ type ResolvedSkill struct {
|
||||
func ParsePersonalSkillMarkdown(raw []byte) (ParsedSkill, error) {
|
||||
if len(raw) > MaxPersonalSkillSizeBytes {
|
||||
return ParsedSkill{}, xerrors.Errorf(
|
||||
"%w: got %d bytes, maximum is %d bytes",
|
||||
ErrSkillTooLarge,
|
||||
"got %d bytes, maximum is %d bytes: %w",
|
||||
len(raw),
|
||||
MaxPersonalSkillSizeBytes,
|
||||
ErrSkillTooLarge,
|
||||
)
|
||||
}
|
||||
|
||||
name, description, body, err := workspacesdk.ParseSkillFrontmatter(string(raw))
|
||||
if err != nil {
|
||||
if xerrors.Is(err, workspacesdk.ErrFrontmatterNameRequired) {
|
||||
return ParsedSkill{}, xerrors.Errorf("%w: frontmatter must contain a 'name' field", ErrInvalidSkillName)
|
||||
return ParsedSkill{}, xerrors.Errorf("frontmatter must contain a 'name' field: %w", ErrInvalidSkillName)
|
||||
}
|
||||
return ParsedSkill{}, xerrors.Errorf("parse skill frontmatter: %w", err)
|
||||
}
|
||||
if !workspacesdk.SkillNamePattern.MatchString(name) {
|
||||
return ParsedSkill{}, xerrors.Errorf(
|
||||
"%w: %q must match %s",
|
||||
ErrInvalidSkillName,
|
||||
"%q must match %s: %w",
|
||||
name,
|
||||
workspacesdk.SkillNameRegex,
|
||||
ErrInvalidSkillName,
|
||||
)
|
||||
}
|
||||
nameBytes := len(name)
|
||||
if nameBytes > MaxPersonalSkillNameBytes {
|
||||
return ParsedSkill{}, xerrors.Errorf(
|
||||
"%w: %q is %d bytes, maximum is %d bytes",
|
||||
ErrInvalidSkillName,
|
||||
"%q is %d bytes, maximum is %d bytes: %w",
|
||||
name,
|
||||
nameBytes,
|
||||
MaxPersonalSkillNameBytes,
|
||||
ErrInvalidSkillName,
|
||||
)
|
||||
}
|
||||
descriptionBytes := len(description)
|
||||
if descriptionBytes > MaxPersonalSkillDescriptionBytes {
|
||||
return ParsedSkill{}, xerrors.Errorf(
|
||||
"%w: got %d bytes, maximum is %d bytes",
|
||||
ErrSkillDescriptionTooLarge,
|
||||
"got %d bytes, maximum is %d bytes: %w",
|
||||
descriptionBytes,
|
||||
MaxPersonalSkillDescriptionBytes,
|
||||
ErrSkillDescriptionTooLarge,
|
||||
)
|
||||
}
|
||||
if strings.TrimSpace(body) == "" {
|
||||
return ParsedSkill{}, xerrors.Errorf(
|
||||
"%w: skill %q has no content after frontmatter",
|
||||
ErrSkillBodyRequired,
|
||||
"skill %q has no content after frontmatter: %w",
|
||||
name,
|
||||
ErrSkillBodyRequired,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -140,50 +142,43 @@ func ParsePersonalSkillMarkdown(raw []byte) (ParsedSkill, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MergeSkills combines personal and workspace skills into a deterministic list
|
||||
// with aliases for chat tool display and lookup. Skill names must already be
|
||||
// valid kebab-case names because qualified aliases use / as a separator. If a
|
||||
// source contains duplicate names, the first skill for that source wins.
|
||||
func MergeSkills(personalSkills, workspaceSkills []Skill) []ResolvedSkill {
|
||||
personalByName := skillsByName(personalSkills, SourcePersonal)
|
||||
workspaceByName := skillsByName(workspaceSkills, SourceWorkspace)
|
||||
|
||||
names := make(map[string]struct{}, len(personalByName)+len(workspaceByName))
|
||||
for name := range personalByName {
|
||||
names[name] = struct{}{}
|
||||
}
|
||||
for name := range workspaceByName {
|
||||
names[name] = struct{}{}
|
||||
// MergeSkills combines personal, organization, and workspace skills into a
|
||||
// deterministic list with aliases for chat tool display and lookup. Skills are
|
||||
// sorted by name, then by source in that order. A name found in more than one
|
||||
// source gets a qualified alias in every source that has it, so no source
|
||||
// overrides another; a name found in one source keeps its bare alias. Skill
|
||||
// names must already be valid kebab-case names because qualified aliases use /
|
||||
// as a separator. If a source contains duplicate names, the first skill for
|
||||
// that source wins.
|
||||
func MergeSkills(personalSkills, organizationSkills, workspaceSkills []Skill) []ResolvedSkill {
|
||||
sources := []map[string]Skill{
|
||||
skillsByName(personalSkills, SourcePersonal),
|
||||
skillsByName(organizationSkills, SourceOrganization),
|
||||
skillsByName(workspaceSkills, SourceWorkspace),
|
||||
}
|
||||
|
||||
resolved := make([]ResolvedSkill, 0, len(personalByName)+len(workspaceByName))
|
||||
names := make(map[string]struct{})
|
||||
for _, byName := range sources {
|
||||
for name := range byName {
|
||||
names[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
resolved := make([]ResolvedSkill, 0, len(names))
|
||||
for _, name := range slices.Sorted(maps.Keys(names)) {
|
||||
personal, hasPersonal := personalByName[name]
|
||||
workspace, hasWorkspace := workspaceByName[name]
|
||||
if hasPersonal && hasWorkspace {
|
||||
resolved = append(resolved,
|
||||
ResolvedSkill{
|
||||
Skill: personal,
|
||||
Alias: QualifiedAlias(SourcePersonal, name),
|
||||
},
|
||||
ResolvedSkill{
|
||||
Skill: workspace,
|
||||
Alias: QualifiedAlias(SourceWorkspace, name),
|
||||
},
|
||||
)
|
||||
continue
|
||||
var matches []Skill
|
||||
for _, byName := range sources {
|
||||
if skill, ok := byName[name]; ok {
|
||||
matches = append(matches, skill)
|
||||
}
|
||||
}
|
||||
if hasPersonal {
|
||||
resolved = append(resolved, ResolvedSkill{
|
||||
Skill: personal,
|
||||
Alias: name,
|
||||
})
|
||||
continue
|
||||
for _, skill := range matches {
|
||||
alias := name
|
||||
if len(matches) > 1 {
|
||||
alias = QualifiedAlias(skill.Source, name)
|
||||
}
|
||||
resolved = append(resolved, ResolvedSkill{Skill: skill, Alias: alias})
|
||||
}
|
||||
resolved = append(resolved, ResolvedSkill{
|
||||
Skill: workspace,
|
||||
Alias: name,
|
||||
})
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
@@ -208,15 +203,15 @@ func Lookup(resolved []ResolvedSkill, lookup string) (ResolvedSkill, error) {
|
||||
}
|
||||
switch len(matches) {
|
||||
case 0:
|
||||
return ResolvedSkill{}, xerrors.Errorf("%w: %q", ErrSkillNotFound, lookup)
|
||||
return ResolvedSkill{}, xerrors.Errorf("%q: %w", lookup, ErrSkillNotFound)
|
||||
case 1:
|
||||
return bareNameMatch, nil
|
||||
default:
|
||||
return ResolvedSkill{}, xerrors.Errorf(
|
||||
"%w: %q matches %s",
|
||||
ErrSkillAmbiguous,
|
||||
"%q matches %s: %w",
|
||||
lookup,
|
||||
strings.Join(matches, ", "),
|
||||
ErrSkillAmbiguous,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+123
-187
@@ -169,208 +169,144 @@ func personalSkillMarkdownForTest(name string, description string, body string)
|
||||
func TestMergeSkills(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("PersonalOnlyUsesBareAlias", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
resolved := func(alias string, source skills.Source, name, description string) skills.ResolvedSkill {
|
||||
return skills.ResolvedSkill{
|
||||
Skill: skills.Skill{Name: name, Description: description, Source: source},
|
||||
Alias: alias,
|
||||
}
|
||||
}
|
||||
shared := []skills.Skill{{Name: "shared", Description: "Shared"}}
|
||||
|
||||
resolved := skills.MergeSkills(
|
||||
[]skills.Skill{{Name: "my-skill", Description: "Mine"}},
|
||||
nil,
|
||||
)
|
||||
|
||||
require.Equal(t, []skills.ResolvedSkill{{
|
||||
Skill: skills.Skill{
|
||||
Name: "my-skill",
|
||||
Description: "Mine",
|
||||
Source: skills.SourcePersonal,
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
personal, organization, workspace []skills.Skill
|
||||
want []skills.ResolvedSkill
|
||||
}{
|
||||
{
|
||||
name: "NonCollidingSkillsUseBareAliasesSortedByName",
|
||||
personal: []skills.Skill{{Name: "personal-skill"}},
|
||||
organization: []skills.Skill{{Name: "org-skill"}},
|
||||
workspace: []skills.Skill{{Name: "workspace-skill"}},
|
||||
want: []skills.ResolvedSkill{
|
||||
resolved("org-skill", skills.SourceOrganization, "org-skill", ""),
|
||||
resolved("personal-skill", skills.SourcePersonal, "personal-skill", ""),
|
||||
resolved("workspace-skill", skills.SourceWorkspace, "workspace-skill", ""),
|
||||
},
|
||||
Alias: "my-skill",
|
||||
}}, resolved)
|
||||
})
|
||||
|
||||
t.Run("WorkspaceOnlyUsesBareAlias", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resolved := skills.MergeSkills(
|
||||
nil,
|
||||
[]skills.Skill{{Name: "my-skill", Description: "Workspace"}},
|
||||
)
|
||||
|
||||
require.Equal(t, []skills.ResolvedSkill{{
|
||||
Skill: skills.Skill{
|
||||
Name: "my-skill",
|
||||
Description: "Workspace",
|
||||
Source: skills.SourceWorkspace,
|
||||
},
|
||||
{
|
||||
name: "PersonalAndWorkspaceCollide",
|
||||
personal: shared,
|
||||
workspace: shared,
|
||||
want: []skills.ResolvedSkill{
|
||||
resolved("personal/shared", skills.SourcePersonal, "shared", "Shared"),
|
||||
resolved("workspace/shared", skills.SourceWorkspace, "shared", "Shared"),
|
||||
},
|
||||
Alias: "my-skill",
|
||||
}}, resolved)
|
||||
})
|
||||
|
||||
t.Run("NonCollidingSkillsUseBareAliases", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resolved := skills.MergeSkills(
|
||||
[]skills.Skill{{Name: "personal-skill"}},
|
||||
[]skills.Skill{{Name: "workspace-skill"}},
|
||||
)
|
||||
|
||||
require.Equal(t, []skills.ResolvedSkill{
|
||||
{
|
||||
Skill: skills.Skill{
|
||||
Name: "personal-skill",
|
||||
Source: skills.SourcePersonal,
|
||||
},
|
||||
Alias: "personal-skill",
|
||||
},
|
||||
{
|
||||
name: "PersonalAndOrganizationCollide",
|
||||
personal: shared,
|
||||
organization: shared,
|
||||
want: []skills.ResolvedSkill{
|
||||
resolved("personal/shared", skills.SourcePersonal, "shared", "Shared"),
|
||||
resolved("org/shared", skills.SourceOrganization, "shared", "Shared"),
|
||||
},
|
||||
{
|
||||
Skill: skills.Skill{
|
||||
Name: "workspace-skill",
|
||||
Source: skills.SourceWorkspace,
|
||||
},
|
||||
Alias: "workspace-skill",
|
||||
},
|
||||
{
|
||||
name: "OrganizationAndWorkspaceCollide",
|
||||
organization: shared,
|
||||
workspace: shared,
|
||||
want: []skills.ResolvedSkill{
|
||||
resolved("org/shared", skills.SourceOrganization, "shared", "Shared"),
|
||||
resolved("workspace/shared", skills.SourceWorkspace, "shared", "Shared"),
|
||||
},
|
||||
}, resolved)
|
||||
})
|
||||
|
||||
t.Run("CollidingSkillsUseQualifiedAliases", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resolved := skills.MergeSkills(
|
||||
[]skills.Skill{{Name: "shared-skill", Description: "Mine"}},
|
||||
[]skills.Skill{{Name: "shared-skill", Description: "Workspace"}},
|
||||
)
|
||||
|
||||
require.Equal(t, []skills.ResolvedSkill{
|
||||
{
|
||||
Skill: skills.Skill{
|
||||
Name: "shared-skill",
|
||||
Description: "Mine",
|
||||
Source: skills.SourcePersonal,
|
||||
},
|
||||
Alias: "personal/shared-skill",
|
||||
},
|
||||
{
|
||||
name: "AllSourcesCollide",
|
||||
personal: shared,
|
||||
organization: shared,
|
||||
workspace: shared,
|
||||
want: []skills.ResolvedSkill{
|
||||
resolved("personal/shared", skills.SourcePersonal, "shared", "Shared"),
|
||||
resolved("org/shared", skills.SourceOrganization, "shared", "Shared"),
|
||||
resolved("workspace/shared", skills.SourceWorkspace, "shared", "Shared"),
|
||||
},
|
||||
{
|
||||
Skill: skills.Skill{
|
||||
Name: "shared-skill",
|
||||
Description: "Workspace",
|
||||
Source: skills.SourceWorkspace,
|
||||
},
|
||||
Alias: "workspace/shared-skill",
|
||||
},
|
||||
}, resolved)
|
||||
|
||||
personal, err := skills.Lookup(resolved, "personal/shared-skill")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, skills.SourcePersonal, personal.Source)
|
||||
require.Equal(t, "shared-skill", personal.Name)
|
||||
|
||||
workspace, err := skills.Lookup(resolved, "workspace/shared-skill")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, skills.SourceWorkspace, workspace.Source)
|
||||
require.Equal(t, "shared-skill", workspace.Name)
|
||||
|
||||
_, err = skills.Lookup(resolved, "shared-skill")
|
||||
require.ErrorIs(t, err, skills.ErrSkillAmbiguous)
|
||||
require.ErrorContains(t, err, "personal/shared-skill")
|
||||
require.ErrorContains(t, err, "workspace/shared-skill")
|
||||
})
|
||||
|
||||
t.Run("DuplicatesWithinSourceKeepFirst", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resolved := skills.MergeSkills(
|
||||
[]skills.Skill{
|
||||
},
|
||||
{
|
||||
name: "DuplicatesWithinSourceKeepFirst",
|
||||
personal: []skills.Skill{
|
||||
{Name: "duplicate-skill", Description: "First"},
|
||||
{Name: "duplicate-skill", Description: "Second"},
|
||||
},
|
||||
[]skills.Skill{
|
||||
{Name: "workspace-skill", Description: "Workspace"},
|
||||
{Name: "workspace-skill", Description: "Workspace duplicate"},
|
||||
want: []skills.ResolvedSkill{
|
||||
resolved("duplicate-skill", skills.SourcePersonal, "duplicate-skill", "First"),
|
||||
},
|
||||
)
|
||||
|
||||
require.Equal(t, []skills.ResolvedSkill{
|
||||
{
|
||||
Skill: skills.Skill{
|
||||
Name: "duplicate-skill",
|
||||
Description: "First",
|
||||
Source: skills.SourcePersonal,
|
||||
},
|
||||
Alias: "duplicate-skill",
|
||||
},
|
||||
{
|
||||
Skill: skills.Skill{
|
||||
Name: "workspace-skill",
|
||||
Description: "Workspace",
|
||||
Source: skills.SourceWorkspace,
|
||||
},
|
||||
Alias: "workspace-skill",
|
||||
},
|
||||
}, resolved)
|
||||
})
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, tc.want, skills.MergeSkills(tc.personal, tc.organization, tc.workspace))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookup(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("BareNameOnNonCollidingSkill", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
shared := []skills.Skill{{Name: "shared"}}
|
||||
merged := skills.MergeSkills(
|
||||
append([]skills.Skill{{Name: "personal-skill"}}, shared...),
|
||||
append([]skills.Skill{{Name: "org-skill"}}, shared...),
|
||||
[]skills.Skill{{Name: "workspace-skill"}},
|
||||
)
|
||||
|
||||
resolved := skills.MergeSkills(
|
||||
[]skills.Skill{{Name: "personal-skill"}},
|
||||
[]skills.Skill{{Name: "workspace-skill"}},
|
||||
)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
resolved []skills.ResolvedSkill
|
||||
lookup string
|
||||
wantSource skills.Source
|
||||
wantName string
|
||||
wantErr error
|
||||
wantErrContains []string
|
||||
}{
|
||||
{name: "BareNameWithoutCollision", resolved: merged, lookup: "workspace-skill", wantSource: skills.SourceWorkspace, wantName: "workspace-skill"},
|
||||
{name: "QualifiedPersonalWithoutCollision", resolved: merged, lookup: "personal/personal-skill", wantSource: skills.SourcePersonal, wantName: "personal-skill"},
|
||||
{name: "QualifiedOrganizationWithoutCollision", resolved: merged, lookup: "org/org-skill", wantSource: skills.SourceOrganization, wantName: "org-skill"},
|
||||
{name: "QualifiedWorkspaceWithoutCollision", resolved: merged, lookup: "workspace/workspace-skill", wantSource: skills.SourceWorkspace, wantName: "workspace-skill"},
|
||||
{name: "QualifiedOrganizationOnCollision", resolved: merged, lookup: "org/shared", wantSource: skills.SourceOrganization, wantName: "shared"},
|
||||
{
|
||||
name: "BareNameOnCollisionIsAmbiguous",
|
||||
resolved: merged,
|
||||
lookup: "shared",
|
||||
wantErr: skills.ErrSkillAmbiguous,
|
||||
wantErrContains: []string{"personal/shared", "org/shared"},
|
||||
},
|
||||
{
|
||||
name: "BareNameFallsBackToSingleQualifiedAliasMatch",
|
||||
resolved: []skills.ResolvedSkill{{
|
||||
Skill: skills.Skill{Name: "personal-skill", Source: skills.SourcePersonal},
|
||||
Alias: "personal/personal-skill",
|
||||
}},
|
||||
lookup: "personal-skill",
|
||||
wantSource: skills.SourcePersonal,
|
||||
wantName: "personal-skill",
|
||||
},
|
||||
{name: "UnknownLookupReturnsNotFound", lookup: "missing-skill", wantErr: skills.ErrSkillNotFound, wantErrContains: []string{"missing-skill"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
personal, err := skills.Lookup(resolved, "personal-skill")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, skills.SourcePersonal, personal.Source)
|
||||
require.Equal(t, "personal-skill", personal.Name)
|
||||
|
||||
workspace, err := skills.Lookup(resolved, "workspace-skill")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, skills.SourceWorkspace, workspace.Source)
|
||||
require.Equal(t, "workspace-skill", workspace.Name)
|
||||
})
|
||||
|
||||
t.Run("QualifiedAliasWorksWithoutCollision", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resolved := skills.MergeSkills(
|
||||
[]skills.Skill{{Name: "personal-skill"}},
|
||||
[]skills.Skill{{Name: "workspace-skill"}},
|
||||
)
|
||||
|
||||
personal, err := skills.Lookup(resolved, "personal/personal-skill")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, skills.SourcePersonal, personal.Source)
|
||||
require.Equal(t, "personal-skill", personal.Name)
|
||||
|
||||
workspace, err := skills.Lookup(resolved, "workspace/workspace-skill")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, skills.SourceWorkspace, workspace.Source)
|
||||
require.Equal(t, "workspace-skill", workspace.Name)
|
||||
})
|
||||
|
||||
t.Run("BareNameFallsBackToSingleQualifiedAliasMatch", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resolved := []skills.ResolvedSkill{{
|
||||
Skill: skills.Skill{Name: "personal-skill", Source: skills.SourcePersonal},
|
||||
Alias: "personal/personal-skill",
|
||||
}}
|
||||
|
||||
personal, err := skills.Lookup(resolved, "personal-skill")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, skills.SourcePersonal, personal.Source)
|
||||
require.Equal(t, "personal-skill", personal.Name)
|
||||
})
|
||||
|
||||
t.Run("UnknownLookupReturnsNotFound", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := skills.Lookup(nil, "missing-skill")
|
||||
|
||||
require.ErrorIs(t, err, skills.ErrSkillNotFound)
|
||||
require.ErrorContains(t, err, "missing-skill")
|
||||
})
|
||||
got, err := skills.Lookup(tc.resolved, tc.lookup)
|
||||
if tc.wantErr != nil {
|
||||
require.ErrorIs(t, err, tc.wantErr)
|
||||
require.Equal(t, 1, strings.Count(err.Error(), tc.wantErr.Error()))
|
||||
for _, want := range tc.wantErrContains {
|
||||
require.ErrorContains(t, err, want)
|
||||
}
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.wantSource, got.Source)
|
||||
require.Equal(t, tc.wantName, got.Name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,9 +65,14 @@ Excluded and oversize resources still appear in the chat's context list with the
|
||||
|
||||
## Skills
|
||||
|
||||
Skills are structured, reusable instruction sets that the agent loads on
|
||||
demand. They live in the workspace filesystem and are discovered
|
||||
automatically when a chat attaches to a workspace.
|
||||
Skills are structured, reusable instruction sets that the agent loads on demand.
|
||||
A chat can use skills from three sources:
|
||||
|
||||
- Workspace skills live in the workspace filesystem and are discovered automatically when a chat attaches to a workspace.
|
||||
- [Personal skills](#personal-skills) belong to you and are available to all of your chats.
|
||||
- [Organization skills](#organization-skills) belong to an organization and are available to the chats in that organization.
|
||||
|
||||
The rest of this section describes workspace skills.
|
||||
|
||||
### How skills work
|
||||
|
||||
@@ -152,9 +157,9 @@ directory.
|
||||
|
||||
## Personal skills
|
||||
|
||||
Personal skills are user-owned skills that are available to all of your
|
||||
chats. They are not tied to a specific workspace. Manage them from the
|
||||
**Agents** page, under **Settings** > **Personal Skills**.
|
||||
Personal skills are user-owned skills that are available to all of your chats.
|
||||
They are not tied to a specific workspace.
|
||||
Manage them from the **Agents** page, under **Settings** > **Skills**, in the **Personal skills** section.
|
||||
|
||||
Personal skills use the same `SKILL.md` format as workspace skills: YAML
|
||||
frontmatter with a kebab-case `name`, an optional `description`, and a
|
||||
@@ -182,6 +187,109 @@ If you need richer skills with supporting files or multiple files, use
|
||||
workspace skills instead. Store them in the repo under
|
||||
`.agents/skills/<name>/`, or load them from a workspace.
|
||||
|
||||
## Organization skills
|
||||
|
||||
Organization skills let administrators publish a skill once for an organization instead of copying it into every workspace.
|
||||
Chats in that organization can use the organization's skills next to personal and workspace skills.
|
||||
Chats in other organizations never receive them.
|
||||
|
||||
Organization skills use the same single-file `SKILL.md` format and the same 64 KB limit as personal skills, so they cannot include supporting files.
|
||||
Each organization can have up to 100 skills, and skills that are turned off count toward that limit.
|
||||
|
||||
### Manage organization skills
|
||||
|
||||
Manage organization skills from **Admin settings** > **AI** > **Coder Agents** > **Skills**.
|
||||
If you can view the skills of more than one organization, choose the organization in the organization picker.
|
||||
The page address is `/ai/settings/skills?org=<organization-name>`.
|
||||
|
||||
| Action | Built-in roles that can do it |
|
||||
|--------------------------------------------------------------------|--------------------------------------------|
|
||||
| Add, edit, delete, turn on or off, manage permissions | Site owners and organization admins |
|
||||
| View, download, and export the skills and their `SKILL.md` content | Site owners, organization admins, auditors |
|
||||
|
||||
A [custom role](../../admin/users/groups-roles.md#custom-roles) can grant the same actions through `organization_skill` permissions.
|
||||
Auditors get a read-only view of the page.
|
||||
Coder records organization skill changes, including permission changes, in the audit log.
|
||||
|
||||
### Control who can use an organization skill
|
||||
|
||||
Each organization skill has a list of the groups and members who can use it.
|
||||
A new skill is shared with the organization's **Everyone** group, so every member can use it until an administrator narrows the list.
|
||||
|
||||
To change who can use a skill:
|
||||
|
||||
1. Open the skill's row menu.
|
||||
1. Select **Manage permissions**.
|
||||
1. In the **Skill permissions** dialog, add or remove organization members and groups.
|
||||
|
||||
Each entry grants the **Use** role.
|
||||
Only current members of the organization can use a shared skill.
|
||||
Removing someone from the organization also removes them from the list of every skill, so adding them back later doesn't restore those entries.
|
||||
Site owners, organization admins, and auditors can read every skill in the organization, so they can use every organization skill that's turned on, whether or not the list includes them.
|
||||
Without a Premium license, you can't create [groups](../../admin/users/groups-roles.md#groups), so the only group is **Everyone**.
|
||||
In that case, share a skill either with everyone or with individual members.
|
||||
|
||||
The organization skill API lives under `/api/experimental/organizations/{organization}/skills`.
|
||||
It is experimental and might change.
|
||||
|
||||
### Find the organization skills you can use
|
||||
|
||||
To check which organization skills you can use, open the **Agents** page and go to **Settings** > **Skills**.
|
||||
The **From your organizations** section shows one organization at a time and lists that organization's skills that are turned on and that you can use.
|
||||
For site owners, organization admins, and auditors, it lists every organization skill that is turned on.
|
||||
If more than one of your organizations has such skills, choose an organization in the section's organization picker.
|
||||
The section starts on your default organization when that organization has such skills.
|
||||
An organization with no such skills doesn't appear in the picker, and the section doesn't appear when none of your organizations has any.
|
||||
|
||||
To read a skill, open its row menu and select **View**, which shows its name, description, and body in a read-only dialog.
|
||||
To save its `SKILL.md` content as a file, select **Download** instead.
|
||||
To save all of the listed skills of the selected organization in one archive, select **Export all**.
|
||||
|
||||
This section is read-only.
|
||||
Ask an organization admin to change an organization skill.
|
||||
|
||||
## How chats load stored skills
|
||||
|
||||
Personal and organization skills are stored in Coder, not in the workspace.
|
||||
At each step, a chat loads the personal skills of the chat owner that are turned on, and the organization skills of the chat's organization that are turned on and that the chat owner can use.
|
||||
Workspace skills come from the chat's pinned context snapshot.
|
||||
|
||||
Because stored skills are read again at each step, a change takes effect on the chat's next step.
|
||||
For example, when an administrator turns off an organization skill or removes someone's access, the chat stops listing that skill on its next step.
|
||||
|
||||
The `/` menu in the chat composer lists skills in three groups: **Personal skills**, **Organization skills**, and **Workspace skills**.
|
||||
The **Organization skills** group shows the skills of the chat's organization that are turned on and that you can use.
|
||||
For site owners, organization admins, and auditors, the group lists every organization skill that is turned on.
|
||||
|
||||
### Turn a skill on or off
|
||||
|
||||
Personal and organization skills each have an **Enabled** column in their skills table.
|
||||
If you can edit the skill, the column shows a switch that turns the skill on or off.
|
||||
Otherwise, the column shows a check mark for a skill that is turned on and an X for a skill that is turned off.
|
||||
For example, auditors on the **Skills** admin page and everyone in the **From your organizations** section see these icons instead of a switch.
|
||||
A skill that is turned off stays stored, but it is hidden from the `/` menu and from the list of skills the agent can choose from.
|
||||
If the agent asks for a skill that is turned off, `read_skill` reports that the skill was not found.
|
||||
When a chat has no skills left to load, the agent doesn't get the `read_skill` tool at all.
|
||||
|
||||
### Skills with the same name
|
||||
|
||||
The same skill name can exist in more than one source.
|
||||
No source overrides another.
|
||||
Instead, each copy gets a qualified alias that names its source:
|
||||
|
||||
| Source | Qualified alias |
|
||||
|--------------|--------------------|
|
||||
| Personal | `personal/<name>` |
|
||||
| Organization | `org/<name>` |
|
||||
| Workspace | `workspace/<name>` |
|
||||
|
||||
A bare name that matches more than one source is ambiguous, so `read_skill` rejects it.
|
||||
The agent must pass the qualified alias instead, for example `org/code-review`.
|
||||
|
||||
The list of available skills in the agent's system prompt shortens a description longer than 1,024 characters to its first 1,023 characters followed by an ellipsis.
|
||||
The stored description keeps the full text.
|
||||
The `read_skill` result returns the skill body without the frontmatter, so it doesn't include the description.
|
||||
|
||||
## Workspace MCP tools
|
||||
|
||||
Workspace templates can expose custom
|
||||
|
||||
@@ -22,6 +22,8 @@ Running more than 1 organization requires a [Premium license](../../../admin/use
|
||||
| Personal model overrides | User, per organization | **Agents** > **Settings** > **Agents** |
|
||||
| Personal override toggle | Deployment | **Admin settings** > **AI** > **Coder Agents** > **Deployment settings** |
|
||||
| MCP servers | Organization | **Admin settings** > **AI** > **Coder Agents** > **MCP servers** |
|
||||
| Organization skills | Organization | **Admin settings** > **AI** > **Coder Agents** > **Skills** |
|
||||
| Personal skills | User | **Agents** > **Settings** > **Skills** |
|
||||
| System prompt | Deployment | **Admin settings** > **AI** > **Coder Agents** > **Instructions** |
|
||||
| Organization instructions | Organization | **Admin settings** > **AI** > **Coder Agents** > **Instructions** |
|
||||
| Plan mode instructions | Deployment | **Admin settings** > **AI** > **Coder Agents** > **Instructions** |
|
||||
|
||||
@@ -2894,6 +2894,7 @@ export const SlashCompactCommandSubmits: Story = {
|
||||
},
|
||||
beforeEach: () => {
|
||||
spyOn(API.experimental, "getUserSkills").mockResolvedValue([]);
|
||||
spyOn(API.experimental, "getOrganizationSkills").mockResolvedValue([]);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
@@ -2942,6 +2943,7 @@ export const SlashClearCommandSubmits: Story = {
|
||||
},
|
||||
beforeEach: () => {
|
||||
spyOn(API.experimental, "getUserSkills").mockResolvedValue([]);
|
||||
spyOn(API.experimental, "getOrganizationSkills").mockResolvedValue([]);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
@@ -2991,6 +2993,7 @@ export const SlashClearCommandConflictShowsError: Story = {
|
||||
},
|
||||
beforeEach: () => {
|
||||
spyOn(API.experimental, "getUserSkills").mockResolvedValue([]);
|
||||
spyOn(API.experimental, "getOrganizationSkills").mockResolvedValue([]);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
@@ -3055,6 +3058,7 @@ export const SlashCompactYieldsToPersonalSkill: Story = {
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
]);
|
||||
spyOn(API.experimental, "getOrganizationSkills").mockResolvedValue([]);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
userChatDebugLogging,
|
||||
} from "#/api/queries/chats";
|
||||
import { deploymentSSHConfig } from "#/api/queries/deployment";
|
||||
import { organizationSkills } from "#/api/queries/organizationSkills";
|
||||
import { userSkills } from "#/api/queries/userSkills";
|
||||
import { preferenceSettings } from "#/api/queries/users";
|
||||
import { workspaceById, workspaceByIdKey } from "#/api/queries/workspaces";
|
||||
@@ -320,6 +321,11 @@ const AgentChatPage: React.FC = () => {
|
||||
...userSkills(),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const organizationSkillsQuery = useQuery({
|
||||
...organizationSkills(chatOrganizationId),
|
||||
enabled: Boolean(chatOrganizationId),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const chatWorkspaceSkills = workspaceSkillsFromChat(chatQuery.data);
|
||||
const { mutateAsync: deleteQueuedMessage } = useMutation(
|
||||
deleteChatQueuedMessage(queryClient, agentId),
|
||||
@@ -625,9 +631,12 @@ const AgentChatPage: React.FC = () => {
|
||||
isSubmissionPending,
|
||||
hasModelOptions,
|
||||
isEditReasoningEffortDirtyRef,
|
||||
personalSkills: personalSkillsQuery.isSuccess
|
||||
? personalSkillsQuery.data
|
||||
: undefined,
|
||||
// chatd skips disabled skills, so they never shadow a built-in
|
||||
// command. A list with no data stays unknown, even after an error.
|
||||
personalSkills: personalSkillsQuery.data?.filter((skill) => skill.enabled),
|
||||
organizationSkills: organizationSkillsQuery.data?.filter(
|
||||
(skill) => skill.enabled,
|
||||
),
|
||||
workspaceSkills: chatWorkspaceSkills,
|
||||
compact,
|
||||
clearChatContext,
|
||||
|
||||
@@ -9,10 +9,14 @@ import userEvent from "@testing-library/user-event";
|
||||
import { createRef } from "react";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { AppProviders } from "#/App";
|
||||
import { organizationSkills } from "#/api/queries/organizationSkills";
|
||||
import { userSkills } from "#/api/queries/userSkills";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { toast } from "#/components/Toaster/toast";
|
||||
import { MockMCPServerConfig } from "#/testHelpers/chatEntities";
|
||||
import { createMockFile } from "#/testHelpers/files";
|
||||
import { createTestQueryClient } from "#/testHelpers/renderHelpers";
|
||||
import { MockSkill } from "#/testHelpers/skills";
|
||||
import { mobileViewportMediaQuery } from "#/utils/mobile";
|
||||
import type * as speechRecognition from "../hooks/useSpeechRecognition";
|
||||
import { useSpeechRecognition } from "../hooks/useSpeechRecognition";
|
||||
@@ -182,6 +186,33 @@ describe("AgentChatInput", () => {
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers the chat organization's skills in the slash menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
const inputRef = createRef<ChatMessageInputRef>();
|
||||
const queryClient = createTestQueryClient();
|
||||
queryClient.setQueryData(userSkills().queryKey, []);
|
||||
queryClient.setQueryData(organizationSkills("org-1").queryKey, [
|
||||
{ ...MockSkill, name: "release-notes" },
|
||||
]);
|
||||
|
||||
render(
|
||||
<AppProviders queryClient={queryClient}>
|
||||
<AgentChatInput
|
||||
{...inputProps}
|
||||
inputRef={inputRef}
|
||||
chatOrganizationId="org-1"
|
||||
/>
|
||||
</AppProviders>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("textbox", { name: "Chat message" }));
|
||||
await user.paste("/rel");
|
||||
await user.click(
|
||||
await screen.findByRole("option", { name: /release-notes/ }),
|
||||
);
|
||||
expect(inputRef.current?.getValue()).toBe("/release-notes");
|
||||
});
|
||||
|
||||
it("attaches supported dropped files and reports unsupported ones", () => {
|
||||
const onAttach = vi.fn();
|
||||
const toastError = vi.spyOn(toast, "error");
|
||||
|
||||
@@ -1460,6 +1460,7 @@ export const AgentChatInput: React.FC<AgentChatInputProps> = ({
|
||||
sendShortcut={sendShortcut}
|
||||
disabled={isReadOnly || isLoading}
|
||||
hasWorkspace={hasSkillsWorkspace}
|
||||
organizationId={chatOrganizationId}
|
||||
workspaceSkills={workspaceSkills}
|
||||
slashCommands={slashCommands}
|
||||
skillsMenuAnchor={composerElement}
|
||||
|
||||
@@ -46,6 +46,7 @@ const buildParams = (
|
||||
hasModelOptions: true,
|
||||
isEditReasoningEffortDirtyRef: { current: false },
|
||||
personalSkills: [],
|
||||
organizationSkills: [],
|
||||
workspaceSkills: [],
|
||||
compact: vi.fn().mockResolvedValue(undefined),
|
||||
clearChatContext: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -148,25 +149,62 @@ describe("submitChatTurn", () => {
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws BuiltInCommandPendingError while slash-command skills are unresolved", async () => {
|
||||
const compact = vi.fn();
|
||||
it.each([
|
||||
["compact", "personalSkills"],
|
||||
["clear", "organizationSkills"],
|
||||
] as const)(
|
||||
"throws BuiltInCommandPendingError for /%s while %s is unresolved",
|
||||
async (command, unknownList) => {
|
||||
const compact = vi.fn();
|
||||
const clearChatContext = vi.fn();
|
||||
const sendMessage = vi.fn();
|
||||
await expect(
|
||||
submitChatTurn(
|
||||
buildParams({
|
||||
message: `/${command}`,
|
||||
[unknownList]: undefined,
|
||||
compact,
|
||||
clearChatContext,
|
||||
sendMessage,
|
||||
}),
|
||||
),
|
||||
).rejects.toBeInstanceOf(BuiltInCommandPendingError);
|
||||
expect(toast.info).toHaveBeenCalledWith(
|
||||
`Checking whether /${command} is available. Try again in a moment.`,
|
||||
);
|
||||
expect(compact).not.toHaveBeenCalled();
|
||||
expect(clearChatContext).not.toHaveBeenCalled();
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("clears instead of sending when every skill list is known", async () => {
|
||||
const clearChatContext = vi.fn().mockResolvedValue(undefined);
|
||||
const sendMessage = vi.fn();
|
||||
await expect(
|
||||
submitChatTurn(
|
||||
buildParams({
|
||||
message: "/compact",
|
||||
personalSkills: undefined,
|
||||
workspaceSkills: [],
|
||||
compact,
|
||||
sendMessage,
|
||||
}),
|
||||
),
|
||||
).rejects.toBeInstanceOf(BuiltInCommandPendingError);
|
||||
expect(toast.info).toHaveBeenCalledWith(
|
||||
"Checking whether /compact is available. Try again in a moment.",
|
||||
await submitChatTurn(
|
||||
buildParams({ message: "/clear", clearChatContext, sendMessage }),
|
||||
);
|
||||
expect(clearChatContext).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends /compact as a message when an organization skill owns the name", async () => {
|
||||
const compact = vi.fn();
|
||||
const sendMessage = vi.fn().mockResolvedValue({ queued: false });
|
||||
await submitChatTurn(
|
||||
buildParams({
|
||||
message: "/compact",
|
||||
organizationSkills: [{ name: "compact" }],
|
||||
compact,
|
||||
sendMessage,
|
||||
}),
|
||||
);
|
||||
expect(compact).not.toHaveBeenCalled();
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
content: [{ type: "text", text: "/compact" }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("compacts instead of sending and restores state if compact fails", async () => {
|
||||
|
||||
@@ -43,6 +43,7 @@ export type SubmitChatTurnParams = {
|
||||
hasModelOptions: boolean;
|
||||
isEditReasoningEffortDirtyRef: { current: boolean };
|
||||
personalSkills: readonly { name: string }[] | undefined;
|
||||
organizationSkills: readonly { name: string }[] | undefined;
|
||||
workspaceSkills: readonly { name: string }[] | undefined;
|
||||
compact: () => Promise<unknown>;
|
||||
clearChatContext: () => Promise<unknown>;
|
||||
@@ -118,8 +119,8 @@ const findBuiltInChatCommand = (
|
||||
content: readonly TypesGen.ChatInputPart[],
|
||||
editedMessageID: number | undefined,
|
||||
): (typeof CHAT_SLASH_COMMANDS)[number] | undefined => {
|
||||
// Built-ins only intercept new, text-only sends. A personal or workspace
|
||||
// skill with the same name takes precedence at availability resolution.
|
||||
// Built-ins only intercept new, text-only sends. A skill with the same
|
||||
// name takes precedence at availability resolution.
|
||||
if (editedMessageID !== undefined || content.length !== 1) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -255,6 +256,7 @@ export async function submitChatTurn(
|
||||
isSubmissionPending,
|
||||
hasModelOptions,
|
||||
personalSkills,
|
||||
organizationSkills,
|
||||
workspaceSkills,
|
||||
compact,
|
||||
clearChatContext,
|
||||
@@ -292,11 +294,11 @@ export async function submitChatTurn(
|
||||
|
||||
const builtInCommand = findBuiltInChatCommand(content, editedMessageID);
|
||||
const builtInCommandResolution = builtInCommand
|
||||
? resolveChatSlashCommandAvailability(
|
||||
builtInCommand,
|
||||
personalSkills,
|
||||
workspaceSkills,
|
||||
)
|
||||
? resolveChatSlashCommandAvailability(builtInCommand, [
|
||||
{ source: "personal", skills: personalSkills },
|
||||
{ source: "org", skills: organizationSkills },
|
||||
{ source: "workspace", skills: workspaceSkills },
|
||||
])
|
||||
: undefined;
|
||||
if (builtInCommandResolution === "pending" && builtInCommand) {
|
||||
const triggerText = chatSlashCommandTriggerText(builtInCommand);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Decorator, Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useEffect } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { expect, fn, userEvent, within } from "storybook/test";
|
||||
import { organizationSkills } from "#/api/queries/organizationSkills";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { MockSkill, MockSkills } from "#/testHelpers/skills";
|
||||
import { DEFAULT_AGENT_CHAT_SEND_SHORTCUT } from "../../utils/agentChatSendShortcut";
|
||||
@@ -213,6 +214,40 @@ export const ArrowDownSelectsWorkspaceSkill: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
const mockOrganizationId = "organization-skills-story";
|
||||
|
||||
export const OpensWithAllSkillSources: Story = {
|
||||
args: {
|
||||
hasWorkspace: true,
|
||||
workspaceSkills: mockWorkspaceSkills,
|
||||
organizationId: mockOrganizationId,
|
||||
},
|
||||
parameters: {
|
||||
queries: [
|
||||
{
|
||||
key: organizationSkills(mockOrganizationId).queryKey,
|
||||
data: [
|
||||
{
|
||||
...MockSkill,
|
||||
id: "skill-release-notes",
|
||||
name: "release-notes",
|
||||
description: "Write release notes in the organization style.",
|
||||
},
|
||||
{
|
||||
...MockSkill,
|
||||
id: "skill-org-reviewer",
|
||||
name: "reviewer",
|
||||
description: "Organization review checklist.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
await typeInEditor(canvasElement, "/");
|
||||
},
|
||||
};
|
||||
|
||||
export const CollidingPersonalSkillInsertsQualifiedTrigger: Story = {
|
||||
args: {
|
||||
hasWorkspace: true,
|
||||
|
||||
@@ -3,9 +3,17 @@ import userEvent from "@testing-library/user-event";
|
||||
import { createRef, useLayoutEffect, useRef, useState } from "react";
|
||||
import { type QueryClient, QueryClientProvider } from "react-query";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { AgentChatSendShortcut } from "#/api/typesGenerated";
|
||||
import { API } from "#/api/api";
|
||||
import { organizationSkills } from "#/api/queries/organizationSkills";
|
||||
import { userSkills } from "#/api/queries/userSkills";
|
||||
import type {
|
||||
AgentChatSendShortcut,
|
||||
SkillMetadata,
|
||||
} from "#/api/typesGenerated";
|
||||
import { createTestQueryClient } from "#/testHelpers/renderHelpers";
|
||||
import { MockSkill } from "#/testHelpers/skills";
|
||||
import { DEFAULT_AGENT_CHAT_SEND_SHORTCUT } from "../../utils/agentChatSendShortcut";
|
||||
import { COMPACT_SLASH_COMMAND } from "../../utils/slashCommands";
|
||||
import { ChatMessageInput, type ChatMessageInputRef } from "./ChatMessageInput";
|
||||
|
||||
const requiredProps = () => ({
|
||||
@@ -242,4 +250,97 @@ describe("ChatMessageInput", () => {
|
||||
expect(inputRef.current?.getValue()).toBe("typed content");
|
||||
});
|
||||
});
|
||||
|
||||
describe("slash menu skill sources", () => {
|
||||
const organizationId = "org-1";
|
||||
const mockReviewerSkill: SkillMetadata = {
|
||||
...MockSkill,
|
||||
id: "skill-reviewer",
|
||||
name: "reviewer",
|
||||
};
|
||||
|
||||
const renderWithSkills = ({
|
||||
personal,
|
||||
organization,
|
||||
}: {
|
||||
personal: SkillMetadata[];
|
||||
organization?: SkillMetadata[];
|
||||
}) => {
|
||||
const queryClient = createTestQueryClient();
|
||||
queryClient.setQueryData(userSkills().queryKey, personal);
|
||||
if (organization) {
|
||||
queryClient.setQueryData(
|
||||
organizationSkills(organizationId).queryKey,
|
||||
organization,
|
||||
);
|
||||
} else {
|
||||
vi.spyOn(API.experimental, "getOrganizationSkills").mockRejectedValue(
|
||||
new Error("Failed to load organization skills."),
|
||||
);
|
||||
}
|
||||
const inputRef = createRef<ChatMessageInputRef>();
|
||||
renderWithQueryClient(
|
||||
<ChatMessageInput
|
||||
{...requiredProps()}
|
||||
ref={inputRef}
|
||||
aria-label="Chat message input"
|
||||
organizationId={organizationId}
|
||||
slashCommands={[COMPACT_SLASH_COMMAND]}
|
||||
/>,
|
||||
queryClient,
|
||||
);
|
||||
return inputRef;
|
||||
};
|
||||
|
||||
const pasteTrigger = async (text: string) => {
|
||||
const user = userEvent.setup();
|
||||
await user.click(
|
||||
screen.getByRole("textbox", { name: "Chat message input" }),
|
||||
);
|
||||
await user.paste(text);
|
||||
return user;
|
||||
};
|
||||
|
||||
it("inserts the bare trigger of an organization skill chosen with arrow keys", async () => {
|
||||
const inputRef = renderWithSkills({
|
||||
personal: [mockReviewerSkill],
|
||||
organization: [
|
||||
{ ...MockSkill, id: "skill-release-notes", name: "release-notes" },
|
||||
],
|
||||
});
|
||||
const user = await pasteTrigger("/");
|
||||
await screen.findByRole("option", { name: /release-notes/ });
|
||||
await user.keyboard("{ArrowDown}{ArrowDown}{Enter}");
|
||||
expect(inputRef.current?.getValue()).toBe("/release-notes");
|
||||
});
|
||||
|
||||
it("ignores disabled skills when qualifying triggers", async () => {
|
||||
const inputRef = renderWithSkills({
|
||||
personal: [mockReviewerSkill],
|
||||
organization: [{ ...mockReviewerSkill, enabled: false }],
|
||||
});
|
||||
const user = await pasteTrigger("/rev");
|
||||
await user.click(await screen.findByRole("option", { name: /reviewer/ }));
|
||||
expect(inputRef.current?.getValue()).toBe("/reviewer");
|
||||
});
|
||||
|
||||
it("hides built-in commands when the organization list fails", async () => {
|
||||
// With /compact hidden, Enter picks the qualified personal match.
|
||||
const inputRef = renderWithSkills({
|
||||
personal: [{ ...MockSkill, id: "skill-compactor", name: "compactor" }],
|
||||
});
|
||||
const user = await pasteTrigger("/comp");
|
||||
await screen.findByText(/Could not load organization skills/);
|
||||
await user.keyboard("{Enter}");
|
||||
expect(inputRef.current?.getValue()).toBe("/personal/compactor");
|
||||
});
|
||||
|
||||
it("keeps triggers qualified when the organization list fails", async () => {
|
||||
const inputRef = renderWithSkills({ personal: [mockReviewerSkill] });
|
||||
const user = await pasteTrigger("/rev");
|
||||
await screen.findByText(/Could not load organization skills/);
|
||||
await user.click(screen.getByRole("option", { name: /reviewer/ }));
|
||||
expect(inputRef.current?.getValue()).toBe("/personal/reviewer");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,11 +33,20 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { organizationSkills } from "#/api/queries/organizationSkills";
|
||||
import { userSkills } from "#/api/queries/userSkills";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { MODIFIER_AGENT_CHAT_SEND_SHORTCUT } from "../../utils/agentChatSendShortcut";
|
||||
import {
|
||||
resolveSkillTriggers,
|
||||
type SkillSource,
|
||||
type SkillSourceList,
|
||||
} from "../../utils/skillAliases";
|
||||
import { filterSkillsByQuery, isSkillTriggerToken } from "../../utils/skills";
|
||||
import type { ChatSlashCommand } from "../../utils/slashCommands";
|
||||
import {
|
||||
type ChatSlashCommand,
|
||||
resolveChatSlashCommandAvailability,
|
||||
} from "../../utils/slashCommands";
|
||||
import {
|
||||
$createFileReferenceNode,
|
||||
FileReferenceNode,
|
||||
@@ -52,7 +61,6 @@ import {
|
||||
} from "./pasteHelpers";
|
||||
import {
|
||||
createCommandMenuItem,
|
||||
createSkillMenuItem,
|
||||
type SkillMenuItem,
|
||||
type SkillMetadata,
|
||||
SkillsTriggerMenu,
|
||||
@@ -541,6 +549,8 @@ type ChatMessageInputProps = Omit<
|
||||
* exist even while workspaceSkills is still undefined.
|
||||
*/
|
||||
hasWorkspace: boolean;
|
||||
/** Organization whose skills the chat loads, if any. */
|
||||
organizationId?: string;
|
||||
/**
|
||||
* Story and test seam for deterministic personal skill menu data.
|
||||
*/
|
||||
@@ -626,6 +636,7 @@ const ChatMessageInput = ({
|
||||
acceptFilePasteWhileDisabled,
|
||||
disabled,
|
||||
hasWorkspace,
|
||||
organizationId,
|
||||
personalSkillsOverride,
|
||||
workspaceSkills,
|
||||
slashCommands,
|
||||
@@ -670,76 +681,77 @@ const ChatMessageInput = ({
|
||||
// Avoid refetching on each trigger toggle from caret movement.
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const personalSkills = personalSkillsOverride ?? skillsQuery.data ?? [];
|
||||
const loadedWorkspaceSkills = workspaceSkills ?? [];
|
||||
// Until the chat detail resolves, workspace skills are unknown: keep
|
||||
// personal triggers qualified (a qualified alias always resolves) and
|
||||
// treat the workspace list as still loading.
|
||||
const workspaceSkillsKnown = !hasWorkspace || workspaceSkills !== undefined;
|
||||
// A personal or workspace skill with the same name takes
|
||||
// precedence over a built-in command: the composer's submit
|
||||
// intercept defers to the skill, so the menu must not advertise a
|
||||
// dead command entry. Until both skill lists resolve, a collision
|
||||
// cannot be ruled out, so no built-in commands are offered
|
||||
// (matching the submit intercept, which also stands down while
|
||||
// skills are unknown).
|
||||
const skillsResolved =
|
||||
(hasPersonalSkillsOverride || skillsQuery.isSuccess) &&
|
||||
workspaceSkillsKnown;
|
||||
const availableSlashCommands = skillsResolved
|
||||
? (slashCommands ?? []).filter(
|
||||
(command) =>
|
||||
!personalSkills.some((skill) => skill.name === command.name) &&
|
||||
!loadedWorkspaceSkills.some((skill) => skill.name === command.name),
|
||||
)
|
||||
const organizationSkillsQueryEnabled =
|
||||
hasSkillsTrigger && Boolean(organizationId);
|
||||
const organizationSkillsQuery = useQuery({
|
||||
...organizationSkills(organizationId ?? ""),
|
||||
enabled: organizationSkillsQueryEnabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
// chatd skips disabled skills, so they never offer or collide.
|
||||
const personalSkills = (personalSkillsOverride ?? skillsQuery.data)?.filter(
|
||||
(skill) => skill.enabled,
|
||||
);
|
||||
const loadedOrganizationSkills = organizationId
|
||||
? organizationSkillsQuery.data?.filter((skill) => skill.enabled)
|
||||
: [];
|
||||
const loadedWorkspaceSkills =
|
||||
workspaceSkills ?? (hasWorkspace ? undefined : []);
|
||||
// A list stays undefined until its source loads, even after an error,
|
||||
// which keeps triggers qualified and built-in commands hidden. A chat
|
||||
// without an organization or workspace has a known empty list.
|
||||
const skillLists: SkillSourceList<SkillMetadata>[] = [
|
||||
{ source: "personal", skills: personalSkills },
|
||||
{ source: "org", skills: loadedOrganizationSkills },
|
||||
{ source: "workspace", skills: loadedWorkspaceSkills },
|
||||
];
|
||||
const availableSlashCommands = (slashCommands ?? []).filter(
|
||||
(command) =>
|
||||
resolveChatSlashCommandAvailability(command, skillLists) === "available",
|
||||
);
|
||||
const hasSlashCommands = availableSlashCommands.length > 0;
|
||||
// A stale empty cache with a refetch in flight must not dismiss the menu.
|
||||
const isResolvedEmptyPersonalSkills = hasPersonalSkillsOverride
|
||||
? personalSkills.length === 0
|
||||
: skillsQuery.isSuccess &&
|
||||
!skillsQuery.isFetching &&
|
||||
personalSkills.length === 0;
|
||||
// Unknown workspace skills must not close the menu: the trigger plugin
|
||||
// records a closed trigger as dismissed, so skills arriving later could
|
||||
// never reopen it.
|
||||
const isResolvedEmptyWorkspaceSkills =
|
||||
workspaceSkillsKnown && loadedWorkspaceSkills.length === 0;
|
||||
// Without built-in commands, "/" is plain text when both skills
|
||||
// lists resolve empty. When only the filtered result is empty,
|
||||
// Unknown skills, or a stale empty cache with a refetch in flight, must
|
||||
// not close the menu: the trigger plugin records a closed trigger as
|
||||
// dismissed, so skills arriving later could never reopen it.
|
||||
const isResolvedEmptySkills =
|
||||
personalSkills?.length === 0 &&
|
||||
(hasPersonalSkillsOverride || !skillsQuery.isFetching) &&
|
||||
loadedOrganizationSkills?.length === 0 &&
|
||||
!organizationSkillsQuery.isFetching &&
|
||||
loadedWorkspaceSkills?.length === 0;
|
||||
// Without built-in commands, "/" is plain text when every skills
|
||||
// list resolves empty. When only the filtered result is empty,
|
||||
// keep the menu open for the no-match message.
|
||||
const skillsMenuOpen =
|
||||
hasSkillsTrigger &&
|
||||
(hasSlashCommands ||
|
||||
!(isResolvedEmptyPersonalSkills && isResolvedEmptyWorkspaceSkills));
|
||||
hasSkillsTrigger && (hasSlashCommands || !isResolvedEmptySkills);
|
||||
const skillsSearchQuery = skillsTrigger?.query ?? "";
|
||||
const commandMenuItems: readonly SkillMenuItem[] = filterSkillsByQuery(
|
||||
availableSlashCommands.map(createCommandMenuItem),
|
||||
skillsSearchQuery,
|
||||
);
|
||||
const workspaceSkillNames = new Set(
|
||||
loadedWorkspaceSkills.map((skill) => skill.name),
|
||||
);
|
||||
const personalSkillItems: readonly SkillMenuItem[] = filterSkillsByQuery(
|
||||
personalSkills.map((skill) =>
|
||||
createSkillMenuItem(
|
||||
"personal",
|
||||
skill,
|
||||
!workspaceSkillsKnown || workspaceSkillNames.has(skill.name),
|
||||
),
|
||||
),
|
||||
skillsSearchQuery,
|
||||
);
|
||||
const workspaceSkillItems: readonly SkillMenuItem[] = filterSkillsByQuery(
|
||||
loadedWorkspaceSkills.map((skill) =>
|
||||
createSkillMenuItem("workspace", skill),
|
||||
),
|
||||
skillsSearchQuery,
|
||||
);
|
||||
const skillTriggers = resolveSkillTriggers(skillLists);
|
||||
const filterSkillTriggers = (source: SkillSource) =>
|
||||
filterSkillsByQuery(
|
||||
skillTriggers.filter((trigger) => trigger.source === source),
|
||||
skillsSearchQuery,
|
||||
);
|
||||
const personalSkillItems = filterSkillTriggers("personal");
|
||||
const organizationSkillItems = filterSkillTriggers("org");
|
||||
const workspaceSkillItems = filterSkillTriggers("workspace");
|
||||
const isPersonalSkillsLoading =
|
||||
personalSkillsQueryEnabled &&
|
||||
skillsQuery.isFetching &&
|
||||
skillsQuery.data === undefined;
|
||||
const isOrganizationSkillsLoading =
|
||||
organizationSkillsQueryEnabled &&
|
||||
organizationSkillsQuery.isFetching &&
|
||||
organizationSkillsQuery.data === undefined;
|
||||
const isWorkspaceSkillsLoading = loadedWorkspaceSkills === undefined;
|
||||
// Commands come first so partitioned menu groups match selection order.
|
||||
const allFilteredSkills: readonly SkillMenuItem[] = [
|
||||
...commandMenuItems,
|
||||
...personalSkillItems,
|
||||
...organizationSkillItems,
|
||||
...workspaceSkillItems,
|
||||
];
|
||||
const selectedSkillIndex =
|
||||
@@ -1016,10 +1028,9 @@ const ChatMessageInput = ({
|
||||
open={skillsMenuOpen}
|
||||
skills={allFilteredSkills}
|
||||
skillsLoading={
|
||||
(personalSkillsQueryEnabled &&
|
||||
skillsQuery.isFetching &&
|
||||
skillsQuery.data === undefined) ||
|
||||
!workspaceSkillsKnown
|
||||
isPersonalSkillsLoading ||
|
||||
isOrganizationSkillsLoading ||
|
||||
isWorkspaceSkillsLoading
|
||||
}
|
||||
selectedIndex={selectedSkillIndex}
|
||||
onSelectedIndexChange={setSkillsMenuSelectedIndex}
|
||||
@@ -1037,19 +1048,23 @@ const ChatMessageInput = ({
|
||||
query={skillsSearchQuery}
|
||||
commands={commandMenuItems}
|
||||
personalSkills={personalSkillItems}
|
||||
organizationSkills={organizationSkillItems}
|
||||
workspaceSkills={workspaceSkillItems}
|
||||
organizationSkillsEnabled={Boolean(organizationId)}
|
||||
workspaceSkillsEnabled={hasWorkspace}
|
||||
isPersonalLoading={
|
||||
personalSkillsQueryEnabled &&
|
||||
skillsQuery.isFetching &&
|
||||
skillsQuery.data === undefined
|
||||
}
|
||||
isPersonalLoading={isPersonalSkillsLoading}
|
||||
isPersonalError={
|
||||
personalSkillsQueryEnabled &&
|
||||
skillsQuery.isError &&
|
||||
skillsQuery.data === undefined
|
||||
}
|
||||
isWorkspaceLoading={!workspaceSkillsKnown}
|
||||
isOrganizationLoading={isOrganizationSkillsLoading}
|
||||
isOrganizationError={
|
||||
organizationSkillsQueryEnabled &&
|
||||
organizationSkillsQuery.isError &&
|
||||
organizationSkillsQuery.data === undefined
|
||||
}
|
||||
isWorkspaceLoading={isWorkspaceSkillsLoading}
|
||||
selectedIndex={selectedSkillIndex}
|
||||
onSelectedIndexChange={setSkillsMenuSelectedIndex}
|
||||
onSelect={replaceActiveSkillsTrigger}
|
||||
|
||||
+52
-27
@@ -2,11 +2,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useState } from "react";
|
||||
import { expect, fn, userEvent } from "storybook/test";
|
||||
import { MockSkills } from "#/testHelpers/skills";
|
||||
import { resolveSkillTriggers } from "../../utils/skillAliases";
|
||||
import { filterSkillsByQuery } from "../../utils/skills";
|
||||
import { COMPACT_SLASH_COMMAND } from "../../utils/slashCommands";
|
||||
import {
|
||||
createCommandMenuItem,
|
||||
createSkillMenuItem,
|
||||
type SkillMetadata,
|
||||
SkillsTriggerMenu,
|
||||
} from "./SkillsTriggerMenu";
|
||||
@@ -23,13 +23,23 @@ const mockWorkspaceSkills: SkillMetadata[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const mockPersonalSkillItems = MockSkills.map((skill) =>
|
||||
createSkillMenuItem("personal", skill),
|
||||
);
|
||||
const mockOrganizationSkills: SkillMetadata[] = [
|
||||
{
|
||||
name: "release-notes",
|
||||
description: "Write release notes in the organization style.",
|
||||
},
|
||||
];
|
||||
|
||||
const mockPersonalSkillItems = resolveSkillTriggers([
|
||||
{ source: "personal", skills: MockSkills },
|
||||
]);
|
||||
const compactCommandItem = createCommandMenuItem(COMPACT_SLASH_COMMAND);
|
||||
const mockWorkspaceSkillItems = mockWorkspaceSkills.map((skill) =>
|
||||
createSkillMenuItem("workspace", skill),
|
||||
);
|
||||
const mockOrganizationSkillItems = resolveSkillTriggers([
|
||||
{ source: "org", skills: mockOrganizationSkills },
|
||||
]);
|
||||
const mockWorkspaceSkillItems = resolveSkillTriggers([
|
||||
{ source: "workspace", skills: mockWorkspaceSkills },
|
||||
]);
|
||||
|
||||
// Provides the composer-box element the menu anchors to, since the
|
||||
// menu is pinned above its anchor at the anchor's width.
|
||||
@@ -59,10 +69,14 @@ const meta: Meta<typeof SkillsTriggerMenu> = {
|
||||
query: "",
|
||||
commands: [],
|
||||
personalSkills: mockPersonalSkillItems,
|
||||
organizationSkills: [],
|
||||
workspaceSkills: [],
|
||||
organizationSkillsEnabled: false,
|
||||
workspaceSkillsEnabled: false,
|
||||
isPersonalLoading: false,
|
||||
isPersonalError: false,
|
||||
isOrganizationLoading: false,
|
||||
isOrganizationError: false,
|
||||
isWorkspaceLoading: false,
|
||||
onSelectedIndexChange: fn(),
|
||||
selectedIndex: 0,
|
||||
@@ -83,15 +97,22 @@ const meta: Meta<typeof SkillsTriggerMenu> = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SkillsTriggerMenu>;
|
||||
|
||||
export const PersonalOnly: Story = {};
|
||||
|
||||
export const BothGroups: Story = {
|
||||
export const AllSkillGroups: Story = {
|
||||
args: {
|
||||
organizationSkills: mockOrganizationSkillItems,
|
||||
workspaceSkills: mockWorkspaceSkillItems,
|
||||
organizationSkillsEnabled: true,
|
||||
workspaceSkillsEnabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const OrganizationError: Story = {
|
||||
args: {
|
||||
organizationSkillsEnabled: true,
|
||||
isOrganizationError: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
isPersonalLoading: true,
|
||||
@@ -99,19 +120,26 @@ export const Loading: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const OrganizationLoading: Story = {
|
||||
args: {
|
||||
personalSkills: [],
|
||||
organizationSkillsEnabled: true,
|
||||
isOrganizationLoading: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WorkspaceLoading: Story = {
|
||||
args: {
|
||||
personalSkills: [],
|
||||
workspaceSkills: [],
|
||||
workspaceSkillsEnabled: true,
|
||||
isWorkspaceLoading: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyWithWorkspace: Story = {
|
||||
export const EmptyWithAllSources: Story = {
|
||||
args: {
|
||||
personalSkills: [],
|
||||
workspaceSkills: [],
|
||||
organizationSkillsEnabled: true,
|
||||
workspaceSkillsEnabled: true,
|
||||
},
|
||||
};
|
||||
@@ -132,12 +160,15 @@ export const Filtered: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
const manyPersonalSkillItems = Array.from({ length: 30 }, (_, index) =>
|
||||
createSkillMenuItem("personal", {
|
||||
name: `skill-${String(index).padStart(2, "0")}`,
|
||||
description: "",
|
||||
}),
|
||||
);
|
||||
const manyPersonalSkillItems = resolveSkillTriggers([
|
||||
{
|
||||
source: "personal",
|
||||
skills: Array.from({ length: 30 }, (_, index) => ({
|
||||
name: `skill-${String(index).padStart(2, "0")}`,
|
||||
description: "",
|
||||
})),
|
||||
},
|
||||
]);
|
||||
|
||||
// cmdk scrolls the controlled highlight into view only at mount, so the
|
||||
// selection must move after mount to exercise the menu's own scrolling.
|
||||
@@ -183,14 +214,6 @@ export const SelectsByClick: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
// Built-in commands render in a separate "Commands" group above
|
||||
// personal skills and stay selectable alongside them.
|
||||
export const WithCommands: Story = {
|
||||
args: {
|
||||
commands: [compactCommandItem],
|
||||
},
|
||||
};
|
||||
|
||||
// With no skills configured, the menu still opens to offer the
|
||||
// built-in commands without any skills group or empty message.
|
||||
export const CommandsOnly: Story = {
|
||||
@@ -200,6 +223,8 @@ export const CommandsOnly: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
// Built-in commands render in a separate "Commands" group above
|
||||
// personal skills and stay selectable alongside them.
|
||||
export const SelectsCommandByClick: Story = {
|
||||
args: {
|
||||
commands: [compactCommandItem],
|
||||
|
||||
@@ -12,21 +12,15 @@ import {
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
} from "#/components/Popover/Popover";
|
||||
|
||||
type SkillSource = "personal" | "workspace";
|
||||
import type { SkillTrigger } from "../../utils/skillAliases";
|
||||
|
||||
export type SkillMetadata = {
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type SkillMenuItem = SkillMetadata & {
|
||||
source: SkillSource | "command";
|
||||
triggerText: string;
|
||||
// The qualified alias stays searchable even when the displayed
|
||||
// trigger is bare, so a typed qualified query keeps matching after
|
||||
// collision state changes mid-trigger.
|
||||
altTriggerText: string;
|
||||
export type SkillMenuItem = Omit<SkillTrigger, "source"> & {
|
||||
source: SkillTrigger["source"] | "command";
|
||||
};
|
||||
|
||||
// Built-in commands (e.g. /compact) share the menu item shape so the
|
||||
@@ -42,20 +36,6 @@ export const createCommandMenuItem = (
|
||||
altTriggerText: `/${command.name}`,
|
||||
});
|
||||
|
||||
export const createSkillMenuItem = (
|
||||
source: SkillSource,
|
||||
skill: SkillMetadata,
|
||||
// Bare personal names are ambiguous to read_skill when a workspace
|
||||
// skill shares the name, so colliding triggers must stay qualified.
|
||||
qualifyTrigger = source === "workspace",
|
||||
): SkillMenuItem => ({
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
source,
|
||||
triggerText: qualifyTrigger ? `/${source}/${skill.name}` : `/${skill.name}`,
|
||||
altTriggerText: `/${source}/${skill.name}`,
|
||||
});
|
||||
|
||||
type SkillsTriggerMenuProps = {
|
||||
open: boolean;
|
||||
// The composer box the menu is pinned above and sized to match.
|
||||
@@ -63,10 +43,14 @@ type SkillsTriggerMenuProps = {
|
||||
query: string;
|
||||
commands: readonly SkillMenuItem[];
|
||||
personalSkills: readonly SkillMenuItem[];
|
||||
organizationSkills: readonly SkillMenuItem[];
|
||||
workspaceSkills: readonly SkillMenuItem[];
|
||||
organizationSkillsEnabled: boolean;
|
||||
workspaceSkillsEnabled: boolean;
|
||||
isPersonalLoading: boolean;
|
||||
isPersonalError: boolean;
|
||||
isOrganizationLoading: boolean;
|
||||
isOrganizationError: boolean;
|
||||
isWorkspaceLoading: boolean;
|
||||
selectedIndex: number;
|
||||
onSelectedIndexChange: (index: number) => void;
|
||||
@@ -78,15 +62,24 @@ type SkillsTriggerMenuProps = {
|
||||
onEscapeKeyDown: (event: KeyboardEvent) => void;
|
||||
};
|
||||
|
||||
const getEmptyMessage = (query: string, workspaceSkillsEnabled: boolean) => {
|
||||
const getEmptyMessage = (
|
||||
query: string,
|
||||
organizationSkillsEnabled: boolean,
|
||||
workspaceSkillsEnabled: boolean,
|
||||
) => {
|
||||
if (query) {
|
||||
return workspaceSkillsEnabled
|
||||
return organizationSkillsEnabled || workspaceSkillsEnabled
|
||||
? "No skills match that query."
|
||||
: "No personal skills match that query.";
|
||||
}
|
||||
return workspaceSkillsEnabled
|
||||
? "No personal or workspace skills found."
|
||||
: "No personal skills found.";
|
||||
const sources = ["personal"];
|
||||
if (organizationSkillsEnabled) {
|
||||
sources.push("organization");
|
||||
}
|
||||
if (workspaceSkillsEnabled) {
|
||||
sources.push("workspace");
|
||||
}
|
||||
return `No ${new Intl.ListFormat("en-US", { type: "disjunction" }).format(sources)} skills found.`;
|
||||
};
|
||||
|
||||
const SkillCommandItem = ({
|
||||
@@ -160,10 +153,14 @@ export const SkillsTriggerMenu = ({
|
||||
query,
|
||||
commands,
|
||||
personalSkills,
|
||||
organizationSkills,
|
||||
workspaceSkills,
|
||||
organizationSkillsEnabled,
|
||||
workspaceSkillsEnabled,
|
||||
isPersonalLoading,
|
||||
isPersonalError,
|
||||
isOrganizationLoading,
|
||||
isOrganizationError,
|
||||
isWorkspaceLoading,
|
||||
selectedIndex,
|
||||
onSelectedIndexChange,
|
||||
@@ -171,7 +168,12 @@ export const SkillsTriggerMenu = ({
|
||||
onClose,
|
||||
onEscapeKeyDown,
|
||||
}: SkillsTriggerMenuProps) => {
|
||||
const allSkills = [...commands, ...personalSkills, ...workspaceSkills];
|
||||
const allSkills = [
|
||||
...commands,
|
||||
...personalSkills,
|
||||
...organizationSkills,
|
||||
...workspaceSkills,
|
||||
];
|
||||
const statusItems = [
|
||||
isPersonalLoading && personalSkills.length === 0
|
||||
? "Loading personal skills..."
|
||||
@@ -179,6 +181,12 @@ export const SkillsTriggerMenu = ({
|
||||
isPersonalError && personalSkills.length === 0
|
||||
? "Could not load personal skills. Close and type / again to retry."
|
||||
: undefined,
|
||||
isOrganizationLoading && organizationSkills.length === 0
|
||||
? "Loading organization skills..."
|
||||
: undefined,
|
||||
isOrganizationError && organizationSkills.length === 0
|
||||
? "Could not load organization skills. Close and type / again to retry."
|
||||
: undefined,
|
||||
isWorkspaceLoading && workspaceSkills.length === 0
|
||||
? "Loading workspace skills..."
|
||||
: undefined,
|
||||
@@ -258,12 +266,25 @@ export const SkillsTriggerMenu = ({
|
||||
)}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{organizationSkills.length > 0 && (
|
||||
<CommandGroup heading="Organization skills">
|
||||
{organizationSkills.map((skill, index) =>
|
||||
renderSkill(
|
||||
skill,
|
||||
commands.length + personalSkills.length + index,
|
||||
),
|
||||
)}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{workspaceSkills.length > 0 && (
|
||||
<CommandGroup heading="Workspace skills">
|
||||
{workspaceSkills.map((skill, index) =>
|
||||
renderSkill(
|
||||
skill,
|
||||
commands.length + personalSkills.length + index,
|
||||
commands.length +
|
||||
personalSkills.length +
|
||||
organizationSkills.length +
|
||||
index,
|
||||
),
|
||||
)}
|
||||
</CommandGroup>
|
||||
@@ -275,7 +296,11 @@ export const SkillsTriggerMenu = ({
|
||||
))}
|
||||
{shouldShowEmpty && (
|
||||
<CommandEmpty>
|
||||
{getEmptyMessage(query, workspaceSkillsEnabled)}
|
||||
{getEmptyMessage(
|
||||
query,
|
||||
organizationSkillsEnabled,
|
||||
workspaceSkillsEnabled,
|
||||
)}
|
||||
</CommandEmpty>
|
||||
)}
|
||||
</CommandList>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveSkillTriggers } from "./skillAliases";
|
||||
|
||||
const skills = (...names: string[]) =>
|
||||
names.map((name) => ({ name, description: `${name} description` }));
|
||||
|
||||
type Lists = {
|
||||
personal?: readonly string[];
|
||||
org?: readonly string[];
|
||||
workspace?: readonly string[];
|
||||
};
|
||||
|
||||
const triggersFor = (lists: Lists) =>
|
||||
resolveSkillTriggers([
|
||||
{ source: "personal", skills: lists.personal && skills(...lists.personal) },
|
||||
{ source: "org", skills: lists.org && skills(...lists.org) },
|
||||
{
|
||||
source: "workspace",
|
||||
skills: lists.workspace && skills(...lists.workspace),
|
||||
},
|
||||
]).map((trigger) => trigger.triggerText);
|
||||
|
||||
describe("resolveSkillTriggers", () => {
|
||||
it.each<{ name: string; lists: Lists; expected: string[] }>([
|
||||
{
|
||||
name: "keeps names unique to one source bare, except workspace",
|
||||
lists: { personal: ["review"], org: ["deploy"], workspace: ["test"] },
|
||||
expected: ["/review", "/deploy", "/workspace/test"],
|
||||
},
|
||||
{
|
||||
name: "qualifies a personal and organization collision in both",
|
||||
lists: { personal: ["review"], org: ["review", "deploy"], workspace: [] },
|
||||
expected: ["/personal/review", "/org/review", "/deploy"],
|
||||
},
|
||||
{
|
||||
name: "qualifies an organization and workspace collision",
|
||||
lists: { personal: ["plan"], org: ["test"], workspace: ["test"] },
|
||||
expected: ["/plan", "/org/test", "/workspace/test"],
|
||||
},
|
||||
{
|
||||
name: "qualifies a name found in every source",
|
||||
lists: { personal: ["review"], org: ["review"], workspace: ["review"] },
|
||||
expected: ["/personal/review", "/org/review", "/workspace/review"],
|
||||
},
|
||||
{
|
||||
name: "keeps triggers qualified while a list is unknown",
|
||||
lists: { personal: ["review"], workspace: ["test"] },
|
||||
expected: ["/personal/review", "/workspace/test"],
|
||||
},
|
||||
])("$name", ({ lists, expected }) => {
|
||||
expect(triggersFor(lists)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("keeps the qualified alias searchable for bare triggers", () => {
|
||||
expect(
|
||||
resolveSkillTriggers([
|
||||
{ source: "org", skills: skills("deploy") },
|
||||
{ source: "personal", skills: [] },
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
source: "org",
|
||||
name: "deploy",
|
||||
description: "deploy description",
|
||||
triggerText: "/deploy",
|
||||
altTriggerText: "/org/deploy",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/** Skill sources in chatd's merge order, named by their alias prefix. */
|
||||
export type SkillSource = "personal" | "org" | "workspace";
|
||||
|
||||
export type SkillSourceList<T extends { name: string }> = {
|
||||
source: SkillSource;
|
||||
/** Undefined while the source's list is still unknown. */
|
||||
skills: readonly T[] | undefined;
|
||||
};
|
||||
|
||||
export type SkillTrigger = {
|
||||
source: SkillSource;
|
||||
name: string;
|
||||
description: string;
|
||||
triggerText: string;
|
||||
// The qualified alias stays searchable even when the displayed trigger
|
||||
// is bare, so a typed qualified query keeps matching after collision
|
||||
// state changes mid-trigger.
|
||||
altTriggerText: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds every "/" trigger. Workspace skills and names in more than one
|
||||
* source (chatd's MergeSkills) are qualified, as is every trigger while any
|
||||
* list is unknown.
|
||||
*/
|
||||
export const resolveSkillTriggers = (
|
||||
lists: readonly SkillSourceList<{ name: string; description: string }>[],
|
||||
): SkillTrigger[] => {
|
||||
const hasUnknownList = lists.some((list) => list.skills === undefined);
|
||||
const sourceCountByName = new Map<string, number>();
|
||||
for (const list of lists) {
|
||||
for (const name of new Set(list.skills?.map((skill) => skill.name))) {
|
||||
sourceCountByName.set(name, (sourceCountByName.get(name) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return lists.flatMap(({ source, skills }) =>
|
||||
(skills ?? []).map((skill) => {
|
||||
const qualified = `/${source}/${skill.name}`;
|
||||
const isQualified =
|
||||
source === "workspace" ||
|
||||
hasUnknownList ||
|
||||
(sourceCountByName.get(skill.name) ?? 0) > 1;
|
||||
return {
|
||||
source,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
triggerText: isQualified ? qualified : `/${skill.name}`,
|
||||
altTriggerText: qualified,
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -1,63 +1,98 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SkillSourceList } from "./skillAliases";
|
||||
import {
|
||||
CLEAR_SLASH_COMMAND,
|
||||
COMPACT_SLASH_COMMAND,
|
||||
resolveChatSlashCommandAvailability,
|
||||
} from "./slashCommands";
|
||||
|
||||
describe("resolveChatSlashCommandAvailability", () => {
|
||||
it("stays pending until both skill sources resolve", () => {
|
||||
expect(
|
||||
resolveChatSlashCommandAvailability(COMPACT_SLASH_COMMAND, undefined, []),
|
||||
).toBe("pending");
|
||||
expect(
|
||||
resolveChatSlashCommandAvailability(COMPACT_SLASH_COMMAND, [], undefined),
|
||||
).toBe("pending");
|
||||
});
|
||||
const skillLists = (
|
||||
personal: readonly { name: string }[] | undefined,
|
||||
org: readonly { name: string }[] | undefined,
|
||||
workspace: readonly { name: string }[] | undefined,
|
||||
): SkillSourceList<{ name: string }>[] => [
|
||||
{ source: "personal", skills: personal },
|
||||
{ source: "org", skills: org },
|
||||
{ source: "workspace", skills: workspace },
|
||||
];
|
||||
|
||||
it("is unavailable when either skill source defines the command", () => {
|
||||
describe("resolveChatSlashCommandAvailability", () => {
|
||||
it("stays pending until every skill source resolves", () => {
|
||||
expect(
|
||||
resolveChatSlashCommandAvailability(
|
||||
COMPACT_SLASH_COMMAND,
|
||||
[{ name: "compact" }],
|
||||
[],
|
||||
skillLists(undefined, [], []),
|
||||
),
|
||||
).toBe("pending");
|
||||
expect(
|
||||
resolveChatSlashCommandAvailability(
|
||||
COMPACT_SLASH_COMMAND,
|
||||
skillLists([], undefined, []),
|
||||
),
|
||||
).toBe("pending");
|
||||
expect(
|
||||
resolveChatSlashCommandAvailability(
|
||||
COMPACT_SLASH_COMMAND,
|
||||
skillLists([], [], undefined),
|
||||
),
|
||||
).toBe("pending");
|
||||
});
|
||||
|
||||
it("is unavailable when any skill source defines the command", () => {
|
||||
expect(
|
||||
resolveChatSlashCommandAvailability(
|
||||
COMPACT_SLASH_COMMAND,
|
||||
skillLists([{ name: "compact" }], [], []),
|
||||
),
|
||||
).toBe("unavailable");
|
||||
expect(
|
||||
resolveChatSlashCommandAvailability(
|
||||
COMPACT_SLASH_COMMAND,
|
||||
[],
|
||||
[{ name: "compact" }],
|
||||
skillLists([], [{ name: "compact" }], []),
|
||||
),
|
||||
).toBe("unavailable");
|
||||
expect(
|
||||
resolveChatSlashCommandAvailability(
|
||||
COMPACT_SLASH_COMMAND,
|
||||
skillLists([], [], [{ name: "compact" }]),
|
||||
),
|
||||
).toBe("unavailable");
|
||||
});
|
||||
|
||||
it("resolves clear availability and skill collisions", () => {
|
||||
expect(
|
||||
resolveChatSlashCommandAvailability(CLEAR_SLASH_COMMAND, undefined, []),
|
||||
resolveChatSlashCommandAvailability(
|
||||
CLEAR_SLASH_COMMAND,
|
||||
skillLists(undefined, [], []),
|
||||
),
|
||||
).toBe("pending");
|
||||
expect(
|
||||
resolveChatSlashCommandAvailability(
|
||||
CLEAR_SLASH_COMMAND,
|
||||
[{ name: "clear" }],
|
||||
[],
|
||||
skillLists([], [{ name: "clear" }], []),
|
||||
),
|
||||
).toBe("unavailable");
|
||||
expect(
|
||||
resolveChatSlashCommandAvailability(
|
||||
CLEAR_SLASH_COMMAND,
|
||||
[{ name: "review" }],
|
||||
[{ name: "test" }],
|
||||
skillLists(
|
||||
[{ name: "review" }],
|
||||
[{ name: "lint" }],
|
||||
[{ name: "test" }],
|
||||
),
|
||||
),
|
||||
).toBe("available");
|
||||
});
|
||||
|
||||
it("is available when both skill sources resolve without a collision", () => {
|
||||
it("is available when every skill source resolves without a collision", () => {
|
||||
expect(
|
||||
resolveChatSlashCommandAvailability(
|
||||
COMPACT_SLASH_COMMAND,
|
||||
[{ name: "review" }],
|
||||
[{ name: "test" }],
|
||||
skillLists(
|
||||
[{ name: "review" }],
|
||||
[{ name: "lint" }],
|
||||
[{ name: "test" }],
|
||||
),
|
||||
),
|
||||
).toBe("available");
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { SkillSourceList } from "./skillAliases";
|
||||
|
||||
/**
|
||||
* A built-in chat command offered by the "/" trigger menu. Unlike
|
||||
* personal skills, commands are fixed client-side actions: the
|
||||
@@ -31,16 +33,19 @@ export const CHAT_SLASH_COMMANDS: readonly ChatSlashCommand[] = [
|
||||
|
||||
type ChatSlashCommandResolution = "pending" | "available" | "unavailable";
|
||||
|
||||
/**
|
||||
* A skill from any source with a command's name makes the command
|
||||
* unavailable. Pending until every list is known.
|
||||
*/
|
||||
export const resolveChatSlashCommandAvailability = (
|
||||
command: ChatSlashCommand,
|
||||
personalSkills: readonly { name: string }[] | undefined,
|
||||
workspaceSkills: readonly { name: string }[] | undefined,
|
||||
skillLists: readonly SkillSourceList<{ name: string }>[],
|
||||
): ChatSlashCommandResolution => {
|
||||
if (personalSkills === undefined || workspaceSkills === undefined) {
|
||||
if (skillLists.some((list) => list.skills === undefined)) {
|
||||
return "pending";
|
||||
}
|
||||
return [...personalSkills, ...workspaceSkills].some(
|
||||
(skill) => skill.name === command.name,
|
||||
return skillLists.some((list) =>
|
||||
list.skills?.some((skill) => skill.name === command.name),
|
||||
)
|
||||
? "unavailable"
|
||||
: "available";
|
||||
|
||||
Reference in New Issue
Block a user