mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 06:54:50 +01:00
test: skillify E2E stops scripting the skill and routes with the shipped section (W7b skillify prompt + routing fixture; W7d skillify narration ban)
- CLAUDE.md fixture: the shipped ## Skill routing section replaces 'ALWAYS invoke it via the Skill tool as your FIRST action' and its hand-written rules. - scrape-match-path: prompt is 'Run /scrape latest hacker news stories'; the 'You MUST follow … 1. Run skill list … Do NOT enter the prototype phase' script is gone. Asserts the outcome: the bundled skill ran, no goto prototyping, no new skill or skillify staging written. Listing is logged. - scrape-prototype-path: drops the five-step script and the self-fulfilling 'Suggest /skillify'; keeps the user's JSON shape request. - skillify-provenance-refusal: prompt no longer dictates the refusal. - Narration grader moves to helpers/skill-body-narration.ts: line-start 'I '/'I'll '/'Let me ' plus mid-sentence 'let me <verb>', so 'let me know' is legal. Stored known-good/known-bad bodies show it still rejects every known-bad case the old /Let me /i ban caught.
This commit is contained in:
1 parent
af041beee1
commit
27e962b2b8
9 files changed
+92
-42
No files matched your search
@@ -0,0 +1,3 @@
|
||||
# items-scraper
|
||||
|
||||
I'll extract each title and score from the list.
|
||||
@@ -0,0 +1,3 @@
|
||||
# items-scraper
|
||||
|
||||
Let me run the script against the fixture first.
|
||||
@@ -0,0 +1,4 @@
|
||||
# items-scraper
|
||||
|
||||
I navigated to the fixture page and parsed the list.
|
||||
Run `$B skill run items-scraper`.
|
||||
@@ -0,0 +1,3 @@
|
||||
# items-scraper
|
||||
|
||||
Scrape the items list. Now let me check the score selector before saving.
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
# hackernews-titles
|
||||
|
||||
Scrape the titles and scores from the Hacker News front page.
|
||||
|
||||
## Triggers
|
||||
- "latest hacker news stories"
|
||||
- "let me know what's on HN"
|
||||
|
||||
## Usage
|
||||
Run `$B skill run hackernews-titles`. If the page layout changes, the script
|
||||
fails loudly; let me know-style requests from the user still route here.
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Transcript narration that leaked into a synthesized SKILL.md body: a line
|
||||
* opening with "I ", "I'll " or "Let me ", or "let me <verb>" mid-sentence.
|
||||
* "let me know" is ordinary instruction text and stays legal.
|
||||
*/
|
||||
export function hasNarrationLeak(body: string): boolean {
|
||||
return /^(?:I |I'll |Let me )/m.test(body) || /\blet me (?!know\b)/i.test(body);
|
||||
}
|
||||
@@ -884,24 +884,24 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
'scrape-match-path': [
|
||||
'scrape/**', 'browse/src/browser-skills.ts', 'browse/src/browser-skill-commands.ts',
|
||||
'browser-skills/hackernews-frontpage/**',
|
||||
'test/skill-e2e-skillify.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts'
|
||||
'test/skill-e2e-skillify.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts', 'test/helpers/skill-body-narration.ts'
|
||||
],
|
||||
'scrape-prototype-path': [
|
||||
'scrape/**', 'browse/src/browser-skills.ts', 'browse/src/browser-skill-commands.ts',
|
||||
'test/skill-e2e-skillify.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts'
|
||||
'test/skill-e2e-skillify.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts', 'test/helpers/skill-body-narration.ts'
|
||||
],
|
||||
'skillify-happy-path': [
|
||||
'skillify/**', 'scrape/**', 'browse/src/browser-skill-write.ts',
|
||||
'browse/src/browser-skills.ts', 'browse/src/browser-skill-commands.ts',
|
||||
'test/skill-e2e-skillify.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts'
|
||||
'test/skill-e2e-skillify.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts', 'test/helpers/skill-body-narration.ts'
|
||||
],
|
||||
'skillify-provenance-refusal': [
|
||||
'skillify/**', 'browse/src/browser-skill-write.ts',
|
||||
'test/skill-e2e-skillify.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts'
|
||||
'test/skill-e2e-skillify.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts', 'test/helpers/skill-body-narration.ts'
|
||||
],
|
||||
'skillify-approval-reject': [
|
||||
'skillify/**', 'scrape/**', 'browse/src/browser-skill-write.ts',
|
||||
'test/skill-e2e-skillify.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts'
|
||||
'test/skill-e2e-skillify.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts', 'test/helpers/skill-body-narration.ts'
|
||||
],
|
||||
|
||||
// Skill routing — journey-stage tests (depend on ALL skill descriptions)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { hasNarrationLeak } from './helpers/skill-body-narration';
|
||||
|
||||
const DIR = path.join(import.meta.dir, 'fixtures', 'skillify-narration');
|
||||
const read = (name: string) => fs.readFileSync(path.join(DIR, name), 'utf8');
|
||||
const KNOWN_BAD = fs.readdirSync(DIR).filter(name => name.startsWith('known-bad-')).sort();
|
||||
|
||||
describe('skillify narration grader', () => {
|
||||
test('accepts a clean body that says "let me know" in a trigger', () => {
|
||||
expect(hasNarrationLeak(read('known-good.md'))).toBe(false);
|
||||
});
|
||||
test('stored known-bad fixtures exist', () => {
|
||||
expect(KNOWN_BAD.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
test.each(KNOWN_BAD)('still rejects %s', (name) => {
|
||||
expect(hasNarrationLeak(read(name))).toBe(true);
|
||||
});
|
||||
test('the previous grader rejected every known-bad fixture and also the known-good one', () => {
|
||||
const previous = (body: string) => /^I /m.test(body) || /Let me /i.test(body) || /^I'll /m.test(body);
|
||||
for (const name of KNOWN_BAD) expect(previous(read(name))).toBe(true);
|
||||
expect(previous(read('known-good.md'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,8 @@ import { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { readShippedSkillRouting } from './helpers/shipped-skill-routing';
|
||||
import { hasNarrationLeak } from './helpers/skill-body-narration';
|
||||
|
||||
const evalCollector = createEvalCollector('e2e-skillify');
|
||||
|
||||
@@ -60,7 +62,7 @@ interface Workdir {
|
||||
* - bin/ scripts referenced by the preamble
|
||||
* - A scoped GSTACK_HOME under the workdir so on-disk artifacts are
|
||||
* contained and assertable
|
||||
* - A CLAUDE.md routing block instructing Skill-tool invocation
|
||||
* - A CLAUDE.md with the ## Skill routing section gstack ships
|
||||
*
|
||||
* `installSkills` lets each test pick the minimum surface (e.g., the
|
||||
* provenance-refusal scenario doesn't need /scrape).
|
||||
@@ -109,16 +111,10 @@ function setupSkillifyWorkdir(suffix: string, installSkills: string[] = ['scrape
|
||||
|
||||
fs.writeFileSync(path.join(workDir, 'CLAUDE.md'), `# Project Instructions
|
||||
|
||||
## Skill routing
|
||||
${readShippedSkillRouting().section}
|
||||
|
||||
When the user's request matches an available skill, ALWAYS invoke it via
|
||||
the Skill tool as your FIRST action.
|
||||
## Test environment
|
||||
|
||||
Key routing rules:
|
||||
- /scrape, "scrape", "get data from", "extract from" → invoke scrape
|
||||
- /skillify, "skillify", "codify this scrape" → invoke skillify
|
||||
|
||||
Environment:
|
||||
- GSTACK_HOME="${gstackHome}" for all gstack bin scripts.
|
||||
- bin scripts are at ./bin/ relative to this directory.
|
||||
- Browse binary is at ${browseBin} — assign to $B (e.g., \`B=${browseBin}\`).
|
||||
@@ -196,13 +192,8 @@ describeIfSelected('Browser-skills Phase 2a E2E (/scrape + /skillify)', [
|
||||
installBundledHackernewsSkill(workDir);
|
||||
|
||||
const result = await runSkillTest({
|
||||
prompt: `Run /scrape latest hacker news stories. Invoke /scrape via the Skill tool.
|
||||
You MUST follow the skill's match-phase logic:
|
||||
1. Run \`$B skill list\` to see what browser-skills are available
|
||||
2. Recognize that "latest hacker news stories" matches the bundled
|
||||
hackernews-frontpage skill's triggers
|
||||
3. Run \`$B skill run hackernews-frontpage\` and emit the JSON
|
||||
Do NOT enter the prototype phase. Do NOT use AskUserQuestion.`,
|
||||
prompt: `Run /scrape latest hacker news stories.
|
||||
This run is non-interactive; AskUserQuestion is unavailable.`,
|
||||
workingDirectory: workDir,
|
||||
env: { GSTACK_HOME: gstackHome },
|
||||
maxTurns: 12,
|
||||
@@ -214,18 +205,31 @@ Do NOT enter the prototype phase. Do NOT use AskUserQuestion.`,
|
||||
|
||||
logCost('scrape-match-path', result);
|
||||
|
||||
// Outcome: the matcher ran the bundled skill instead of prototyping, and
|
||||
// nothing new was written. Listing first is one valid way to match.
|
||||
const cmds = bashCommands(result);
|
||||
const listedSkills = cmds.some(c => /\bskill\s+list\b/.test(c));
|
||||
const ranBundledSkill = cmds.some(c => /\bskill\s+run\s+hackernews-frontpage\b/.test(c));
|
||||
const prototyped = cmds.some(c => /\$B\s+goto\b|\/browse\s+goto\b/.test(c));
|
||||
const newSkills = [workDir, gstackHome].flatMap((root) => {
|
||||
const skillsRoot = path.join(root, '.gstack', 'browser-skills');
|
||||
return fs.existsSync(skillsRoot) ? fs.readdirSync(skillsRoot).filter(d => !d.startsWith('.') && d !== 'hackernews-frontpage') : [];
|
||||
});
|
||||
const staged = [workDir, gstackHome].some((root) => {
|
||||
const stagingRoot = path.join(root, '.gstack', '.tmp');
|
||||
return fs.existsSync(stagingRoot) && fs.readdirSync(stagingRoot).some(d => d.startsWith('skillify-'));
|
||||
});
|
||||
console.log(`scrape-match-path: listed skills first = ${cmds.some(c => /\bskill\s+list\b/.test(c))}`);
|
||||
const exitOk = ['success', 'error_max_turns'].includes(result.exitReason);
|
||||
|
||||
recordE2E(evalCollector, 'scrape match-path routes to bundled skill', 'Phase 2a E2E', result, {
|
||||
passed: exitOk && listedSkills && ranBundledSkill,
|
||||
passed: exitOk && ranBundledSkill && !prototyped && newSkills.length === 0 && !staged,
|
||||
});
|
||||
|
||||
expect(exitOk).toBe(true);
|
||||
expect(listedSkills).toBe(true);
|
||||
expect(ranBundledSkill).toBe(true);
|
||||
expect(prototyped, 'matched skill must run without prototyping').toBe(false);
|
||||
expect(newSkills).toEqual([]);
|
||||
expect(staged).toBe(false);
|
||||
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {}
|
||||
}, CAPTURE_MS);
|
||||
|
||||
@@ -240,13 +244,8 @@ Do NOT enter the prototype phase. Do NOT use AskUserQuestion.`,
|
||||
|
||||
const result = await runSkillTest({
|
||||
prompt: `Run /scrape titles and scores from ${fileUrl}.
|
||||
Invoke /scrape via the Skill tool. Follow the skill's prototype-phase logic:
|
||||
1. \`$B skill list\` finds NO matching skill
|
||||
2. Drive: \`$B goto ${fileUrl}\` then \`$B html\` (or \`$B text\`)
|
||||
3. Parse the items (each has a title and a score)
|
||||
4. Emit JSON of the form {"items": [{"title": "...", "score": N}, ...], "count": N}
|
||||
5. Suggest /skillify in one line
|
||||
Do NOT use AskUserQuestion.`,
|
||||
Return JSON of the form {"items": [{"title": "...", "score": N}, ...], "count": N}.
|
||||
This run is non-interactive; AskUserQuestion is unavailable.`,
|
||||
workingDirectory: workDir,
|
||||
env: { GSTACK_HOME: gstackHome },
|
||||
maxTurns: 18,
|
||||
@@ -351,16 +350,13 @@ Do NOT halt for clarification.`,
|
||||
&& fs.existsSync(path.join(skillDir, '_lib', 'browse-client.ts'))
|
||||
&& fs.existsSync(path.join(skillDir, 'fixtures'));
|
||||
|
||||
// D2 enforcement: the SKILL.md prose body MUST NOT contain conversation
|
||||
// fragments. Cheap heuristic: it shouldn't have "I" or "Let me" or other
|
||||
// first-person/agent-narration markers.
|
||||
// D2 enforcement: the SKILL.md prose body must not contain conversation
|
||||
// fragments (stored good/bad bodies: skill-body-narration.test.ts).
|
||||
let prosesClean = false;
|
||||
if (hasAllFiles) {
|
||||
const skillMd = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf-8');
|
||||
const body = skillMd.split(/\n---\n/)[1] || '';
|
||||
prosesClean = !/^I /m.test(body)
|
||||
&& !/Let me /i.test(body)
|
||||
&& !/^I'll /m.test(body);
|
||||
prosesClean = !hasNarrationLeak(body);
|
||||
}
|
||||
|
||||
const exitOk = ['success', 'error_max_turns'].includes(result.exitReason);
|
||||
@@ -390,11 +386,8 @@ Do NOT halt for clarification.`,
|
||||
fs.mkdirSync(childHome, { recursive: true });
|
||||
|
||||
const result = await runSkillTest({
|
||||
prompt: `Run /skillify via the Skill tool. There has been NO prior /scrape
|
||||
in this conversation. Follow the skill's Step 1 (D1 provenance guard) literally:
|
||||
walk back through agent turns, find no /scrape result, refuse with the exact
|
||||
message the skill specifies, and stop. Do NOT synthesize anything. Do NOT
|
||||
write any files.`,
|
||||
prompt: `Run /skillify.
|
||||
This run is non-interactive; AskUserQuestion is unavailable.`,
|
||||
workingDirectory: workDir,
|
||||
env: {
|
||||
GSTACK_HOME: gstackHome,
|
||||
|
||||
Reference in new issue
Block a user