Merge remote-tracking branch 'origin/master' into feature/shared-cli-config-foundation
# Conflicts: # packages/host/apiproxy/README.i18n.yaml
This commit is contained in:
@@ -225,11 +225,12 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('loads a local skill via /skill: and delivers its body to the model as a user turn', async () => {
|
||||
// The whole manual-invocation path in one keyless boot: `ctx.get('skills')`
|
||||
// The whole user-only invocation path in one keyless boot: `ctx.get('skills')`
|
||||
// resolves in the shipped tree, the client-side `/skill:` command parses,
|
||||
// the local provider loads `scripted-skill` from the agents home, and the
|
||||
// rendered `<skill name="…">` block reaches the model — proven by the
|
||||
// scripted adapter echoing the fixture's body marker only when it arrives.
|
||||
// and the local provider admits a model-disabled skill by the omitted
|
||||
// `user-invocable` default. The rendered `<skill name="…">` block reaches
|
||||
// the model — proven by the scripted adapter echoing the fixture's body
|
||||
// marker only when it arrives.
|
||||
const output = await smoke({
|
||||
label: 'dsh skill',
|
||||
tempDirPrefix: 'dsh-tui-skill-',
|
||||
@@ -240,6 +241,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
|
||||
'---',
|
||||
'name: scripted-skill',
|
||||
'description: Keyless PTY proof that the skill command loads a local skill into the conversation.',
|
||||
'disable-model-invocation: true',
|
||||
'---',
|
||||
'',
|
||||
'SCRIPTED SKILL BODY MARKER',
|
||||
|
||||
@@ -41,6 +41,7 @@ const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi
|
||||
|
||||
type SnapshotMode = 'replay' | 'record' | 'refresh'
|
||||
type Composition = 'native' | 'code' | 'advanced'
|
||||
type ScenarioInteraction = 'skill-invocation-policy'
|
||||
|
||||
interface Scenario {
|
||||
name: string
|
||||
@@ -65,6 +66,8 @@ interface Scenario {
|
||||
* preview + locator while the program value stays whole.
|
||||
*/
|
||||
spillMaxInlineBytes?: number
|
||||
/** Run scenario-specific terminal input instead of replaying recorded user prompts. */
|
||||
interaction?: ScenarioInteraction
|
||||
}
|
||||
|
||||
const SCENARIOS: Scenario[] = [
|
||||
@@ -98,6 +101,14 @@ const SCENARIOS: Scenario[] = [
|
||||
recorded: true,
|
||||
seedWorkspace: true,
|
||||
},
|
||||
{
|
||||
name: 'skill-invocation-policy',
|
||||
composition: 'native',
|
||||
expectedTools: [],
|
||||
recorded: false,
|
||||
seedWorkspace: true,
|
||||
interaction: 'skill-invocation-policy',
|
||||
},
|
||||
{
|
||||
name: 'code-mode',
|
||||
composition: 'code',
|
||||
@@ -269,9 +280,10 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
|
||||
const dir = scenarioDir(scenario)
|
||||
const fixtureFile = join(dir, 'session.jsonl')
|
||||
const childFiles = childFixturePaths(scenario)
|
||||
const fixture = await readFile(fixtureFile, 'utf8')
|
||||
const prompts = userPrompts(fixture)
|
||||
expect(prompts.length, `${scenario.name} must carry at least one recorded user prompt`).toBeGreaterThan(0)
|
||||
const prompts = userPrompts(await readFile(fixtureFile, 'utf8'))
|
||||
if (scenario.interaction === undefined) {
|
||||
expect(prompts.length, `${scenario.name} must carry at least one recorded user prompt`).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
const cwd = await mkdtemp(join(SNAPSHOT_TMP_ROOT, `dsh-tui-snapshot-${scenario.name}-`))
|
||||
const displayCwd = `/tmp/${basename(cwd)}`
|
||||
@@ -310,6 +322,63 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
|
||||
})
|
||||
await settleTerminal(terminal)
|
||||
|
||||
let interactionSnapshot: string | undefined
|
||||
if (scenario.interaction === 'skill-invocation-policy') {
|
||||
terminal.send('/skill')
|
||||
await settleTerminal(terminal)
|
||||
const discovery = normalizeTerminalSnapshot(
|
||||
await terminal.snapshot({ includeScrollback: true }),
|
||||
cwd,
|
||||
displayCwd,
|
||||
)
|
||||
expect(discovery).toContain('user-only-skill')
|
||||
expect(discovery).not.toContain('model-only-skill')
|
||||
|
||||
terminal.send('\x03')
|
||||
await settleTerminal(terminal)
|
||||
const skillContext = ctx
|
||||
const skillTurnEnded = new Promise<void>((resolve) => {
|
||||
const detach = skillContext.on('session/event', (session, event) => {
|
||||
if (session !== agent.session || event.type !== 'turn/end') return
|
||||
detach()
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
terminal.send('/skill:user-only-skill')
|
||||
terminal.send('\r')
|
||||
await skillTurnEnded
|
||||
await agent.whenIdle()
|
||||
await settleTerminal(terminal)
|
||||
const loaded = normalizeTerminalSnapshot(
|
||||
await terminal.snapshot({ includeScrollback: true }),
|
||||
cwd,
|
||||
displayCwd,
|
||||
)
|
||||
expect(loaded).toContain('USER-ONLY SKILL LOADED')
|
||||
|
||||
terminal.send('/skill:model-only-skill')
|
||||
terminal.send('\r')
|
||||
await settleTerminal(terminal)
|
||||
const denied = normalizeTerminalSnapshot(
|
||||
await terminal.snapshot({ includeScrollback: true }),
|
||||
cwd,
|
||||
displayCwd,
|
||||
)
|
||||
expect(denied).toContain('model-only-skill')
|
||||
expect(denied).toContain('not available for user invocation.')
|
||||
expect(denied).not.toContain('MODEL-ONLY BODY MUST NOT LOAD')
|
||||
interactionSnapshot = [
|
||||
'=== skill autocomplete ===',
|
||||
discovery,
|
||||
'',
|
||||
'=== loaded exact invocation ===',
|
||||
loaded,
|
||||
'',
|
||||
'=== denied exact invocation ===',
|
||||
denied,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
let remainingPrompts = prompts
|
||||
if (scenario.enterPlanMode === true) {
|
||||
const firstPrompt = prompts[0]!
|
||||
@@ -392,7 +461,7 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
|
||||
}
|
||||
|
||||
expect(terminal.themeViolations(), `${scenario.name} must remain theme-agnostic`).toEqual([])
|
||||
const snapshot = normalizeTerminalSnapshot(
|
||||
const snapshot = interactionSnapshot ?? normalizeTerminalSnapshot(
|
||||
await terminal.snapshot({ includeScrollback: true }),
|
||||
cwd,
|
||||
displayCwd,
|
||||
|
||||
115
apps/web/tests/skill-invocation-policy.e2e.ts
Normal file
115
apps/web/tests/skill-invocation-policy.e2e.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
// Web e2e scenario: the real host filters skill.list to the model-and-user
|
||||
// intersection before the browser slash source renders candidates. A real
|
||||
// chromium connects a fresh workspace seeded with all four policy quadrants;
|
||||
// no model call is issued, so a stray stream fails loud on the open LLM seam.
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import {
|
||||
assertFixtureInventory,
|
||||
captureStableAria,
|
||||
compareOrRefreshGolden,
|
||||
launchWebScaffold,
|
||||
watchConsole,
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/skill-invocation-policy', import.meta.url))
|
||||
const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
interface SeedSkill {
|
||||
name: string
|
||||
description: string
|
||||
frontmatter: string
|
||||
}
|
||||
|
||||
const SKILLS: readonly SeedSkill[] = [
|
||||
{
|
||||
name: 'policy-shared',
|
||||
description: 'Available to both model and user invocation',
|
||||
frontmatter: '',
|
||||
},
|
||||
{
|
||||
name: 'policy-model-only',
|
||||
description: 'Available only to model invocation',
|
||||
frontmatter: 'user-invocable: false\n',
|
||||
},
|
||||
{
|
||||
name: 'policy-user-only',
|
||||
description: 'Available only to user invocation',
|
||||
frontmatter: 'disable-model-invocation: true\n',
|
||||
},
|
||||
{
|
||||
name: 'policy-trusted-only',
|
||||
description: 'Available only to trusted internal callers',
|
||||
frontmatter: 'disable-model-invocation: true\nuser-invocable: false\n',
|
||||
},
|
||||
]
|
||||
|
||||
async function seedSkills(workspaceCwd: string): Promise<void> {
|
||||
for (const skill of SKILLS) {
|
||||
const directory = join(workspaceCwd, 'workspace', '.agents', 'skills', skill.name)
|
||||
await mkdir(directory, { recursive: true })
|
||||
const policyLines = skill.frontmatter === '' ? [] : skill.frontmatter.trimEnd().split('\n')
|
||||
await writeFile(join(directory, 'SKILL.md'), [
|
||||
'---',
|
||||
`name: ${skill.name}`,
|
||||
`description: ${skill.description}`,
|
||||
...policyLines,
|
||||
'---',
|
||||
'',
|
||||
`# ${skill.name}`,
|
||||
'',
|
||||
].join('\n'))
|
||||
}
|
||||
}
|
||||
|
||||
describe('web e2e: skill invocation policy through the real host', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
await seedSkills(scaffold.workspaceCwd)
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('renders only the model-and-user intersection in slash candidates', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-invocation-policy'))
|
||||
const input = page.locator('textarea').first()
|
||||
await input.fill('/policy')
|
||||
const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
|
||||
await expect.poll(
|
||||
() => menu.getByRole('option', { name: /policy-shared/ }).count(),
|
||||
{ timeout: 10_000 },
|
||||
).toBe(1)
|
||||
|
||||
expect(await menu.getByRole('option', { name: /policy-model-only/ }).count()).toBe(0)
|
||||
expect(await menu.getByRole('option', { name: /policy-user-only/ }).count()).toBe(0)
|
||||
expect(await menu.getByRole('option', { name: /policy-trusted-only/ }).count()).toBe(0)
|
||||
|
||||
const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['menu.expected.md'])
|
||||
})
|
||||
})
|
||||
@@ -2,8 +2,9 @@
|
||||
// Assembled keyless snapshot of the slash/input/session convergence under the
|
||||
// agent-parity model: the New Session view state locks the composer until a
|
||||
// Workspace is picked (connectWorkspace materializes the full Session+Agent),
|
||||
// the '/' menu serves the session's wire command catalog (sessions are always
|
||||
// agent-backed — no draft/materialized split), a leadingInput command claims,
|
||||
// the '/' menu renders the session's skill and wire command catalogs
|
||||
// (sessions are always agent-backed — no draft/materialized split), a skill
|
||||
// pick inserts its reference, a leadingInput command claims,
|
||||
// submits over the wire, and notices its result, and the SAME composer
|
||||
// textarea then carries the first plain send, whose ACCEPTANCE (not attempt)
|
||||
// flips blank and surfaces the session in lists. This is the user-visible
|
||||
@@ -119,7 +120,7 @@ async function typeComposer(composer: HTMLTextAreaElement, value: string): Promi
|
||||
await waitFor(() => { expect(composer.value).toBe(value) })
|
||||
}
|
||||
|
||||
it('locked view state, connectWorkspace unlock, /echo claim chain, and blank-on-acceptance ride one resident composer', async () => {
|
||||
it('locked view state, skill discovery, /echo claim chain, and blank-on-acceptance ride one resident composer', async () => {
|
||||
boot('?fixture=empty')
|
||||
|
||||
// View state: no session entity — the composer renders locked; only the
|
||||
@@ -145,6 +146,19 @@ it('locked view state, connectWorkspace unlock, /echo claim chain, and blank-on-
|
||||
)
|
||||
expect(composer.disabled).toBe(false)
|
||||
|
||||
// The built skill plugin prewarms the fixture's session-addressed catalog;
|
||||
// this pins client rendering and picking, while the real-host browser lane
|
||||
// owns policy filtering. Picking inserts the literal reference into the
|
||||
// resident composer.
|
||||
await typeComposer(composer, '/fixture')
|
||||
const skillMenu = await screen.findByRole('listbox', { name: 'Trigger suggestions' })
|
||||
const skillOption = await within(skillMenu).findByRole('option', { name: /fixture-demo/ })
|
||||
const skillMenuText = visibleText(skillMenu)
|
||||
fireEvent.mouseDown(skillOption)
|
||||
await waitFor(() => { expect(composer.value).toBe('/fixture-demo ') })
|
||||
const pickedSkill = composer.value
|
||||
await typeComposer(composer, '')
|
||||
|
||||
// '/' opens the menu with the session's wire command catalog (the session
|
||||
// is agent-backed from birth — the catalog is the single-address list).
|
||||
await typeComposer(composer, '/')
|
||||
@@ -183,6 +197,8 @@ it('locked view state, connectWorkspace unlock, /echo claim chain, and blank-on-
|
||||
menuHadEcho: menuText.includes('echo'),
|
||||
menuHadCompact: menuText.includes('compact'),
|
||||
composerSurvivedConversion: after === before,
|
||||
skillMenuHadFixtureDemo: skillMenuText.includes('fixture-demo'),
|
||||
skillPickInserted: pickedSkill,
|
||||
sessionListed: visibleText(within(tree).getByText('1 session').closest('[role="treeitem"]')!),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
@@ -190,6 +206,8 @@ it('locked view state, connectWorkspace unlock, /echo claim chain, and blank-on-
|
||||
"menuHadCompact": true,
|
||||
"menuHadEcho": true,
|
||||
"sessionListed": "nova1 session",
|
||||
"skillMenuHadFixtureDemo": true,
|
||||
"skillPickInserted": "/fixture-demo ",
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
- listbox "Trigger suggestions":
|
||||
- text: 技能
|
||||
- option "policy-shared Available to both model and user invocation" [selected]
|
||||
@@ -36,7 +36,8 @@
|
||||
"tests/sidebar-scrollbar.e2e.ts",
|
||||
"tests/code-mode-round.e2e.ts",
|
||||
"tests/cordis-tool-round.e2e.ts",
|
||||
"tests/message-actions.e2e.ts"
|
||||
"tests/message-actions.e2e.ts",
|
||||
"tests/skill-invocation-policy.e2e.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user