Files
2026-07-13 12:20:01 +08:00

147 lines
7.1 KiB
TypeScript

import { readFileSync } from "fs"
import path from "path"
import { describe, expect, test } from "bun:test"
const SKILL_PATH = path.join(process.cwd(), "skills/ce-explain/SKILL.md")
const SKILL_BODY = readFileSync(SKILL_PATH, "utf8")
// Regression guard mirroring tests/skills/ce-plan-handoff-routing.test.ts
// (issue #714 class): SKILL.md content caches at session start while reference
// files load on demand, so the bare per-option action for the Phase 6
// destination ask and the outbound handoffs MUST live inline in SKILL.md —
// not solely in references/destinations.md. Symptom when this regresses: the
// agent renders the destination menu, the user picks an option, and the agent
// stops in prose without firing the action.
describe("ce-explain destination and handoff routing", () => {
const phaseStart = SKILL_BODY.indexOf("### Phase 6")
test("SKILL.md contains the Phase 6 destination-ask region", () => {
expect(
phaseStart,
"ce-explain SKILL.md no longer contains the '### Phase 6' heading — the test anchor needs updating, or the destination ask was removed.",
).toBeGreaterThan(-1)
})
const phaseRegion = phaseStart > -1 ? SKILL_BODY.slice(phaseStart) : ""
test("inline routing exists for every destination option", () => {
const optionFragments: { name: string; fragment: string }[] = [
{ name: "Artifact surface", fragment: "Artifact surface" },
{ name: "Local file", fragment: "Local file" },
{ name: "Publish to Proof", fragment: "Publish to Proof" },
{ name: "Send to Thinkroom", fragment: "Send to Thinkroom" },
{ name: "Leave it", fragment: "Leave it" },
]
for (const { name, fragment } of optionFragments) {
const escaped = fragment.replace(/[.*+?^${}()|[\]\\`]/g, "\\$&")
// Bullet form: `- **<fragment>**` then a separator and at least one
// non-newline character of action text on the SAME line ([ \t]*, not
// \s*, so an empty-action bullet cannot match by spilling into the next
// bullet's leading `-`). The separator requires surrounding whitespace
// (` — ` / ` - `) so a mid-word hyphen in a qualifier like
// "(auto-generated)" cannot satisfy the action-separator match.
const inlineRoutingPattern = new RegExp(
`^- \\*\\*[^\\n]*${escaped}[^\\n]*\\*\\*[^\\n]*[ \\t][—-][ \\t]+[^\\n]+`,
"m",
)
expect(
inlineRoutingPattern.test(phaseRegion),
`ce-explain SKILL.md Phase 6 is missing inline routing for destination option "${name}". The bare per-option action MUST live in SKILL.md (not solely in references/destinations.md). See docs/solutions/skill-design/post-menu-routing-belongs-inline.md.`,
).toBe(true)
}
})
test("ce-ideate and ce-simplify-code handoffs use the skill-invocation primitive", () => {
for (const target of ["ce-ideate", "ce-simplify-code"]) {
const bullet = phaseRegion.match(
new RegExp(`^- \\*\\*[^\\n]+\\*\\*[^\\n]*\`${target}\`[^\\n]+`, "m"),
)
expect(
bullet,
`ce-explain SKILL.md Phase 6 is missing the inline handoff bullet naming ${target}.`,
).not.toBeNull()
expect(
/skill[\s-]?invocation|Skill tool|skill primitive/i.test(bullet![0]),
`ce-explain SKILL.md ${target} handoff must name the skill-invocation primitive so the agent fires the invocation rather than announcing a handoff in prose.`,
).toBe(true)
}
})
test("ce-polish handoff is user-run, never skill-invoked", () => {
// ce-polish sets disable-model-invocation: true (pinned in
// EXPECTED_USER_INVOKED_SKILLS in tests/skill-conventions.test.ts), so the
// model cannot dispatch it via the Skill tool. The routing must present
// observations in chat and tell the user to run /ce-polish themselves.
const polishBullet = phaseRegion.match(/^- \*\*[^\n]*polish[^\n]*\*\*[^\n]+/im)
expect(
polishBullet,
"ce-explain SKILL.md Phase 6 is missing the inline UI/UX polish handoff bullet.",
).not.toBeNull()
const line = polishBullet![0]
expect(
/tell the user to run\s+`\/ce-polish`|user-invoked only/i.test(line),
"ce-explain SKILL.md polish handoff must present observations in chat and route to a user-run /ce-polish.",
).toBe(true)
expect(
/invoke the `ce-polish` skill/i.test(line),
"ce-explain SKILL.md polish handoff must NOT instruct invoking ce-polish via the skill primitive — it is user-invoked only (disable-model-invocation).",
).toBe(false)
})
test("predict-then-reveal ordering rule is inline in SKILL.md", () => {
// R13: the leak-proof ordering is load-bearing and must not live only in
// references/check-in.md, which an agent might not load before acting.
expect(
/end the turn/i.test(SKILL_BODY) &&
/before the user's prediction turn ends/i.test(SKILL_BODY),
"ce-explain SKILL.md must carry the predict-then-reveal ordering rule inline (show raw change only, take the prediction, end the turn).",
).toBe(true)
})
})
// Cross-file parity guard (issue #1057): SKILL.md Phase 3 and
// references/check-in.md deliberately BOTH carry the predict-then-reveal
// protocol — the inline copy is load-bearing (AGENTS.md: "Inline the Trigger,
// Not the Content"; the routing test above guards its presence), and the
// reference holds the on-demand detail. Two independently-editable copies of
// a safety-critical protocol can drift silently, so each load-bearing
// invariant must survive in both files. These are structural matches, not
// verbatim prose locks — the two copies already word the protocol slightly
// differently, and future wording improvements are fine as long as every
// invariant stays present in both.
describe("ce-explain predict-then-reveal parity between SKILL.md and references/check-in.md", () => {
const CHECK_IN_PATH = path.join(process.cwd(), "skills/ce-explain/references/check-in.md")
const CHECK_IN_BODY = readFileSync(CHECK_IN_PATH, "utf8")
const invariants: { name: string; pattern: RegExp }[] = [
{
name: "the prediction question (what the change does, and why it was made)",
pattern: /what do(?:es)?\s+(?:you think\s+)?this change do(?:es)?\b[\s\S]{0,40}?why (?:was it|it was) made/i,
},
{
name: "the turn-end rule (end the turn after the prediction prompt)",
pattern: /end the turn/i,
},
{
name: "the never-same-message rule (no explanation in the prediction-prompt message)",
pattern: /same message as the prediction prompt/i,
},
]
const copies: { label: string; body: string }[] = [
{ label: "SKILL.md", body: SKILL_BODY },
{ label: "references/check-in.md", body: CHECK_IN_BODY },
]
for (const { name, pattern } of invariants) {
for (const { label, body } of copies) {
test(`${label} carries ${name}`, () => {
expect(
pattern.test(body),
`ce-explain ${label} no longer carries ${name}. The predict-then-reveal protocol is duplicated across SKILL.md and references/check-in.md by design; if the wording changed, keep the invariant present in BOTH copies (matching ${pattern}) so the copies cannot drift apart silently.`,
).toBe(true)
})
}
}
})