History

fix(skills): support names beginning with digits (#9244)

Amirali Beigi committed 2 months ago

Commit 82f64cd1 parent 896fe8211 files · +157 −7Browse files

Unified · Split

11 files
  1. apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts +2 −1
  2. apps/mobile/src/lib/nativeMarkdownText.test.ts +23 −0
  3. apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts +15 −1
  4. apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts +2 −1
  5. apps/server/src/provider/Drivers/CursorSkills.ts +3 −2
  6. apps/server/src/provider/Layers/CursorProvider.test.ts +18 −0
  7. apps/web/src/components/ChatMarkdown.test.tsx +40 −0
  8. apps/web/src/components/chat/SkillInlineText.tsx +2 −1
  9. apps/web/src/composer-editor-mentions.test.ts +23 −0
  10. packages/shared/src/composerInlineTokens.test.ts +20 −0
  11. packages/shared/src/composerInlineTokens.ts +9 −1
apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts+2−1
@@ -195,7 +195,8 @@ function appendRun( · 194 unmodified lines
195 return runs;195 return runs;
196}196}
197 197
198const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g;198const SKILL_TOKEN_REGEX =
199 /(^|\s)\$(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/g;
199 200
200function formatSkillLabel(skill: SelectableMarkdownSkill): string {201function formatSkillLabel(skill: SelectableMarkdownSkill): string {
201 const displayName = skill.displayName?.trim();202 const displayName = skill.displayName?.trim();
apps/mobile/src/lib/nativeMarkdownText.test.ts+23−0
@@ -226,6 +226,29 @@ describe("nativeMarkdownDocumentRuns", () => { · 225 unmodified lines
226 ]);226 ]);
227 });227 });
228 228
229 it("decorates known skill references that begin with a digit", () => {
230 const node: MarkdownNode = {
231 type: "document",
232 children: [
233 {
234 type: "paragraph",
235 children: [{ type: "text", content: "Use $2spec for this." }],
236 },
237 ],
238 };
239
240 expect(nativeMarkdownDocumentRuns(node, [{ name: "2spec", displayName: "2Spec" }])).toEqual([
241 { text: "Use ", role: "body" },
242 {
243 text: "$2spec",
244 role: "body",
245 skillName: "2spec",
246 skillLabel: "2Spec",
247 },
248 { text: " for this.", role: "body" },
249 ]);
250 });
251
229 it("decorates known skill references inside blockquotes", () => {252 it("decorates known skill references inside blockquotes", () => {
230 const node: MarkdownNode = {253 const node: MarkdownNode = {
231 type: "blockquote",254 type: "blockquote",
apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts+15−1
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; · 1 unmodified line
2 2
3import { planClaudeSkillDispatch } from "./ClaudeSkillDispatch.ts";3import { planClaudeSkillDispatch } from "./ClaudeSkillDispatch.ts";
4 4
5const SKILLS = new Set(["implement", "review", "re-release-version"]);5const SKILLS = new Set(["2spec", "implement", "review", "re-release-version"]);
6 6
7describe("planClaudeSkillDispatch", () => {7describe("planClaudeSkillDispatch", () => {
8 it("leaves a prompt without a known skill untouched", () => {8 it("leaves a prompt without a known skill untouched", () => {
@@ -27,6 +27,14 @@ describe("planClaudeSkillDispatch", () => { · 18 unmodified lines
27 });27 });
28 });28 });
29 29
30 it("dispatches a known skill whose name begins with a digit", () => {
31 expect(planClaudeSkillDispatch("use $2spec for this", SKILLS)).toEqual({
32 leadingText: "use",
33 commandText: "/2spec for this",
34 skillName: "2spec",
35 });
36 });
37
30 it("dispatches the last mention and rewrites earlier ones inline", () => {38 it("dispatches the last mention and rewrites earlier ones inline", () => {
31 expect(planClaudeSkillDispatch("$review the diff, then $implement the fixes", SKILLS)).toEqual({39 expect(planClaudeSkillDispatch("$review the diff, then $implement the fixes", SKILLS)).toEqual({
32 leadingText: "/review the diff, then",40 leadingText: "/review the diff, then",
@@ -38,4 +46,10 @@ describe("planClaudeSkillDispatch", () => { · 5 unmodified lines
38 it("ignores a dollar token glued to other text", () => {46 it("ignores a dollar token glued to other text", () => {
39 expect(planClaudeSkillDispatch("cost is 5$implement", SKILLS)).toBeUndefined();47 expect(planClaudeSkillDispatch("cost is 5$implement", SKILLS)).toBeUndefined();
40 });48 });
49
50 it("ignores currency amounts and compact monetary expressions", () => {
51 const skillsWithCurrency = new Set([...SKILLS, "20", "20k", "100M"]);
52 expect(planClaudeSkillDispatch("pay $20 tomorrow", skillsWithCurrency)).toBeUndefined();
53 expect(planClaudeSkillDispatch("budget is $20k tomorrow", skillsWithCurrency)).toBeUndefined();
54 });
41});55});
apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts+2−1
@@ -29,7 +29,8 @@ · 28 unmodified lines
29 * (`packages/shared/src/composerInlineTokens.ts`), so a rendered chip and a29 * (`packages/shared/src/composerInlineTokens.ts`), so a rendered chip and a
30 * dispatched skill are always the same set.30 * dispatched skill are always the same set.
31 */31 */
32const SKILL_MENTION_PATTERN = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g;32const SKILL_MENTION_PATTERN =
33 /(^|\s)\$(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/g;
33 34
34export interface ClaudeSkillDispatch {35export interface ClaudeSkillDispatch {
35 /** Text before the dispatched mention, or `undefined` when it opens the prompt. */36 /** Text before the dispatched mention, or `undefined` when it opens the prompt. */
apps/server/src/provider/Drivers/CursorSkills.ts+3−2
@@ -19,8 +19,9 @@ import * as Schema from "effect/Schema"; · 18 unmodified lines
19import { parse as parseYamlDocument } from "yaml";19import { parse as parseYamlDocument } from "yaml";
20 20
21const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;21const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
22const SKILL_MENTION_PATTERN = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g;22const SKILL_MENTION_PATTERN =
23const HAS_SKILL_MENTION_PATTERN = /(^|\s)\$[a-zA-Z][a-zA-Z0-9:_-]*(?=\s|$)/;23 /(^|\s)\$(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/g;
24const HAS_SKILL_MENTION_PATTERN = new RegExp(SKILL_MENTION_PATTERN.source);
24const MAX_SKILL_DEPTH = 10;25const MAX_SKILL_DEPTH = 10;
25const MAX_SKILL_BYTES = FileSystem.Size(1_000_000);26const MAX_SKILL_BYTES = FileSystem.Size(1_000_000);
26const MAX_SKILL_SCAN_ENTRIES = 10_000;27const MAX_SKILL_SCAN_ENTRIES = 10_000;
apps/server/src/provider/Layers/CursorProvider.test.ts+18−0
@@ -455,6 +455,24 @@ describe("Cursor skills", () => { · 454 unmodified lines
455 "please /review this",455 "please /review this",
456 );456 );
457 });457 });
458
459 it("detects and invokes digit-leading Cursor skills without rewriting money", () => {
460 const names = new Set(["2spec", "20k", "100M", "1e6"]);
461 // Repeated presence checks must not carry a global-regex cursor.
462 expect(hasCursorSkillMention("use $2spec here")).toBe(true);
463 expect(hasCursorSkillMention("use $2spec here")).toBe(true);
464 expect(rewriteCursorSkillMentions("use $2spec here", names)).toBe("use /2spec here");
465 expect(rewriteCursorSkillMentions("use $2spec here", new Set())).toBe("use $2spec here");
466 for (const text of [
467 "pay $20 tomorrow",
468 "budget $20k here",
469 "cost $100M total",
470 "limit $1e6 here",
471 ]) {
472 expect(hasCursorSkillMention(text)).toBe(false);
473 expect(rewriteCursorSkillMentions(text, names)).toBe(text);
474 }
475 });
458});476});
459 477
460describe("getCursorFallbackModels", () => {478describe("getCursorFallbackModels", () => {
apps/web/src/components/ChatMarkdown.test.tsx+40−0
@@ -293,6 +293,46 @@ describe("hasMarkdownFilePrimaryAction", () => { · 292 unmodified lines
293 });293 });
294});294});
295 295
296describe("ChatMarkdown skill chips", () => {
297 it("updates digit-leading skill labels when discovered skills change", async () => {
298 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
299 let renderer: ReactTestRenderer | undefined;
300 const text = "Use $2spec with a $20k budget.";
301 try {
302 await act(async () => {
303 renderer = create(<ChatMarkdown cwd="/tmp/project" text={text} />);
304 });
305 const mounted = renderer!;
306 const labels = (label: string) =>
307 mounted.root.findAllByType("span").filter((node) => node.children.includes(label));
308 expect(labels("2Spec")).toHaveLength(0);
309
310 await act(async () => {
311 mounted.update(
312 <ChatMarkdown
313 cwd="/tmp/project"
314 text={text}
315 skills={[
316 { name: "2spec", displayName: "2Spec" },
317 { name: "20k", displayName: "MoneySkill" },
318 ]}
319 />,
320 );
321 });
322 expect(labels("2Spec")).toHaveLength(1);
323 expect(labels("MoneySkill")).toHaveLength(0);
324
325 await act(async () => {
326 mounted.update(<ChatMarkdown cwd="/tmp/project" text={text} skills={[]} />);
327 });
328 expect(labels("2Spec")).toHaveLength(0);
329 } finally {
330 await act(async () => renderer?.unmount());
331 vi.unstubAllGlobals();
332 }
333 });
334});
335
296describe("ChatMarkdown file option chips", () => {336describe("ChatMarkdown file option chips", () => {
297 it("keeps the fallback button text selectable", () => {337 it("keeps the fallback button text selectable", () => {
298 const html = renderToStaticMarkup(338 const html = renderToStaticMarkup(
apps/web/src/components/chat/SkillInlineText.tsx+2−1
@@ -10,7 +10,8 @@ import { · 9 unmodified lines
10} from "../composerInlineChip";10} from "../composerInlineChip";
11import { cn } from "~/lib/utils";11import { cn } from "~/lib/utils";
12 12
13const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g;13const SKILL_TOKEN_REGEX =
14 /(^|\s)\$(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/g;
14 15
15type InlineSkill = Pick<ServerProviderSkill, "name" | "displayName">;16type InlineSkill = Pick<ServerProviderSkill, "name" | "displayName">;
16 17
apps/web/src/composer-editor-mentions.test.ts+23−0
@@ -198,6 +198,29 @@ describe("splitPromptIntoComposerSegments", () => { · 197 unmodified lines
198 ]);198 ]);
199 });199 });
200 200
201 it("splits digit-leading skill tokens into skill segments", () => {
202 expect(splitPromptIntoComposerSegments("Use $2spec please")).toEqual([
203 { type: "text", text: "Use " },
204 { type: "skill", name: "2spec" },
205 { type: "text", text: " please" },
206 ]);
207 });
208
209 it("keeps digits-only dollar amounts and compact monetary expressions as text", () => {
210 expect(splitPromptIntoComposerSegments("I'll pay $20 tomorrow")).toEqual([
211 { type: "text", text: "I'll pay $20 tomorrow" },
212 ]);
213 expect(splitPromptIntoComposerSegments("Budget is $20k tomorrow")).toEqual([
214 { type: "text", text: "Budget is $20k tomorrow" },
215 ]);
216 expect(splitPromptIntoComposerSegments("Cost is $100M total")).toEqual([
217 { type: "text", text: "Cost is $100M total" },
218 ]);
219 expect(splitPromptIntoComposerSegments("Limit is $1e6 here")).toEqual([
220 { type: "text", text: "Limit is $1e6 here" },
221 ]);
222 });
223
201 it("does not convert an incomplete trailing skill token", () => {224 it("does not convert an incomplete trailing skill token", () => {
202 expect(splitPromptIntoComposerSegments("Use $review-follow-up")).toEqual([225 expect(splitPromptIntoComposerSegments("Use $review-follow-up")).toEqual([
203 { type: "text", text: "Use $review-follow-up" },226 { type: "text", text: "Use $review-follow-up" },
packages/shared/src/composerInlineTokens.test.ts+20−0
@@ -31,6 +31,26 @@ describe("collectComposerInlineTokens", () => { · 30 unmodified lines
31 ]);31 ]);
32 });32 });
33 33
34 it("collects skill names that begin with a digit", () => {
35 expect(collectComposerInlineTokens("Use $2spec next")).toEqual([
36 {
37 type: "skill",
38 value: "2spec",
39 source: "$2spec",
40 start: 4,
41 end: 10,
42 },
43 ]);
44 });
45
46 it("leaves digits-only dollar amounts and compact monetary expressions as text", () => {
47 expect(collectComposerInlineTokens("I'll pay $20 tomorrow")).toEqual([]);
48 expect(collectComposerInlineTokens("Budget is $1_000 total")).toEqual([]);
49 expect(collectComposerInlineTokens("Budget is $20k tomorrow")).toEqual([]);
50 expect(collectComposerInlineTokens("Cost is $100M total")).toEqual([]);
51 expect(collectComposerInlineTokens("Limit is $1e6 here")).toEqual([]);
52 });
53
34 it("does not convert incomplete trailing tokens", () => {54 it("does not convert incomplete trailing tokens", () => {
35 expect(collectComposerInlineTokens("Use $ui")).toEqual([]);55 expect(collectComposerInlineTokens("Use $ui")).toEqual([]);
36 expect(collectComposerInlineTokens("Inspect @AGENTS.md")).toEqual([]);56 expect(collectComposerInlineTokens("Inspect @AGENTS.md")).toEqual([]);
packages/shared/src/composerInlineTokens.ts+9−1
@@ -18,7 +18,15 @@ export interface CollectComposerInlineTokensOptions { · 17 unmodified lines
18 readonly preserveTrailingFrom?: ReadonlyArray<ComposerInlineToken>;18 readonly preserveTrailingFrom?: ReadonlyArray<ComposerInlineToken>;
19}19}
20 20
21const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s)/g;21/**
22 * A skill name may start with a digit, but compact monetary amounts and
23 * numeric expressions like "$20", "$20k", "$100M", and "$1e6" must stay prose:
24 * the composer chips any matched `$name` token, known or not. Tokens beginning
25 * with digits must not match numbers with currency/exponent suffixes, and must
26 * contain at least one letter.
27 */
28const SKILL_TOKEN_REGEX =
29 /(^|\s)\$(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s)/g;
22const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g;30const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g;
23/**31/**
24 * The label body is bounded rather than `*`. Unbounded, every whitespace in32 * The label body is bounded rather than `*`. Unbounded, every whitespace in