Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e9eed2e
fix(skills): safely serialize skill frontmatter
taltas Jul 18, 2026
55d95f5
Merge remote-tracking branch 'origin/main' into fix/issue-859-skill-yaml
taltas Jul 21, 2026
d4f2ca3
fix(skills): report YAML frontmatter parse errors and serialize SKILL…
easonliang28 Aug 21, 2026
872179d
Merge remote-tracking branch 'upstream/main' into fix/skill-frontmatt…
easonliang28 Aug 21, 2026
6c2e44e
fix(i18n): add skill diagnostics translations and cover updateSkillModes
easonliang28 Aug 21, 2026
928f3bf
fix(webview): default skillDiagnostics to empty array in SkillsSettings
easonliang28 Aug 21, 2026
987fa9a
test(e2e): smoke test skill diagnostics across the extension-host bou…
easonliang28 Aug 21, 2026
65a0ff3
test(api): cover the test-only getSkillsState accessor
easonliang28 Aug 21, 2026
ae9b80a
test(webview): cover the remaining skill diagnostics patch lines
easonliang28 Aug 21, 2026
5153db6
fix(skills): serialize discovery scans, scope the quote hint, and par…
easonliang28 Aug 21, 2026
3c652b2
Merge branch 'main' into fix/skill-frontmatter-yaml-859
easonLiangWorldedtech Aug 23, 2026
a5cb89e
Merge branch 'main' into fix/skill-frontmatter-yaml-859
easonLiangWorldedtech Aug 30, 2026
e91e586
Merge branch 'main' into fix/skill-frontmatter-yaml-859
easonLiangWorldedtech Sep 1, 2026
a3f0dd9
Merge branch 'main' into fix/skill-frontmatter-yaml-859
easonLiangWorldedtech Sep 2, 2026
ce9297e
Merge branch 'main' into fix/skill-frontmatter-yaml-859
easonLiangWorldedtech Sep 3, 2026
b19eeb3
Merge branch 'main' into fix/skill-frontmatter-yaml-859
easonLiangWorldedtech Sep 5, 2026
6e683f9
Merge remote-tracking branch 'upstream/main' into fix/skill-frontmatt…
easonliang28 Sep 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions apps/vscode-e2e/src/suite/skills-diagnostics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import * as assert from "assert"
import * as fs from "fs/promises"
import * as path from "path"

import * as vscode from "vscode"

import { setDefaultSuiteTimeout } from "./test-utils"
import { waitFor } from "./utils"

const GOOD_SKILL = "e2e-skill-good"
const BAD_SKILL = "e2e-skill-bad"

// Issue #859 reproduction content: the description is a double-quoted YAML
// scalar whose inner double quotes are left unescaped, which makes the
// frontmatter unparseable.
const MALFORMED_SKILL_MD = `---
name: ${BAD_SKILL}
description: "Use when implementing features. Triggers on: "TDD", "test-driven development"
---
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# E2E Skill Bad

Instructions here.
`

const FIXED_SKILL_MD = `---
name: ${BAD_SKILL}
description: 'Use when implementing features. Triggers on: "TDD", "test-driven development"'
---

# E2E Skill Bad

Instructions here.
`

const GOOD_SKILL_MD = `---
name: ${GOOD_SKILL}
description: A healthy skill used by the skill diagnostics e2e smoke test.
---

# E2E Skill Good

Instructions here.
`

// Write a skill file atomically (write to a sidecar, then rename over the
// target) so the extension host's file watcher only ever observes complete
// content. An in-place fs.writeFile is visible mid-write, the watcher can
// fire for that moment, and - because discovery scans are serialized - a
// mid-write event could be the last one, leaving a stale scan result.
const writeSkillFileAtomic = async (finalPath: string, content: string): Promise<void> => {
const tmpPath = `${finalPath}.tmp`
await fs.writeFile(tmpPath, content, "utf8")
await fs.rename(tmpPath, finalPath)
}

suite("Roo Code Skill Diagnostics", function () {
setDefaultSuiteTimeout(this)

let skillsRoot: string

setup(async function () {
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
assert.ok(workspaceRoot, "e2e workspace folder must be open")
skillsRoot = path.join(workspaceRoot, ".roo", "skills")
})

teardown(async function () {
// Remove only the skill directories this suite created so pre-existing
// or other suites' fixtures under .roo/skills are left intact.
await Promise.all(
[GOOD_SKILL, BAD_SKILL].map((name) => fs.rm(path.join(skillsRoot, name), { recursive: true, force: true })),
)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

test("should surface a malformed SKILL.md as a diagnostic without hiding healthy skills", async function () {
this.timeout(180_000)

// Arrange: one healthy skill and one malformed skill on real disk in the
// workspace's .roo/skills directory, written atomically so the watcher
// only observes complete files.
await fs.mkdir(path.join(skillsRoot, GOOD_SKILL), { recursive: true })
await writeSkillFileAtomic(path.join(skillsRoot, GOOD_SKILL, "SKILL.md"), GOOD_SKILL_MD)
await fs.mkdir(path.join(skillsRoot, BAD_SKILL), { recursive: true })
const badSkillMd = path.join(skillsRoot, BAD_SKILL, "SKILL.md")
await writeSkillFileAtomic(badSkillMd, MALFORMED_SKILL_MD)

// Act: the extension host's file watcher re-discovers skills; wait until
// the real SkillsManager reports the healthy skill and a diagnostic for
// the malformed one.
await waitFor(
async () => {
const state = globalThis.api.getSkillsState()
const goodVisible = state.skills.some((skill) => skill.name === GOOD_SKILL)
const badDiagnosed = state.skillDiagnostics.some((diagnostic) => diagnostic.path.includes(BAD_SKILL))
return goodVisible && badDiagnosed
},
{ timeout: 60_000, interval: 500 },
)

// Assert: the malformed skill is skipped with a diagnostic pointing at it,
// while the healthy skill is unaffected.
const state = globalThis.api.getSkillsState()
const diagnostic = state.skillDiagnostics.find((d) => d.path.includes(BAD_SKILL))
assert.ok(diagnostic, "malformed SKILL.md should produce a diagnostic")
assert.strictEqual(diagnostic.source, "project")
assert.ok(diagnostic.message.length > 0, "diagnostic should carry the parse error message")
assert.ok(
state.skills.some((skill) => skill.name === GOOD_SKILL),
"healthy skill should still be discovered",
)
assert.ok(
!state.skills.some((skill) => skill.name === BAD_SKILL),
"malformed skill should be omitted from skills",
)

// Act: repair the frontmatter in place (atomically); the watcher
// re-discovers and the diagnostic clears.
await writeSkillFileAtomic(badSkillMd, FIXED_SKILL_MD)

await waitFor(
async () => {
const next = globalThis.api.getSkillsState()
const cleared = !next.skillDiagnostics.some((d) => d.path.includes(BAD_SKILL))
const loaded = next.skills.some((skill) => skill.name === BAD_SKILL)
return cleared && loaded
},
{ timeout: 60_000, interval: 500 },
)

const fixed = globalThis.api.getSkillsState()
assert.ok(
fixed.skills.some((skill) => skill.name === BAD_SKILL),
"fixed skill should load after repair",
)
assert.ok(fixed.skills.find((skill) => skill.name === BAD_SKILL)?.description.includes("TDD") === true)
})
})
10 changes: 10 additions & 0 deletions packages/types/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { RooCodeSettings } from "./global-settings.js"
import type { HistoryItem } from "./history.js"
import type { ProviderSettingsEntry, ProviderSettings } from "./provider-settings.js"
import type { IpcMessage, IpcServerEvents } from "./ipc.js"
import type { SkillDiagnostic, SkillMetadata } from "./skills.js"
import type { WebviewThemeFixture } from "./vscode-extension-host.js"

export type RooCodeAPIEvents = RooCodeEvents
Expand Down Expand Up @@ -58,6 +59,15 @@ export interface RooCodeAPI extends EventEmitter<RooCodeAPIEvents> {
* @returns The number of persisted API conversation history entries, or 0 if unavailable.
*/
getTaskApiConversationHistoryLength(taskId: string): Promise<number>
/**
* Returns the skill metadata and load diagnostics currently discovered by the
* extension host. Intended for use in tests only.
* @returns The discovered skills and any diagnostics for skills that failed to load.
*/
getSkillsState(): {
skills: SkillMetadata[]
skillDiagnostics: SkillDiagnostic[]
}
/**
* Checks for an ordered user turn and assistant tool call in persisted API history.
* @param taskId The ID of the task.
Expand Down
9 changes: 9 additions & 0 deletions packages/types/src/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ export interface SkillMetadata {
modeSlugs?: string[]
}

/** A user-actionable problem found while loading a SKILL.md file. */
export interface SkillDiagnostic {
path: string
source: "global" | "project"
message: string
line?: number
column?: number
}

/**
* Skill name validation constants per agentskills.io specification:
* https://agentskills.io/specification
Expand Down
3 changes: 2 additions & 1 deletion packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { OllamaModelsMessageType } from "./providers/ollama.js"
import { OpenAiModelsMessageType } from "./providers/openai.js"
import { VsCodeLmModelsMessageType } from "./providers/vscode-llm.js"
import type { OpenAiCodexRateLimitInfo } from "./providers/openai-codex-rate-limits.js"
import type { SkillMetadata } from "./skills.js"
import type { SkillDiagnostic, SkillMetadata } from "./skills.js"
import type { RuleMetadata } from "./rules.js"
import type { TelemetrySetting } from "./telemetry.js"
import type { WorktreeIncludeStatus } from "./worktree.js"
Expand Down Expand Up @@ -186,6 +186,7 @@ export interface ExtensionMessage {
list?: string[] // For dismissedUpsells
tools?: SerializedCustomToolDefinition[] // For customToolsResult
skills?: SkillMetadata[] // For skills response
skillDiagnostics?: SkillDiagnostic[] // For malformed skills omitted from the skills response
rules?: RuleMetadata[] // For rules response
modes?: { slug: string; name: string }[] // For modes response
rooHistoryImportProgress?: {
Expand Down
Loading
Loading