Merge remote-tracking branch 'refs/remotes/origin/worktree/web-multimodal-image-input' into worktree/pr555-simplify

# Conflicts:
#	.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md
#	.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md
#	apps/cli/README.i18n.yaml
#	apps/cli/README.md
#	apps/cli/README.zh.md
#	apps/cli/src/app-cli-entry.ts
#	packages/client/connection/src/client/fixture.ts
#	packages/client/ui-conversation/src/client/service.ts
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/host.schema.ts
#	packages/host/apiproxy/src/api/host.ts
#	packages/host/apiproxy/tests/fetch-carrier.spec.ts
This commit is contained in:
Tianyi Cui
2026-07-30 01:16:58 +08:00
97 changed files with 1375 additions and 447 deletions

View 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'])
})
})

View File

@@ -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 ",
}
`)
})

View File

@@ -0,0 +1,3 @@
- listbox "Trigger suggestions":
- text: 技能
- option "policy-shared Available to both model and user invocation" [selected]

View File

@@ -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": [
{