Merge remote-tracking branch 'origin/master' into turtle/preview-codemirror-slow-e4bae59874fa

This commit is contained in:
Turtle committed 2026-10-22 15:44:16 +08:00
commit ca28551037
82 files changed
+2790 -384

No files matched your search

@@ -0,0 +1,9 @@
# Plugin header natural scrolling
- Title / 1280px: resting title 28, actions 28, tabs none; pinned actions 16, tabs none, bar 64; stable range, reversible coordinates, same controls.
- Title / 600px: resting title 28, actions 28, tabs none; pinned actions 16, tabs none, bar 64; stable range, reversible coordinates, same controls.
- Title / 390px: resting title 28, actions 96, tabs none; pinned actions 16, tabs none, bar 64; stable range, reversible coordinates, same controls.
- Tabs / 1280px: resting title 28, actions 28, tabs 96; pinned actions 17, tabs 16, bar 66; stable range, reversible coordinates, same controls.
- Tabs / 600px: resting title 28, actions 97, tabs 96; pinned actions 17, tabs 16, bar 66; stable range, reversible coordinates, same controls.
- Tabs / 470px: resting title 28, actions 97, tabs 96; pinned actions 17, tabs 16, bar 66; stable range, reversible coordinates, same controls.
- Tabs / 390px: resting title 28, actions 146, tabs 96; pinned actions 66, tabs 16, bar 114; stable range, reversible coordinates, same controls.
@@ -4,16 +4,16 @@ en 1680px: actions beside the heading, single-line; contained; information follo
en 768px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained
en 520px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained
en 440px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained
en 390px: actions below the heading, single-line; contained; information follows subtitle; focus retained
en 430px: actions below the heading, single-line; contained; information follows subtitle; focus retained
en 390px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained
en 430px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained
en 1024px: actions beside the heading, single-line; contained; information follows subtitle; focus retained
zh 1680px: actions beside the heading, single-line; contained; information follows subtitle; focus retained
zh 768px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained
zh 520px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained
zh 440px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained
zh 390px: actions below the heading, single-line; contained; information follows subtitle; focus retained
zh 430px: actions below the heading, single-line; contained; information follows subtitle; focus retained
zh 390px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained
zh 430px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained
zh 1024px: actions beside the heading, single-line; contained; information follows subtitle; focus retained
A forced no-wrap layout at 390px fails the same measured layout check in both locales; restoring wrap passes.
A forced no-wrap layout at 240px fails the same measured layout check in both locales; restoring wrap and 390px passes.
Add menu dismisses on Escape and returns focus in both locales.
+191 -12
View File
@@ -107,8 +107,10 @@ describe('web e2e: plugin manager', () => {
const headerScaffold = await launchWebScaffold()
try {
// Pin the extremes; at intermediate widths, the rendered font and locale
// determine whether the two intrinsic items fit beside each other.
const PINNED_PLACEMENT: Record<number, 'beside' | 'below'> = { 1680: 'beside', 1024: 'beside', 390: 'below', 430: 'below' }
// determine whether the two intrinsic items fit beside each other. Narrow
// widths shrink the actions to icons before they wrap; the collapsed-bar case
// pins those widths.
const PINNED_PLACEMENT: Record<number, 'beside' | 'below'> = { 1680: 'beside', 1024: 'beside' }
for (const language of ['en', 'zh'] as const) {
const context = await browser.newContext({
viewport: { width: 1680, height: 1000 }, locale: language === 'en' ? 'en-US' : ZH_BROWSER_LOCALE,
@@ -121,7 +123,7 @@ describe('web e2e: plugin manager', () => {
const title = language === 'en' ? 'Plugins' : '插件'
await probe.getByRole('button', { name: title, exact: true }).click()
const panel = probe.locator('[data-plugin-panel]')
const header = panel.locator(':scope > header')
const header = panel.locator('[data-plugin-bar] > header')
await header.getByRole('heading', { name: title, exact: true }).waitFor()
await probe.evaluate(() => document.fonts.ready)
const add = header.getByRole('button', { name: language === 'en' ? 'Add plugin' : '添加插件', exact: true })
@@ -129,13 +131,23 @@ describe('web e2e: plugin manager', () => {
await add.focus()
const measure = () => header.evaluate((element) => {
const heading = element.firstElementChild!
const actions = element.lastElementChild!
const headingRect = heading.getBoundingClientRect()
const actions = element.querySelector('[data-plugin-toolbar]')!
const titleRect = heading.querySelector('h1')!.getBoundingClientRect()
const introRect = heading.querySelector('h1 + div')!.getBoundingClientRect()
const headingRect = {
right: Math.max(titleRect.right, introRect.right),
bottom: Math.max(titleRect.bottom, introRect.bottom),
width: Math.max(titleRect.right, introRect.right) - Math.min(titleRect.left, introRect.left),
}
const actionRect = actions.getBoundingClientRect()
const headerRect = element.getBoundingClientRect()
const intrinsicHeading = heading.cloneNode(true) as HTMLElement
intrinsicHeading.querySelectorAll('[data-plugin-header-part]').forEach((part) => {
part.removeAttribute('data-plugin-header-part')
part.removeAttribute('style')
})
Object.assign(intrinsicHeading.style, {
position: 'absolute', width: 'max-content', maxWidth: 'none', visibility: 'hidden', pointerEvents: 'none',
display: 'block', position: 'absolute', width: 'max-content', maxWidth: 'none', visibility: 'hidden', pointerEvents: 'none',
})
element.append(intrinsicHeading)
let intrinsicHeadingWidth: number
@@ -194,10 +206,12 @@ describe('web e2e: plugin manager', () => {
if (pinned !== undefined) expect(placement).toBe(pinned)
facts.push(`${language} ${width}px: actions ${pinned === undefined ? 'beside or below (according to intrinsic fit)' : placement} the heading, single-line; contained; information follows subtitle; focus retained`)
}
await probe.setViewportSize({ width: 390, height: 1000 })
// Force less room than even the icon actions need beside the heading.
await probe.setViewportSize({ width: 240, height: 1000 })
const previousWrap = await header.evaluate((element) => {
const style = element.style
const previous = { value: style.getPropertyValue('flex-wrap'), priority: style.getPropertyPriority('flex-wrap') }
element.parentElement!.removeAttribute('data-natural')
style.setProperty('flex-wrap', 'nowrap')
return previous
})
@@ -210,8 +224,10 @@ describe('web e2e: plugin manager', () => {
await header.evaluate((element, previous) => {
if (previous.value === '') element.style.removeProperty('flex-wrap')
else element.style.setProperty('flex-wrap', previous.value, previous.priority)
element.parentElement!.setAttribute('data-natural', '')
}, previousWrap)
}
await probe.setViewportSize({ width: 390, height: 1000 })
await expect.poll(async () => validLayout(await measure())).toBe(true)
const menuTrigger = header.getByRole('button', { name: language === 'en' ? 'Choose how to add a plugin' : '选择添加插件方式' })
await menuTrigger.click()
@@ -226,7 +242,7 @@ describe('web e2e: plugin manager', () => {
}
await compareOrRefreshGolden(
join(SNAPSHOT_DIR, 'header-responsive.expected.md'),
`# Plugin header responsive layout\n\n${facts.join('\n')}\n\nA forced no-wrap layout at 390px fails the same measured layout check in both locales; restoring wrap passes.\nAdd menu dismisses on Escape and returns focus in both locales.`,
`# Plugin header responsive layout\n\n${facts.join('\n')}\n\nA forced no-wrap layout at 240px fails the same measured layout check in both locales; restoring wrap and 390px passes.\nAdd menu dismisses on Escape and returns focus in both locales.`,
MODE,
)
} finally {
@@ -234,6 +250,166 @@ describe('web e2e: plugin manager', () => {
}
})
it('uses icon actions before shrinking tabs and wraps only when both still cannot fit', async () => {
const compactScaffold = await launchWebScaffold({
profile: { packages: [], bundles: ['@deepseek-ai/dsh-experimental-plugin-market-bundle'] },
})
const context = await browser.newContext({ viewport: { width: 1280, height: 480 }, locale: 'en-US', reducedMotion: 'reduce' })
try {
const probe = await context.newPage()
await probe.goto(compactScaffold.authenticatedUrl, { waitUntil: 'load' })
await probe.getByRole('button', { name: 'Plugins', exact: true }).click()
const panel = probe.locator('[data-plugin-panel]')
const bar = panel.locator('[data-plugin-bar]')
const body = panel.locator('[data-plugin-body]')
const refresh = bar.getByRole('button', { name: 'Refresh', exact: true })
const add = bar.getByRole('button', { name: 'Add plugin', exact: true })
await panel.locator('[data-plugin-package]').first().waitFor()
await bar.getByRole('tablist').waitFor()
await add.focus()
for (const [width, icons, narrowTabs, wraps] of [
[1280, false, false, false], [600, true, false, false],
[470, true, true, false], [390, true, true, true], [300, true, true, true],
] as const) {
await probe.setViewportSize({ width, height: 480 })
await body.evaluate((element) => { element.scrollTop = 180 })
await probe.waitForFunction(() => document.querySelector('[data-plugin-bar]')?.hasAttribute('data-compact'))
await expect.poll(() => bar.evaluate((element) => {
const tablist = element.querySelector('[role="tablist"]')!
const toolbar = element.querySelector('[data-plugin-toolbar]')!
const action = toolbar.querySelector('button')!
const tabRect = tablist.getBoundingClientRect(), actions = toolbar.getBoundingClientRect()
const actionRect = action.getBoundingClientRect()
const wrapped = actions.top >= tabRect.bottom
return {
icons: getComputedStyle(action.lastElementChild!).display === 'none',
narrowTabs: tabRect.width < 240,
wraps: wrapped,
centered: wrapped || Math.abs(tabRect.top + tabRect.height / 2 - actionRect.top - actionRect.height / 2) < 1,
}
}), { message: `compact toolbar at ${width}px` }).toEqual({ icons, narrowTabs, wraps, centered: true })
expect(await add.evaluate(element => element === document.activeElement)).toBe(true)
// The collapsed bar hides the title visually but keeps the page's heading for assistive technology.
expect(await bar.getByRole('heading', { name: 'Plugins', level: 1, exact: true }).count()).toBe(1)
}
// A short list keeps its range even when the available travel cannot fully compact the tabs.
await probe.setViewportSize({ width: 1280, height: 480 })
await body.evaluate((element) => { element.scrollTop = 0 })
await expect.poll(() => bar.evaluate(element => element.hasAttribute('data-compact'))).toBe(false)
const range = await body.evaluate(element => element.scrollHeight - element.clientHeight)
await probe.setViewportSize({ width: 1280, height: 480 + range - 30 })
await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight)).toBe(30)
await probe.mouse.move(800, 400)
await probe.mouse.wheel(0, 200)
await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(30)
expect(await body.evaluate(element => element.scrollHeight - element.clientHeight)).toBe(30)
expect(await bar.evaluate(element => element.hasAttribute('data-compact'))).toBe(false)
await probe.setViewportSize({ width: 390, height: 480 })
// Locator hover scrolls its target into view, and scrolling this list moves the header controls away from
// the pointer; once the resized header stops moving, move the pointer onto each control instead.
const pointAt = async (target: Locator): Promise<void> => {
await expect.poll(() => target.evaluate(element => new Promise<boolean>((resolve) => {
const before = element.getBoundingClientRect()
requestAnimationFrame(() => requestAnimationFrame(() => {
const after = element.getBoundingClientRect()
resolve(before.x === after.x && before.y === after.y)
}))
}))).toBe(true)
const bounds = await target.boundingBox()
if (bounds === null) throw new Error('Header control is not visible')
await probe.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2)
}
await pointAt(refresh)
await probe.getByRole('tooltip', { name: 'Refresh', exact: true }).waitFor()
await pointAt(add)
await probe.getByRole('tooltip', { name: 'Add plugin', exact: true }).waitFor()
await bar.getByRole('button', { name: 'Choose how to add a plugin', exact: true }).click()
await probe.getByRole('menu').waitFor()
await probe.keyboard.press('Escape')
await probe.getByRole('menu').waitFor({ state: 'hidden' })
await add.click()
await probe.getByRole('dialog').waitFor()
} finally {
await context.close()
await compactScaffold.close()
}
})
it('keeps the plugin page title and actions pinned while the cards scroll', async () => {
await page.setViewportSize({ width: 1000, height: 480 })
const panel = await openPluginsPanel()
const body = panel.locator('[data-plugin-body]')
try {
await panel.locator('[data-plugin-package]').first().waitFor()
const headerTop = () => panel.locator('[data-plugin-bar] > header').evaluate(element => element.getBoundingClientRect().top)
const bar = panel.locator('[data-plugin-bar]')
const barHeight = () => bar.evaluate(element => element.getBoundingClientRect().height)
const resting = await headerTop()
const restingHeight = await barHeight()
const refreshButton = bar.getByRole('button', { name: '刷新', exact: true })
const addButton = bar.getByRole('button', { name: '添加插件', exact: true })
const restingActions = await Promise.all([refreshButton.boundingBox(), addButton.boundingBox()])
expect(resting).toBe(await panel.evaluate(element => element.getBoundingClientRect().top))
const restingBarBottom = await bar.evaluate(element => element.getBoundingClientRect().bottom)
expect(await body.locator('[data-plugin-group]').first().evaluate(element => element.getBoundingClientRect().top))
.toBeCloseTo(restingBarBottom + 32, 1)
await body.evaluate((element) => { element.scrollTop = element.scrollHeight })
const bottom = await body.evaluate(element => element.scrollTop)
expect(bottom).toBeGreaterThan(100)
expect(await headerTop()).toBe(resting)
// The painted bar compacts while its document-flow height remains unchanged.
await page.waitForFunction(() => document.querySelector('[data-plugin-bar]')?.hasAttribute('data-compact') === true)
expect(await barHeight()).toBe(restingHeight)
expect(await bar.evaluate(element => getComputedStyle(element, '::after').borderBottomWidth)).not.toBe('0px')
expect(await bar.evaluate(element => Number.parseFloat(getComputedStyle(element, '::before').height))).toBe(64)
expect(await body.evaluate(element => element.getBoundingClientRect().top)).toBe(resting)
expect(await Promise.all([refreshButton.boundingBox(), addButton.boundingBox()]))
.toEqual(restingActions.map((bounds) => {
if (bounds === null) throw new Error('Plugin toolbar action is not visible')
return { ...bounds, y: bounds.y - 12 }
}))
// Reverse keyboard navigation scrolls each focused control into view below the bar, never under it.
await panel.locator('[data-plugin-package] [role="switch"]').last().focus()
const barBottom = await bar.evaluate(element => element.getBoundingClientRect().top + Number.parseFloat(getComputedStyle(element, '::before').height))
const hidden: string[] = []
for (let step = 0; step < 30; step++) {
await page.keyboard.press('Shift+Tab')
const focus = await page.evaluate(() => {
const element = document.activeElement
const box = element?.getBoundingClientRect()
return {
inBody: element?.closest('[data-plugin-body]') !== null && element?.closest('[data-plugin-bar]') === null,
top: box?.top ?? 0,
name: element?.getAttribute('aria-label') ?? element?.textContent ?? '',
}
})
if (!focus.inBody) break
if (focus.top < barBottom) hidden.push(`${focus.name} at ${String(focus.top)}`)
}
expect(hidden).toEqual([])
expect(await body.evaluate(element => element.scrollTop)).toBeLessThan(bottom)
expect(await headerTop()).toBe(resting)
await page.setViewportSize({ width: 1680, height: 480 })
await body.evaluate((element) => { element.scrollTop = element.scrollHeight })
await page.waitForFunction(() => document.querySelector('[data-plugin-bar]')?.hasAttribute('data-compact') === true)
await expect.poll(() => panel.evaluate((element) => {
const bounds = element.getBoundingClientRect()
const barElement = element.querySelector('[data-plugin-bar]')!
const barBounds = barElement.getBoundingClientRect()
const bodyBounds = element.querySelector('[data-plugin-body]')!.getBoundingClientRect()
const dividerStyle = getComputedStyle(barElement, '::after')
return {
left: barBounds.left + Number.parseFloat(dividerStyle.left) - bounds.left,
right: barBounds.right - Number.parseFloat(dividerStyle.right) - bounds.right,
gap: bodyBounds.top - barBounds.top,
}
})).toEqual({ left: 0, right: 0, gap: 0 })
} finally {
await body.evaluate((element) => { element.scrollTop = 0 })
await page.setViewportSize({ width: 1680, height: 1000 })
}
})
it('aligns the first-read skeleton with the loaded plugin cards', async () => {
const facts: string[] = []
let aria = ''
@@ -263,7 +439,7 @@ describe('web e2e: plugin manager', () => {
expect(await skeleton.getAttribute('role')).toBe('status')
expect(await skeleton.getAttribute('aria-label')).toBe('正在读取插件…')
expect(await panel.getAttribute('aria-busy')).toBe('true')
const actions = panel.locator(':scope > header > div:last-child button')
const actions = panel.locator('[data-plugin-bar] > header > div:last-child button')
expect(await actions.count()).toBe(3)
for (const action of await actions.all()) expect(await action.isDisabled()).toBe(true)
expect(await panel.getByRole('button', { name: '插件说明' }).isEnabled()).toBe(true)
@@ -278,7 +454,7 @@ describe('web e2e: plugin manager', () => {
return { x, y, width, height }
}
return {
pageHeader: rect(element.closest('[data-plugin-panel]')?.querySelector(':scope > header')),
pageHeader: rect(element.closest('[data-plugin-panel]')?.querySelector('[data-plugin-bar] > header')),
groupHeader: rect(element.firstElementChild),
rows: Array.from(element.querySelectorAll(selector)).slice(0, 4).map((row) => {
const head = row.firstElementChild
@@ -559,7 +735,9 @@ describe('web e2e: plugin manager', () => {
await probe.getByRole('tooltip').filter({ hasText: '刷新' }).waitFor()
facts.push('Mouse hover after touch: refresh tooltip visible')
await probe.mouse.move(0, 843)
expect(await help.count()).toBe(0)
// Tab moved focus to the icon-only Add plugin button, whose focus tooltip stays; the hovered ones leave.
expect(await probe.getByRole('tooltip').filter({ hasText: '刷新' }).count()).toBe(0)
expect(await probe.getByRole('tooltip').filter({ hasText: '在这里配置官方插件' }).count()).toBe(0)
await input.detach()
await probe.clock.resume()
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'touch-tooltips.expected.md'),
@@ -1258,7 +1436,8 @@ describe('web e2e: plugin manager', () => {
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, [
'manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md', 'exports.expected.md', 'exports-en.expected.md', 'icons.expected.md', 'loading.expected.md', 'refresh.expected.md',
'ime-enter.expected.md', 'header-responsive.expected.md', 'preview.expected.md', 'touch-tooltips.expected.md',
'ime-enter.expected.md', 'header-responsive.expected.md', 'header-natural-motion.expected.md', 'preview.expected.md',
'touch-tooltips.expected.md',
])
})
})
+218
View File
@@ -0,0 +1,218 @@
import { fileURLToPath } from 'node:url'
import { chromium, type Page } from 'playwright'
import { expect, it } from 'vitest'
import { compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode } from './scaffold.ts'
const EXPECTED = fileURLToPath(new URL('./expected/plugin-manager/header-natural-motion.expected.md', import.meta.url))
/** Measure the accepted resting or compact flex layout without changing the live header. */
async function reference(page: Page, compact: boolean) {
return page.locator('[data-plugin-bar]').evaluate((bar, compact) => {
const clone = bar.cloneNode(true) as HTMLElement
clone.removeAttribute('data-natural')
clone.toggleAttribute('data-compact', compact)
clone.setAttribute('data-measuring', '')
clone.inert = true
clone.style.width = `${bar.getBoundingClientRect().width}px`
clone.querySelectorAll('[data-plugin-header-part]').forEach((element) => { element.removeAttribute('style') })
bar.parentElement!.append(clone)
try {
const origin = clone.querySelector('header')!.getBoundingClientRect()
const box = (selector: string) => {
const element = clone.querySelector(selector)
if (element === null) return null
const rect = element.getBoundingClientRect()
return { x: rect.x - origin.x, y: rect.y - origin.y, width: rect.width, height: rect.height }
}
return {
height: clone.getBoundingClientRect().height,
title: box('h1')!, intro: box('[data-plugin-header-part="intro"]')!,
toolbar: box('[data-plugin-toolbar]')!, tabs: box('[role="tablist"]'),
}
} finally { clone.remove() }
}, compact)
}
/** Wait for scroll delivery before sampling the resulting layout. */
async function scrollTo(page: Page, offset: number): Promise<void> {
await page.locator('[data-plugin-body]').evaluate(async (body, offset) => {
body.scrollTop = offset
await new Promise<void>((resolve) => {
requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() }) })
})
}, offset)
}
async function geometry(page: Page) {
return page.locator('[data-plugin-bar]').evaluate((bar) => {
const origin = bar.querySelector('header')!.getBoundingClientRect()
const body = bar.parentElement!
const box = (selector: string) => {
const element = bar.querySelector(selector)
if (element === null) return null
const rect = element.getBoundingClientRect()
return { x: rect.x - origin.x, y: rect.y - origin.y, width: rect.width, height: rect.height }
}
return {
title: box('h1')!, intro: box('[data-plugin-header-part="intro"]')!,
toolbar: box('[data-plugin-toolbar]')!, tabs: box('[role="tablist"]'),
barHeight: bar.getBoundingClientRect().height,
paintHeight: Number.parseFloat(getComputedStyle(bar, '::before').height),
travel: Number.parseFloat((bar as HTMLElement).style.getPropertyValue('--plugin-header-travel')),
range: body.scrollHeight - body.clientHeight, offset: body.scrollTop,
first: body.querySelector('[data-plugin-group]')!.getBoundingClientRect().top,
}
})
}
it('preserves both header layouts and follows the scroll position without changing the scroll range', async () => {
const browser = await chromium.launch()
const facts: string[] = []
try {
for (const hasTabs of [false, true]) {
const scaffold = await launchWebScaffold(hasTabs ? {
profile: { packages: [], bundles: ['@deepseek-ai/dsh-experimental-plugin-market-bundle'] },
} : {})
try {
const page = await browser.newPage({ viewport: { width: 1280, height: 480 }, locale: 'en-US' })
const consoleWatch = watchConsole(page)
try {
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.getByRole('button', { name: 'Plugins', exact: true }).click()
await page.locator('[data-plugin-package]').first().waitFor()
await page.locator('[data-plugin-bar][data-natural]').waitFor()
await page.evaluate(() => document.fonts.ready)
// Without tabs, whether the actions fit beside the title at 470px depends on the platform font,
// so that width is pinned only with tabs.
for (const width of hasTabs ? [1280, 600, 470, 390] : [1280, 600, 390]) {
await page.setViewportSize({ width, height: 480 })
await scrollTo(page, 0)
const resting = await reference(page, false)
const compact = await reference(page, true)
const initial = await geometry(page)
expect(initial.title).toEqual(resting.title)
expect(initial.toolbar).toEqual(resting.toolbar)
expect(initial.tabs).toEqual(resting.tabs)
if (hasTabs && width <= 600) {
const tabs = initial.tabs!
if (width === 390) {
expect(initial.toolbar.y).toBeGreaterThanOrEqual(tabs.y + tabs.height)
} else {
expect(initial.toolbar.x).toBeGreaterThanOrEqual(tabs.x + tabs.width)
expect(initial.toolbar.y + initial.toolbar.height / 2).toBeCloseTo(tabs.y + tabs.height / 2, 5)
}
}
const refresh = await page.getByRole('button', { name: 'Refresh', exact: true }).elementHandle()
if (refresh === null) throw new Error('The refresh control is missing')
const samples: Awaited<ReturnType<typeof geometry>>[] = []
const offsets = [...new Set([
0, 1, 4, 8, 9, ...Array.from({ length: Math.ceil(initial.travel / 4) + 1 }, (_, i) => i * 4),
initial.travel, initial.travel + 16,
])].sort((a, b) => a - b)
for (const offset of offsets) {
await scrollTo(page, offset)
const current = await geometry(page)
expect(current.range).toBe(initial.range)
expect(current.barHeight).toBe(resting.height)
if (hasTabs && width <= 600) {
expect(current.toolbar.y - current.tabs!.y).toBeCloseTo(initial.toolbar.y - initial.tabs!.y, 5)
expect(current.toolbar.x).toBe(initial.toolbar.x)
expect(current.tabs!.x).toBe(initial.tabs!.x)
}
expect(current.first).toBeCloseTo(initial.first - current.offset, 5)
const previous = samples.at(-1)
if (previous !== undefined) {
expect(current.toolbar.y).toBeLessThanOrEqual(previous.toolbar.y + 0.02)
if (current.tabs !== null && previous.tabs !== null) expect(current.tabs.y).toBeLessThanOrEqual(previous.tabs.y + 0.02)
}
if (offset === 8) {
expect(current.paintHeight).toBeGreaterThan(compact.height)
if (hasTabs) expect(current.tabs!.y).toBeCloseTo(resting.tabs!.y - 8, 5)
}
samples.push(current)
}
const terminal = await geometry(page)
expect(terminal.toolbar).toEqual(compact.toolbar)
expect(terminal.tabs).toEqual(compact.tabs)
expect(terminal.paintHeight).toBe(compact.height)
if (!hasTabs) expect(terminal.title).toEqual(compact.title)
expect(await refresh.evaluate(element => element.isConnected)).toBe(true)
await refresh.focus()
expect(await refresh.evaluate(element => element === document.activeElement)).toBe(true)
await scrollTo(page, terminal.offset)
expect(await geometry(page)).toEqual(terminal)
for (const sample of samples.toReversed()) {
await scrollTo(page, sample.offset)
expect(await geometry(page)).toEqual(sample)
}
expect(await page.locator('[data-measuring]').count()).toBe(0)
facts.push(`- ${hasTabs ? 'Tabs' : 'Title'} / ${width}px: resting title ${resting.title.y}, actions ${resting.toolbar.y}, tabs ${resting.tabs?.y ?? 'none'}; pinned actions ${compact.toolbar.y}, tabs ${compact.tabs?.y ?? 'none'}, bar ${compact.height}; stable range, reversible coordinates, same controls.`)
}
expect(consoleWatch.pageErrors).toEqual([])
expect(consoleWatch.warnings).toEqual([])
} finally { await page.close() }
} finally { await scaffold.close() }
}
} finally { await browser.close() }
await compareOrRefreshGolden(EXPECTED, `# Plugin header natural scrolling\n\n${facts.join('\n')}`, webSnapshotMode())
})
it('preserves the full header width when the list reserves a scrollbar gutter', async () => {
const scaffold = await launchWebScaffold({
profile: { packages: [], bundles: ['@deepseek-ai/dsh-experimental-plugin-market-bundle'] },
})
try {
const browser = await chromium.launch()
try {
const page = await browser.newPage({ viewport: { width: 1280, height: 480 }, locale: 'en-US' })
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.getByRole('button', { name: 'Plugins', exact: true }).click()
await page.locator('[data-plugin-package]').first().waitFor()
await page.locator('[data-plugin-bar][data-natural]').waitFor()
await scrollTo(page, 0)
const initial = await geometry(page)
const compact = await reference(page, true)
const headerBounds = await page.locator('[data-plugin-bar] > header').boundingBox()
await page.locator('[data-plugin-body]').evaluate((body) => { body.style.scrollbarGutter = 'stable' })
await expect.poll(() => page.locator('[data-plugin-body]').evaluate(body => (body as HTMLElement).offsetWidth - body.clientWidth))
.toBeGreaterThan(0)
await expect.poll(() => page.locator('[data-plugin-bar] > header').boundingBox()).toEqual(headerBounds)
const withGutter = await geometry(page)
expect(withGutter.toolbar).toEqual(initial.toolbar)
expect(withGutter.tabs).toEqual(initial.tabs)
await scrollTo(page, 300)
expect((await geometry(page)).toolbar).toEqual(compact.toolbar)
expect((await geometry(page)).tabs).toEqual(compact.tabs)
} finally { await browser.close() }
} finally { await scaffold.close() }
})
it('does not manufacture extra scroll range or clamp a nearly fitting list while the header moves', async () => {
const scaffold = await launchWebScaffold()
try {
const browser = await chromium.launch()
try {
const page = await browser.newPage({ viewport: { width: 1000, height: 480 }, locale: 'en-US' })
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.getByRole('button', { name: 'Plugins', exact: true }).click()
await page.locator('[data-plugin-package]').first().waitFor()
await page.locator('[data-plugin-bar][data-natural]').waitFor()
await scrollTo(page, 0)
const initial = await geometry(page)
await page.setViewportSize({ width: 1000, height: 480 + initial.range - 12 })
await scrollTo(page, 0)
const before = await geometry(page)
expect(before.range).toBe(12)
await page.mouse.move(500, 300)
await page.mouse.wheel(0, 200)
await expect.poll(async () => (await geometry(page)).offset).toBe(12)
const bottom = await geometry(page)
expect(bottom.range).toBe(12)
expect(bottom.barHeight).toBe(before.barHeight)
expect(bottom.paintHeight).toBe(before.barHeight - 12)
await page.mouse.wheel(0, -200)
await expect.poll(async () => (await geometry(page)).offset).toBe(0)
expect(await geometry(page)).toEqual(before)
} finally { await browser.close() }
} finally { await scaffold.close() }
})
+108 -10
View File
@@ -198,10 +198,14 @@ describe('web e2e: macOS window drag coverage', () => {
})
/**
* Open a darwin-marked page in the shipped composition.
* Open a desktop-marked page in the shipped composition.
* @param platform - Desktop CSS and preload platform to emulate in Chromium.
* @param host - Managed profile serving this page.
* @returns the page and its console tripwire.
*/
async function darwinPage(): Promise<{ page: Page; tripwire: ReturnType<typeof watchConsole> }> {
async function desktopPage(
platform: 'darwin' | 'win32' = 'darwin', host: WebScaffold = scaffold,
): Promise<{ page: Page; tripwire: ReturnType<typeof watchConsole> }> {
const page = await newEnglishPage(browser)
await page.addInitScript(({ value, snapshot }) => {
// The Desktop platform marker requires the preload's keyboard and preference capabilities.
@@ -211,18 +215,112 @@ describe('web e2e: macOS window drag coverage', () => {
subscribe: () => () => {}, recording: async () => {},
edit: async () => ({ status: 'not-ready', snapshot }) },
} })
const mark = (): void => { document.documentElement.setAttribute('data-platform', value) }
const mark = (): void => {
document.documentElement.setAttribute('data-platform', value)
if (value === 'win32') {
document.documentElement.setAttribute('data-windows-titlebar', '')
document.documentElement.style.setProperty('--dsh-windows-titlebar-height', '40px')
}
}
if (document.documentElement === null) document.addEventListener('DOMContentLoaded', mark, { once: true })
else mark()
}, { value: 'darwin', snapshot: initialShortcutConfig() })
}, { value: platform, snapshot: initialShortcutConfig() })
const tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.goto(host.authenticatedUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
return { page, tripwire }
}
it.each(['darwin', 'win32'] as const)('%s keeps compact Plugins controls below native chrome in both sidebar states', async (platform) => {
const { page, tripwire } = await desktopPage(platform)
try {
await page.setViewportSize({ width: 1280, height: 480 })
await page.getByRole('button', { name: 'Plugins', exact: true }).click()
await page.locator('[data-plugin-package]').first().waitFor()
const bar = page.locator('[data-plugin-bar]')
const body = page.locator('[data-plugin-body]')
const toolbarGeometry = () => bar.evaluate((element) => {
const header = element.querySelector('header')!
const bounds = element.getBoundingClientRect()
const buttons = Array.from(element.querySelectorAll('button'))
.filter(button => button.textContent?.trim() === 'Refresh' || button.textContent?.trim() === 'Add plugin')
return {
top: bounds.top,
padding: Number.parseFloat(getComputedStyle(header).paddingTop),
buttons: buttons.map((button) => { const rect = button.getBoundingClientRect(); return { x: rect.x, y: rect.y } }),
}
})
for (const collapsed of [false, true]) {
if (collapsed) {
await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click()
}
await settled(page)
await body.evaluate((element) => { element.scrollTop = 0 })
await page.waitForFunction(() => !document.querySelector('[data-plugin-bar]')?.hasAttribute('data-compact'))
const before = await toolbarGeometry()
expect(before.top).toBe(platform === 'darwin' ? 0 : 40)
expect(before.padding).toBe(platform === 'darwin' ? 76 : 28)
expect(before.buttons).toHaveLength(2)
await body.evaluate((element) => { element.scrollTop = 120 })
await page.waitForFunction(() => document.querySelector('[data-plugin-bar]')?.hasAttribute('data-compact'))
const after = await toolbarGeometry()
// macOS only needs the top strip cleared while the sidebar is collapsed; with it open the
// native controls live in the sidebar, so the compact bar keeps its own 16px spacing.
const compactTop = platform === 'darwin' && collapsed ? 56 : 16
expect(after.padding).toBe(before.padding)
expect(await bar.evaluate(element => Number.parseFloat(getComputedStyle(element, '::before').height))).toBe(compactTop + 48)
expect(after.buttons).toEqual(before.buttons.map(button => ({ ...button, y: button.y - (before.padding - compactTop) })))
expect(after.buttons.every(button => button.y >= (platform === 'darwin' ? compactTop : 16))).toBe(true)
expect(await body.evaluate(element => element.getBoundingClientRect().top))
.toBe(await bar.evaluate(element => element.getBoundingClientRect().top))
if (platform === 'darwin') expect(swallowedBoxes(await collectedRegions(page, INTERACTIVE_SELECTOR))).toEqual([])
}
expect(tripwire.pageErrors).toEqual([])
} finally { await page.close() }
})
it.each([
['darwin', false], ['darwin', true], ['win32', false], ['win32', true],
] as const)('%s scopes compact spacing with tabs=%s to the collapsed macOS header at every width', async (platform, hasTabs) => {
const host = hasTabs ? await launchWebScaffold({
profile: { packages: [], bundles: ['@deepseek-ai/dsh-experimental-plugin-market-bundle'] },
}) : scaffold
try {
const { page, tripwire } = await desktopPage(platform, host)
try {
await page.setViewportSize({ width: 1280, height: 480 })
await page.getByRole('button', { name: 'Plugins', exact: true }).click()
await page.locator('[data-plugin-package]').first().waitFor()
const bar = page.locator('[data-plugin-bar]')
const body = page.locator('[data-plugin-body]')
const control = bar.locator(hasTabs ? '[role="tablist"]' : '[data-plugin-toolbar]')
const top = async () => (await control.boundingBox())!.y - (await bar.boundingBox())!.y
await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click()
for (const width of [1280, 801, 520, 1280]) {
await page.setViewportSize({ width, height: 480 })
await settled(page)
await body.evaluate((element) => { element.scrollTop = 0 })
await page.waitForFunction(() => !document.querySelector('[data-plugin-bar]')?.hasAttribute('data-compact'))
expect(await bar.locator('h1').evaluate(element => element.getBoundingClientRect().top)
- (await bar.boundingBox())!.y).toBe(platform === 'darwin' ? 76 : 28)
await body.evaluate((element) => { element.scrollTop = 500 })
await page.waitForFunction(() => document.querySelector('[data-plugin-bar]')?.hasAttribute('data-compact'))
const inset = platform === 'darwin' ? 56 : 16
expect(await top()).toBe(inset)
expect(await bar.evaluate(element => Number.parseFloat(getComputedStyle(element, '::before').height)))
.toBe(inset + (hasTabs ? 34 : 32) + 16)
}
await page.getByRole('button', { name: 'Open sidebar', exact: true }).last().click()
await settled(page)
await body.evaluate((element) => { element.scrollTop = 500 })
await expect.poll(top).toBe(16)
expect(tripwire.pageErrors).toEqual([])
} finally { await page.close() }
} finally { if (host !== scaffold) await host.close() }
})
it('leaves no interactive box inside the drag surface, and keeps the panel strip row draggable', async () => {
const { page, tripwire } = await darwinPage()
const { page, tripwire } = await desktopPage()
try {
await connectFreshWorkspace(page, scaffold.workspaceCwd)
const agent = scaffold.ctx.agents.list()[0]
@@ -338,7 +436,7 @@ describe('web e2e: macOS window drag coverage', () => {
}, 180_000)
it('keeps the Conversation header draggable over the collapsed column, and the seat out of it', async () => {
const { page, tripwire } = await darwinPage()
const { page, tripwire } = await desktopPage()
try {
await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click()
await page.locator('[data-shell-leading]').waitFor({ state: 'visible', timeout: 15_000 })
@@ -369,7 +467,7 @@ describe('web e2e: macOS window drag coverage', () => {
}, 120_000)
it('keeps an entry page’s chrome row draggable', async () => {
const { page, tripwire } = await darwinPage()
const { page, tripwire } = await desktopPage()
try {
await page.getByRole('button', { name: 'Plugins', exact: true }).click()
const head = page.locator('[class*="pageHead"]').first()
@@ -406,7 +504,7 @@ describe('web e2e: macOS window drag coverage', () => {
}, 120_000)
it('keeps a detail page’s head row draggable and its body out of the drag surface', async () => {
const { page, tripwire } = await darwinPage()
const { page, tripwire } = await desktopPage()
try {
await page.getByRole('button', { name: 'Plugins', exact: true }).click()
const cards = page.locator('[data-plugin-package], [data-plugin-item]')
@@ -472,7 +570,7 @@ describe('web e2e: macOS window drag coverage', () => {
}, 120_000)
it('keeps a covering overlay out of the drag surface', async () => {
const { page, tripwire } = await darwinPage()
const { page, tripwire } = await desktopPage()
try {
await page.getByRole('button', { name: 'Account menu', exact: true }).click()
await page.getByRole('menuitem', { name: 'Settings', exact: true }).click()
+1
View File
@@ -88,6 +88,7 @@
"tests/plugin-config.e2e.ts",
"tests/input-plugin-extensions.e2e.ts",
"tests/plugin-manager.e2e.ts",
"tests/plugin-sticky-motion.e2e.ts",
"tests/entry-customization.e2e.ts",
"tests/cua-optin.e2e.ts",
"tests/cua-permissions.e2e.ts",
+11 -2
View File
@@ -2485,7 +2485,7 @@ export interface PlanModeConfig {
- `inject`: `loader` · `profileContext`
- `refs`: [`NpmFileSource`](subsystems/boot.md)
- `source`: [`packages/boot/plugin-manager/src/index.ts:51`](../packages/boot/plugin-manager/src/index.ts)
- `source`: [`packages/boot/plugin-manager/src/index.ts:52`](../packages/boot/plugin-manager/src/index.ts)
```ts config-catalog
/** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */
@@ -2500,6 +2500,8 @@ export interface Config {
inspectTimeoutMs?: number
/** Maximum duration of the GitHub repository connection check before installation, in milliseconds. */
githubConnectionTimeoutMs?: number
/** Maximum duration of each HTTP(S) and SSH access probe of a self-hosted Git repository before installation, in milliseconds. */
gitTransportTimeoutMs?: number
/** Maximum time one captured package run may print nothing before the manager terminates it, in milliseconds. */
idleTimeoutMs?: number
/** The registry lookups and installations ask first, as an http(s) URL; absent, the one pnpm's own configuration names. */
@@ -3958,7 +3960,7 @@ export interface Config {
## `@deepseek-ai/dsh-tool-jobs`
- `inject`: `tools` · `jobs` · `systemPrompt`
- `source`: [`packages/jobs/tool-jobs/src/index.ts:41`](../packages/jobs/tool-jobs/src/index.ts)
- `source`: [`packages/jobs/tool-jobs/src/index.ts:43`](../packages/jobs/tool-jobs/src/index.ts)
```ts config-catalog
/** Configures bounded `job_output` waits and completion-notice delivery. */
@@ -3978,6 +3980,13 @@ export interface Config {
* the next user input.
*/
maxConsecutiveWakes?: number
/**
* Largest complete completion notice, in UTF-8 bytes, that carries the
* job's output and final result inline (default 2048); a larger one, or one
* over the producer's `outputLimitBytes`, leaves them for `job_output`. 0
* never inlines.
*/
inlineOutputBytes?: number
}
/**
+11 -2
View File
@@ -2487,7 +2487,7 @@ export interface PlanModeConfig {
- `inject`: `loader` · `profileContext`
- `refs`: [`NpmFileSource`](subsystems/boot.zh.md)
- `source`: [`packages/boot/plugin-manager/src/index.ts:51`](../packages/boot/plugin-manager/src/index.ts)
- `source`: [`packages/boot/plugin-manager/src/index.ts:52`](../packages/boot/plugin-manager/src/index.ts)
```ts config-catalog
/** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */
@@ -2502,6 +2502,8 @@ export interface Config {
inspectTimeoutMs?: number
/** Maximum duration of the GitHub repository connection check before installation, in milliseconds. */
githubConnectionTimeoutMs?: number
/** Maximum duration of each HTTP(S) and SSH access probe of a self-hosted Git repository before installation, in milliseconds. */
gitTransportTimeoutMs?: number
/** Maximum time one captured package run may print nothing before the manager terminates it, in milliseconds. */
idleTimeoutMs?: number
/** The registry lookups and installations ask first, as an http(s) URL; absent, the one pnpm's own configuration names. */
@@ -3960,7 +3962,7 @@ export interface Config {
## `@deepseek-ai/dsh-tool-jobs`
- `inject`: `tools` · `jobs` · `systemPrompt`
- `source`: [`packages/jobs/tool-jobs/src/index.ts:41`](../packages/jobs/tool-jobs/src/index.ts)
- `source`: [`packages/jobs/tool-jobs/src/index.ts:43`](../packages/jobs/tool-jobs/src/index.ts)
```ts config-catalog
/** Configures bounded `job_output` waits and completion-notice delivery. */
@@ -3980,6 +3982,13 @@ export interface Config {
* the next user input.
*/
maxConsecutiveWakes?: number
/**
* Largest complete completion notice, in UTF-8 bytes, that carries the
* job's output and final result inline (default 2048); a larger one, or one
* over the producer's `outputLimitBytes`, leaves them for `job_output`. 0
* never inlines.
*/
inlineOutputBytes?: number
}
/**
+1 -1
View File
@@ -65,7 +65,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` |
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
+1 -1
View File
@@ -67,7 +67,7 @@
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` |
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
+3 -1
View File
@@ -250,7 +250,9 @@ Manage profile files and apply their declared reload lifecycle.
/**
* Install a package using the same pnpm implementation as dsh plugin. GitHub
* repositories get a connection check bounded by githubConnectionTimeoutMs before pnpm starts;
* only network failures or timeouts stop installation, while pnpm owns authentication and transport fallback. A run
* only network failures or timeouts stop installation, while pnpm owns authentication and transport fallback. An
* HTTP(S) Git URL on another host is installed over SSH instead when access probes bounded by gitTransportTimeoutMs
* find that only SSH can read it. A run
* that fails, is cancelled, or adds a package without a bundle patch restores
* `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.
* @param spec One package spec, including local paths relative to the invocation directory.
+3 -1
View File
@@ -250,7 +250,9 @@ Manage profile files and apply their declared reload lifecycle.
/**
* Install a package using the same pnpm implementation as dsh plugin. GitHub
* repositories get a connection check bounded by githubConnectionTimeoutMs before pnpm starts;
* only network failures or timeouts stop installation, while pnpm owns authentication and transport fallback. A run
* only network failures or timeouts stop installation, while pnpm owns authentication and transport fallback. An
* HTTP(S) Git URL on another host is installed over SSH instead when access probes bounded by gitTransportTimeoutMs
* find that only SSH can read it. A run
* that fails, is cancelled, or adds a package without a bundle patch restores
* `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.
* @param spec One package spec, including local paths relative to the invocation directory.
@@ -15,14 +15,14 @@
en: 87679c51aa3693f5
zh: 11684a48a5806afb
/deepseek-ai-dsh-plugin-manager/use-this-package:
en: 425473b242360a78
zh: bd91b346ab3d7a78
en: ebd29c2b513aa085
zh: 1827a4ff6cee19fd
/deepseek-ai-dsh-plugin-manager/use-this-package/version-compatibility-and-exemptions:
en: 483285658e267dd5
zh: 846bec2daac59a96
/deepseek-ai-dsh-plugin-manager/use-this-package/configuration:
en: 64089ca8084f5e0f
zh: 8328a53cd6420a82
en: ccec56c785b3ca28
zh: b945f933888fc7cb
/deepseek-ai-dsh-plugin-manager/use-this-package/bundle-detail-documents:
en: 99b084ff497011d2
zh: 6c047796c1991784
@@ -48,8 +48,8 @@
en: c29f60be7f3edc1a
zh: d9b9388b96ef95cc
/deepseek-ai-dsh-plugin-manager/known-limitations-and-deferred-work:
en: eeeaa73140fa45d7
zh: 12b5c10f0390310e
en: be72520bfbd9e98c
zh: 55cc921d9dbb7cf3
/deepseek-ai-dsh-plugin-manager/known-limitations-and-deferred-work/failure-behavior:
en: c670eb2330b441e8
zh: 2c8223e03e1e3ef0
+4
View File
@@ -57,6 +57,8 @@ Official on-demand installs call the same `installBundle` operation as other bun
`installBundle` checks GitHub repositories with `git ls-remote` before starting pnpm, using the profile directory and the installer's Git and proxy configuration. `githubConnectionTimeoutMs` defaults to 5000 ms and limits only this check, not package download or builds. The check disables credential helpers and prompts; only network failures and timeouts stop installation, reporting `failedAt: 'spec-host'` with the existing failure kind and diagnostic log. Authentication, repository lookup and other failures are left to pnpm, including its HTTPS-to-SSH fallback. Cancellation and manager disposal stop the check and its descendants. Registry packages, paths, tarballs and other Git hosts skip this check. A reachable repository can still fail during download or bundle validation.
An HTTP(S) Git URL on a host for which pnpm has no HTTPS-to-SSH fallback, that is, any host `hosted-git-info` does not know, such as a self-hosted GitLab, is probed with `git ls-remote` before pnpm starts. The probe uses the installer's Git configuration and credential helpers with Git's terminal and askpass prompts disabled; on POSIX it also has no controlling terminal, so SSH cannot prompt. When HTTP(S) cannot read the repository and `ssh://git@<host>/<path>` can, pnpm installs that SSH URL with the original fragment, and the profile records it. Otherwise the spec is installed unchanged. Each probe is bounded by `gitTransportTimeoutMs`, 5000 ms by default; cancellation and manager disposal stop it. URLs with credentials or a query skip the probe.
Registries are asked in turn. The plan starts at `options.registry`, else the configured `registry` (`null` is the one pnpm's own configuration names), and continues through `fallbackRegistries` while a registry is unreachable, times out, or answers that it has no such package or version, which a mirror not yet synced does. A registry outside the configured set is asked alone, so a private registry never falls through to a public one; pnpm's own registry counts as part of the set only while what it names, read through `pnpm config get registry` before each plan, is npm's own registry or one of the fallbacks, and is otherwise asked alone as a private one. A registry pnpm's own configuration already names is asked once. The lookup runs `pnpm view` with `--registry` and without pnpm's own retries, so a dead registry is reported within `inspectTimeoutMs` and the next one is asked; pnpm prints its refusal as JSON on stdout, which is read like stderr. The install keeps pnpm's retry settings. `registries()` answers the configured set and what pnpm names, for a picker. The [registry Agent Note](../../../.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.md) owns the rationale.
The browser-safe `@deepseek-ai/dsh-plugin-manager/registry` entry exports `OFFICIAL_NPM_REGISTRY` and `NPMMIRROR_REGISTRY` for consumers that identify those public registries.
@@ -85,6 +87,7 @@ The CLI exposes `dsh plugin --profile <profile> version-exemptions`, `allow-vers
| `pnpmCommand` | `pnpm` | The pnpm executable name or path, resolved through `PATH` like the `dsh plugin` command. |
| `inspectTimeoutMs` | `20000` | Bound on one registry lookup an inspection runs, in milliseconds. |
| `githubConnectionTimeoutMs` | `5000` | Deadline for the GitHub repository check before installation, in milliseconds. |
| `gitTransportTimeoutMs` | `5000` | Deadline for each HTTP(S) and SSH access probe of a self-hosted Git repository before installation, in milliseconds. |
| `registry` | pnpm's own | The registry lookups and installations ask first, as an http(s) URL; absent, the one pnpm's own configuration names. |
| `fallbackRegistries` | `['https://registry.npmmirror.com/']` | Registries asked in turn, as http(s) URLs, while the one before is unreachable or holds no copy of the package; pnpm's own registry joins the order only while it names npm's own registry or one of these. |
| `outputBytes` | `16384` | Maximum pnpm diagnostic bytes returned per operation; the full output remains in the returned log path. |
@@ -161,6 +164,7 @@ Tool results append to the transcript. Enabling or disabling other tools can cha
- pnpm verifies every lockfile tarball URL against its default registry when a command changes the lockfile. After a package was installed from another registry, installing a different package with the default registry, or removing that package after a restart, can fail with `ERR_PNPM_TARBALL_URL_MISMATCH`.
- An update saves the target as an exact version, and a reinstallation after a failed update saves the previous version exactly, even when the profile had saved a range. With HMR the reinstalled bundle joins the end of the selection, which can change which bundle's configuration takes precedence.
- An update runs its removal and its installation as two profile operations; another operation can run between them.
- The Git transport probe keeps the parent console on Windows, so an SSH host-key or passphrase prompt can appear in the launching terminal until `gitTransportTimeoutMs`. On POSIX a host missing from `known_hosts` fails the SSH probe, so installation stays on HTTPS until the host key is accepted once, for example with `ssh -T git@<host>`. A credential-helper dialog or ssh-agent confirmation, such as a Touch ID or `ssh-add -c` dialog, that takes longer than the deadline counts as a failed probe, and an HTTPS probe that times out lets a reachable SSH form be saved to the profile. A private repository waits up to twice `gitTransportTimeoutMs` before pnpm starts. The SSH form always uses the `git` user and SSH's default port; other SSH endpoints need a `url.<base>.insteadOf` rule. When both probes fail, the diagnostic is pnpm's HTTPS failure; the SSH probe's reason is not reported.
- A newer version whose dependencies need new build-script approval fails to update and is reinstalled at the previous version; approve its scripts through an ordinary installation after removing the package.
- Package replacements require restarting the process without HMR. A package relinked outside Plugin Manager while it is still installed waits for a restart.
- Startup-only profiles cannot remove packages used to start the current process; stop it and use `dsh plugin`.
@@ -57,6 +57,8 @@ kind: "package-reference"
`installBundle` 在启动 pnpm 前通过 `git ls-remote` 检查 GitHub 仓库,使用 profile 目录及安装器的 Git 与代理配置。`githubConnectionTimeoutMs` 默认为 5000 毫秒,只限制这次检查,不限制包下载或构建。检查禁用凭据助手和认证提示;只有网络失败与超时会停止安装,通过现有失败类型与诊断日志返回,并标记 `failedAt: 'spec-host'`。认证、仓库查找及其他失败继续交给 pnpm,包括其 HTTPS 到 SSH 的回退。取消安装或销毁管理器会停止检查及其子进程。注册表包、本地路径、压缩包和其他 Git 主机跳过此检查。仓库可达后,下载或组合包验证仍可能失败。
HTTP(S) Git URL 指向 pnpm 没有 HTTPS 到 SSH 回退的主机(即 `hosted-git-info` 不认识的任何主机,例如自托管 GitLab)时,在启动 pnpm 前通过 `git ls-remote` 探测。探测使用安装器的 Git 配置与凭据助手,并禁用 Git 的终端与 askpass 提示;在 POSIX 上探测也没有控制终端,因此 SSH 无法提示。HTTP(S) 无法读取仓库而 `ssh://git@<host>/<path>` 可以读取时,pnpm 以该 SSH URL 加上原片段安装,profile 也记录该 URL。否则按原 spec 安装。每次探测受 `gitTransportTimeoutMs` 限制,默认为 5000 毫秒;取消安装或销毁管理器会停止探测。带凭据或查询串的 URL 跳过探测。
注册表按顺序询问。计划从 `options.registry` 开始,否则从配置的 `registry`(`null` 即 pnpm 自身配置指定的那个)开始,并在一个注册表不可达、超时或答复没有这个包或版本(尚未同步的镜像会如此)时继续问 `fallbackRegistries`。配置集合之外的注册表只问它自己,因而私有源永远不会落到公共源;pnpm 自身的注册表只在它指向的地址(每次做计划前经 `pnpm config get registry` 读出)是 npm 官方源或某个备选源时才算集合成员,否则视为私有源只问它自己。pnpm 自身配置已经指向的注册表只问一次。查询以 `--registry` 运行 `pnpm view` 且不带 pnpm 自身的重试,所以死掉的注册表会在 `inspectTimeoutMs` 内报告并转问下一个;pnpm 把拒绝以 JSON 打印在 stdout,读法与 stderr 相同。安装保留 pnpm 的重试设置。`registries()` 把配置集合和 pnpm 指向的地址答复给选择器。[注册表 Agent Note](../../../.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.zh.md) 拥有理由。
可在浏览器使用的 `@deepseek-ai/dsh-plugin-manager/registry` 入口导出 `OFFICIAL_NPM_REGISTRY` 和 `NPMMIRROR_REGISTRY`,供消费方识别这两个公共注册表。
@@ -85,6 +87,7 @@ CLI 提供 `dsh plugin --profile <profile> version-exemptions`、`allow-version
| `pnpmCommand` | `pnpm` | pnpm 可执行文件名或路径,与 `dsh plugin` 命令一样通过 `PATH` 解析。 |
| `inspectTimeoutMs` | `20000` | 单次检查所做注册表查询的上限,单位毫秒。 |
| `githubConnectionTimeoutMs` | `5000` | 安装前 GitHub 仓库连接检查的时限,单位毫秒。 |
| `gitTransportTimeoutMs` | `5000` | 安装前对自托管 Git 仓库每次 HTTP(S) 与 SSH 访问探测的时限,单位毫秒。 |
| `registry` | pnpm 自身配置 | 查询与安装首先询问的注册表,http(s) URL;缺省为 pnpm 自身配置指定的那个。 |
| `fallbackRegistries` | `['https://registry.npmmirror.com/']` | 前一个注册表不可达或没有该包副本时依次询问的注册表,http(s) URL;pnpm 自身的注册表只在它指向 npm 官方源或这里的某一个时才进入顺序。 |
| `outputBytes` | `16384` | 每次操作返回的 pnpm 诊断字节上限;完整输出保留在返回的日志路径中。 |
@@ -161,6 +164,7 @@ service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止,
- 命令改动锁文件时,pnpm 按默认注册表校验锁文件中的每个 tarball URL。某个包从其他注册表安装后,使用默认注册表安装另一个包,或在重启后删除该包,可能以 `ERR_PNPM_TARBALL_URL_MISMATCH` 失败。
- 更新把目标保存为确切版本,失败后的重新安装也把原版本保存为确切版本,即使 profile 原先保存的是范围。有 HMR 时,重新安装的组合包排到选中列表末尾,这可能改变哪个组合包的配置优先。
- 更新把移除与安装作为两次 profile 操作运行,其他操作可能在两者之间运行。
- 在 Windows 上,Git 传输探测沿用父进程的控制台,SSH 的主机密钥或密码提示可能出现在启动终端中,直到 `gitTransportTimeoutMs` 到期。在 POSIX 上,`known_hosts` 中没有的主机会让 SSH 探测失败,因此在接受一次主机密钥(例如执行 `ssh -T git@<host>`)之前一直走 HTTPS。凭据助手对话框或 ssh-agent 确认(例如 Touch ID 或 `ssh-add -c` 对话框)超过时限时算作探测失败;HTTPS 探测超时时,可达的 SSH 形式会写入 profile。私有仓库在 pnpm 启动前最多等待两倍 `gitTransportTimeoutMs`。SSH 形式固定使用 `git` 用户和 SSH 默认端口,其他 SSH 端点需要 `url.<base>.insteadOf` 规则。两种探测都失败时,诊断是 pnpm 的 HTTPS 失败,不报告 SSH 探测失败的原因。
- 依赖需要新的构建脚本批准的新版本会更新失败,并重新安装为原版本;移除该包后通过普通安装批准其脚本。
- 没有 HMR 时,替换包需要重启进程。在 Plugin Manager 之外重新链接仍处于安装状态的包,需要等待重启。
- 仅启动时加载的 profile 不能删除当前进程启动时使用的包;停止进程后使用 `dsh plugin`。
+4 -2
View File
@@ -70,7 +70,8 @@
"zod": "4.4.3",
"@deepseek-ai/dsh-util-values": "workspace:*",
"@deepseek-ai/dsh-plugin-detail": "workspace:*",
"@deepseek-ai/dsh-timeout": "workspace:*"
"@deepseek-ai/dsh-timeout": "workspace:*",
"hosted-git-info": "^9.0.3"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~",
@@ -115,6 +116,7 @@
"@deepseek-ai/dsh-session-projection": "workspace:*",
"@deepseek-ai/dsh-sandbox": "workspace:*",
"@deepseek-ai/dsh-user-approval": "workspace:*",
"@deepseek-ai/dsh-client-connection": "workspace:*"
"@deepseek-ai/dsh-client-connection": "workspace:*",
"@types/hosted-git-info": "^3.0.5"
}
}
@@ -0,0 +1,72 @@
/** The Git transport an installation of a self-hosted repository uses, chosen by probing access before pnpm starts. */
import { execa } from 'execa'
import hostedGitInfo from 'hosted-git-info'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import type { ParsedInstallSpec } from './install-spec.ts'
/** Deadline and cancellation for each read-only access probe. */
export interface GitTransportOptions {
timeoutMs: number
signal: AbortSignal
/** The application-owned package manager's environment, also used by its Git children. */
env?: Readonly<Record<string, string>>
}
/** An HTTP(S) Git URL with an optional `git+` prefix, which pnpm clones over that transport as written. */
const HTTP_GIT_URL = /^(?:git\+)?https?:\/\//i
/**
* The SSH form of an HTTP(S) Git spec on a host pnpm has no transport fallback for. pnpm tries HTTPS and SSH itself
* for the hosts `hosted-git-info` knows, such as GitHub and GitLab.com, and clones any other URL over the transport
* it names.
* @param spec The parsed installation address.
* @returns `git+ssh://git@<host>/<path>` on SSH's default port with the spec's fragment, or `undefined` for a spec that
* is not an HTTP(S) Git URL on such a host, or whose URL carries credentials or a query.
*/
export function sshGitSpec(spec: ParsedInstallSpec): string | undefined {
if (spec.kind !== 'git' || !HTTP_GIT_URL.test(spec.spec) || hostedGitInfo.fromUrl(spec.spec) !== undefined) return undefined
const url = new URL(spec.spec.replace(/^git\+/i, ''))
if (url.username !== '' || url.password !== '' || url.search !== '') return undefined
// The fragment is copied as written: WHATWG serialization would percent-encode some of its characters.
const hash = /#.*$/s.exec(spec.spec)?.[0] ?? ''
return `git+ssh://git@${url.hostname}${url.pathname}${hash}`
}
/**
* Probe whether Git can read a repository with the installer's Git configuration and credential helpers. Git's
* terminal and askpass prompts are disabled, `core.askPass` included because Windows drops the empty `GIT_ASKPASS`,
* and on POSIX the probe has no controlling terminal, so SSH cannot ask about host keys or passphrases either.
* @param repository The repository URL Git reads.
* @param dir The profile directory where installation runs.
* @param options The probe deadline, cancellation and environment.
* @returns Whether `git ls-remote` listed the repository's HEAD.
*/
async function readable(repository: string, dir: string, options: GitTransportOptions): Promise<boolean> {
const result = await execa('git', ['-c', 'core.askPass=', 'ls-remote', '--exit-code', '--', repository, 'HEAD'], {
cwd: dir,
env: {
...scrubbedParentEnv(), ...options.env, LC_ALL: 'C', GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never',
GIT_ASKPASS: '', SSH_ASKPASS: '', SSH_ASKPASS_REQUIRE: 'never',
},
extendEnv: false, stdin: 'ignore', stdout: 'ignore', stderr: 'ignore', reject: false,
timeout: options.timeoutMs, cancelSignal: options.signal, killDescendants: true, killSignal: 'SIGKILL',
})
return !result.failed
}
/**
* Choose how pnpm clones an HTTP(S) Git repository that {@link sshGitSpec} has an SSH form for: probe the spec's URL,
* and when HTTP(S) cannot read it, probe the SSH form.
* @param spec The parsed installation address.
* @param dir The profile directory where installation runs.
* @param options The deadline of each probe, cancellation and the installer's environment.
* @returns The SSH form when only SSH can read the repository; otherwise the spec unchanged.
*/
export async function chooseGitTransport(spec: ParsedInstallSpec, dir: string, options: GitTransportOptions): Promise<string> {
const ssh = sshGitSpec(spec)
if (ssh === undefined) return spec.spec
const strip = (value: string): string => value.replace(/^git\+/i, '').replace(/#.*$/s, '')
if (await readable(strip(spec.spec), dir, options)) return spec.spec
if (options.signal.aborted || !await readable(strip(ssh), dir, options)) return spec.spec
return ssh
}
+25 -9
View File
@@ -36,6 +36,7 @@ import { flatten, writePluginEnabled } from './patch.ts'
import { incompatiblePlugin, ManagementFailure } from './failure.ts'
import { approveBuilds, recordPendingBuilds } from './build-approval.ts'
import { checkGithubConnection } from './github-connection.ts'
import { chooseGitTransport, sshGitSpec } from './git-transport.ts'
import { newerVersions, VERSION_FIELDS, VERSION_LIST_FIELDS, versionFacts } from './versions.ts'
import type {
BundleDetail, BundleInfo, BundleRowInfo, ChangeResult, InspectOptions, InstallBundleOptions, ListVersionsOptions, ManagementError,
@@ -59,6 +60,8 @@ export interface Config {
inspectTimeoutMs?: number
/** Maximum duration of the GitHub repository connection check before installation, in milliseconds. */
githubConnectionTimeoutMs?: number
/** Maximum duration of each HTTP(S) and SSH access probe of a self-hosted Git repository before installation, in milliseconds. */
gitTransportTimeoutMs?: number
/** Maximum time one captured package run may print nothing before the manager terminates it, in milliseconds. */
idleTimeoutMs?: number
/** The registry lookups and installations ask first, as an http(s) URL; absent, the one pnpm's own configuration names. */
@@ -249,7 +252,7 @@ function bundleSelection(manifest: ProfileManifest): readonly string[] {
/**
* The package an installation spec names before pnpm runs: a registry name, a local directory's manifest name, or the
* dependency saved with this git or tarball spec, compared as the plugin list shows saved specs.
* dependency saved with this git or tarball spec or the SSH form of its Git URL, compared as the plugin list shows saved specs.
* @param spec The installation spec.
* @param dependencies The profile's saved dependencies.
* @param profileDir The profile directory saved local paths are relative to.
@@ -259,7 +262,8 @@ function specPackageName(spec: string, dependencies: Readonly<Record<string, str
const parsed = parsedForRegistry(spec)
if (parsed.kind === 'registry') return parsed.name
if (parsed.kind === 'path') return packageName(parsed.path)
const specs = parsed.kind === 'tarball' && parsed.path !== undefined ? [spec, `file:${parsed.path}`] : [spec]
const alternative = parsed.kind === 'tarball' && parsed.path !== undefined ? `file:${parsed.path}` : sshGitSpec(parsed)
const specs = alternative === undefined ? [spec] : [spec, alternative]
return Object.entries(dependencies).find(([dependency, saved]) => specs.includes(dependencySpec(dependency, saved, profileDir)))?.[0]
}
@@ -300,6 +304,7 @@ export class PluginManager extends TypertRemoteService {
lockWaitMs: z.number().step(1).min(0).default(120000),
inspectTimeoutMs: z.number().step(1).min(1000).default(20000),
githubConnectionTimeoutMs: z.number().step(1).min(1000).default(5000),
gitTransportTimeoutMs: z.number().step(1).min(1000).default(5000),
idleTimeoutMs: z.number().step(1).min(1000).default(600000),
registry: z.string().pattern(REGISTRY_URL),
fallbackRegistries: z.array(z.string().pattern(REGISTRY_URL)).default([NPMMIRROR_REGISTRY]),
@@ -324,6 +329,7 @@ export class PluginManager extends TypertRemoteService {
private readonly lockWaitMs: number
private readonly inspectTimeoutMs: number
private readonly githubConnectionTimeoutMs: number
private readonly gitTransportTimeoutMs: number
private readonly idleTimeoutMs: number
private readonly pnpmCommand: string
private readonly versionListLimit: number
@@ -345,6 +351,7 @@ export class PluginManager extends TypertRemoteService {
this.lockWaitMs = (config as Required<Config>).lockWaitMs
this.inspectTimeoutMs = (config as Required<Config>).inspectTimeoutMs
this.githubConnectionTimeoutMs = (config as Required<Config>).githubConnectionTimeoutMs
this.gitTransportTimeoutMs = (config as Required<Config>).gitTransportTimeoutMs
this.idleTimeoutMs = (config as Required<Config>).idleTimeoutMs
this.pnpmCommand = (config as Required<Config>).pnpmCommand
this.versionListLimit = (config as Required<Config>).versionListLimit
@@ -827,7 +834,9 @@ export class PluginManager extends TypertRemoteService {
/**
* Install a package using the same pnpm implementation as dsh plugin. GitHub
* repositories get a connection check bounded by githubConnectionTimeoutMs before pnpm starts;
* only network failures or timeouts stop installation, while pnpm owns authentication and transport fallback. A run
* only network failures or timeouts stop installation, while pnpm owns authentication and transport fallback. An
* HTTP(S) Git URL on another host is installed over SSH instead when access probes bounded by gitTransportTimeoutMs
* find that only SSH can read it. A run
* that fails, is cancelled, or adds a package without a bundle patch restores
* `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.
* @param spec One package spec, including local paths relative to the invocation directory.
@@ -862,10 +871,11 @@ export class PluginManager extends TypertRemoteService {
let version: string | undefined
try {
result.registries = []
const connection = checkGithubConnection(parsedForRegistry(spec), this.profile.dir, {
timeoutMs: this.githubConnectionTimeoutMs, outputBytes: this.outputBytes,
signal: AbortSignal.any([this.abort.signal, control.abort.signal]),
...this.profile.packageManager?.env === undefined ? {} : { env: this.profile.packageManager.env },
const parsed = parsedForRegistry(spec)
const signal = AbortSignal.any([this.abort.signal, control.abort.signal])
const env = this.profile.packageManager?.env === undefined ? {} : { env: this.profile.packageManager.env }
const connection = checkGithubConnection(parsed, this.profile.dir, {
timeoutMs: this.githubConnectionTimeoutMs, outputBytes: this.outputBytes, signal, ...env,
})
this.packageOperations.add(connection)
let connectionFailure: PackageResult | undefined
@@ -877,6 +887,12 @@ export class PluginManager extends TypertRemoteService {
result.failedAt = 'spec-host'
throw new Error(connectionFailure.output)
}
const transport = chooseGitTransport(parsed, this.profile.dir, { timeoutMs: this.gitTransportTimeoutMs, signal, ...env })
this.packageOperations.add(transport)
let installSpec: string
try { installSpec = await transport }
finally { this.packageOperations.delete(transport) }
if (stopped()) throw new InstallCancelledError()
// The last run is the result's; the registries asked stay listed whatever the outcome.
const plan = registryPlan(options?.registry, await this.registries())
let run: PackageResult | undefined
@@ -888,7 +904,7 @@ export class PluginManager extends TypertRemoteService {
result.registries.push(registry)
used = registry
announce('installing', { registry, index: index + 1, total: plan.length })
run = await this.runPnpm(['add', spec, ...options?.saveExact === true ? ['--save-exact'] : [], ...registryArguments(registry)], control.abort.signal, requestId)
run = await this.runPnpm(['add', installSpec, ...options?.saveExact === true ? ['--save-exact'] : [], ...registryArguments(registry)], control.abort.signal, requestId)
result.packageResult = run
if (stopped()) throw new InstallCancelledError()
// A compatibility refusal is the package's own answer, so no other registry is asked.
@@ -901,7 +917,7 @@ export class PluginManager extends TypertRemoteService {
delete result.failedAt
// A run this manager terminated got no answer from the registry at all, so no registry explains it.
if (run.timedOut === true) break
const failedAt = attributeFailure(run.kind, run.output, parsedForRegistry(spec))
const failedAt = attributeFailure(run.kind, run.output, parsedForRegistry(installSpec))
if (failedAt !== 'other') result.failedAt = failedAt
if (failedAt !== 'registry' || index === plan.length - 1) break
}
@@ -0,0 +1,153 @@
/** Real Git access probes that choose the transport of a self-hosted repository installation. */
import { once } from 'node:events'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import * as subprocess from 'execa'
import { afterAll, beforeAll, expect, it, onTestFinished, vi } from 'vitest'
import { chooseGitTransport } from '../src/git-transport.ts'
import { parseInstallSpec } from '../src/install-spec.ts'
import { isolateGitCommandLineConfig } from './git-environment.ts'
vi.mock('execa', async (original) => {
const module = await original<typeof import('execa')>()
return { ...module, execa: vi.fn(module.execa) }
})
// These cases spawn real Git, so the host's command-line configuration group must not reach its children.
let restoreGitCommandLineConfig: () => void
beforeAll(() => { restoreGitCommandLineConfig = isolateGitCommandLineConfig() })
afterAll(() => { restoreGitCommandLineConfig() })
const SPEC = `git+https://git.example/team/sub/plugin.git#${'a'.repeat(40)}&path:/packages/a`
const SSH_SPEC = `git+ssh://git@git.example/team/sub/plugin.git#${'a'.repeat(40)}&path:/packages/a`
async function fixture() {
const dir = await mkdtemp(join(tmpdir(), 'dsh-git-transport-'))
onTestFinished(() => rm(dir, { recursive: true, force: true }))
const config = join(dir, 'git.config')
await writeFile(config, '')
const env = { GIT_CONFIG_GLOBAL: config, GIT_CONFIG_NOSYSTEM: '1' }
const git = (...args: string[]) => subprocess.execa('git', args, { env })
const repository = join(dir, 'plugin.git')
await git('init', '--bare', repository)
// ls-remote --exit-code needs a HEAD that names a commit.
const work = join(dir, 'work')
await git('init', work)
await git('-C', work, '-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'commit.gpgsign=false', 'commit', '--allow-empty', '-m', 'init')
await git('-C', work, 'push', repository, 'HEAD:refs/heads/main')
await git('-C', repository, 'symbolic-ref', 'HEAD', 'refs/heads/main')
const controller = new AbortController()
const tasks: Promise<unknown>[] = []
// Registered after the directory removal, so it runs first: no probe outlives the directory it runs in.
onTestFinished(async () => { controller.abort(); await Promise.allSettled(tasks) })
const choose = (spec: string, timeoutMs = 5000) => {
const task = chooseGitTransport(parseInstallSpec(spec), dir, { timeoutMs, signal: controller.signal, env })
tasks.push(task)
return task
}
return { dir, config, repository: pathToFileURL(repository).href, controller, choose }
}
/** An HTTP Git endpoint that answers every request with `handler`. */
async function server(handler: (request: IncomingMessage, response: ServerResponse) => void): Promise<string> {
const http = createServer(handler)
onTestFinished(async () => {
http.closeAllConnections()
await new Promise<void>((resolve, reject) => { http.close((error) => { if (error) reject(error); else resolve() }) })
})
http.listen(0, '127.0.0.1')
await once(http, 'listening')
const address = http.address()
if (address === null || typeof address === 'string') throw new Error('server did not bind a TCP port')
return `http://127.0.0.1:${String(address.port)}/`
}
const unauthorized = (_request: IncomingMessage, response: ServerResponse): void => {
response.writeHead(401, { 'WWW-Authenticate': 'Basic realm="fixture"' })
response.end()
}
it('keeps the HTTPS spec when HTTPS can read the repository', async () => {
const { config, repository, choose } = await fixture()
await writeFile(config, `[url "${repository}"]\n insteadOf = https://git.example/team/sub/plugin.git\n insteadOf = ssh://git@git.example/team/sub/plugin.git\n`)
expect(await choose(SPEC)).toBe(SPEC)
})
it('installs over SSH when HTTPS asks for credentials that SSH does not need', async () => {
const { config, repository, choose } = await fixture()
const http = await server(unauthorized)
await writeFile(config, `[url "${http}"]\n insteadOf = https://git.example/\n insteadOf = http://git.example/\n[url "${repository}"]\n insteadOf = ssh://git@git.example/team/sub/plugin.git\n[http]\n proxy =\n`)
expect(await choose(SPEC)).toBe(SSH_SPEC)
// The fragment is copied as written, not as URL serialization would encode it.
expect(await choose('git+https://git.example/team/sub/plugin.git#semver:<2 >1')).toBe('git+ssh://git@git.example/team/sub/plugin.git#semver:<2 >1')
expect(await choose('git+http://git.example/team/sub/plugin.git')).toBe('git+ssh://git@git.example/team/sub/plugin.git')
})
it('keeps the HTTPS spec when neither transport can read the repository', async () => {
const { config, choose } = await fixture()
const http = await server(unauthorized)
await writeFile(config, `[url "${http}"]\n insteadOf = https://git.example/\n[url "file:///nonexistent/plugin.git"]\n insteadOf = ssh://git@git.example/\n[http]\n proxy =\n`)
expect(await choose(SPEC)).toBe(SPEC)
})
it('lets configured credential helpers answer the HTTPS probe', async () => {
const { dir, config, repository, choose } = await fixture()
const expected = `Basic ${Buffer.from('person:token').toString('base64')}`
const http = await server((request, response) => {
if (request.headers.authorization === expected) {
// Any reply Git can parse as a ref advertisement is enough; a dumb-HTTP info/refs listing is the smallest.
response.writeHead(200, { 'Content-Type': 'text/plain' })
response.end(`${'b'.repeat(40)}\tHEAD\n`)
} else unauthorized(request, response)
})
const helper = join(dir, 'credential-helper.cjs')
await writeFile(helper, 'process.stdout.write("username=person\\npassword=token\\n")')
await writeFile(config, `[url "${http}"]\n insteadOf = https://git.example/\n[url "${repository}"]\n insteadOf = ssh://git@git.example/team/sub/plugin.git\n[http]\n proxy =\n`)
await subprocess.execa('git', ['config', '--file', config, 'credential.helper', `!"${process.execPath.replaceAll('\\', '/')}" "${helper.replaceAll('\\', '/')}"`])
expect(await choose(SPEC)).toBe(SPEC)
})
it.each([
'@acme/plugin',
'github:acme/plugin',
'https://gitlab.com/acme/plugin.git',
'git+https://github.com/acme/plugin.git#main',
'git+ssh://git@git.example/team/plugin.git',
'git@git.example:team/plugin.git',
'git+https://person:token@git.example/team/plugin.git',
'git+https://git.example/team/plugin.git?ref=x',
])('leaves %s to pnpm without probing', async (spec) => {
const { choose } = await fixture()
const run = vi.mocked(subprocess.execa)
run.mockClear()
expect(await choose(spec)).toBe(spec)
expect(run).not.toHaveBeenCalled()
})
it('ends an in-flight HTTPS probe on cancellation and keeps the spec', async () => {
const { config, controller, choose } = await fixture()
const requested = Promise.withResolvers<undefined>()
// Never answers, so only cancellation ends the probe.
const http = await server(() => { requested.resolve(undefined) })
await writeFile(config, `[url "${http}"]\n insteadOf = https://git.example/\n[http]\n proxy =\n`)
const run = vi.mocked(subprocess.execa)
run.mockClear()
const choosing = choose(SPEC, 60000)
await requested.promise
controller.abort()
expect(await choosing).toBe(SPEC)
expect(run).toHaveBeenCalledTimes(1)
expect(run.mock.calls[0]?.[1]).toEqual(['-c', 'core.askPass=', 'ls-remote', '--exit-code', '--', 'https://git.example/team/sub/plugin.git', 'HEAD'])
})
it('does not probe SSH after cancellation', async () => {
const { controller, choose } = await fixture()
const run = vi.mocked(subprocess.execa)
run.mockClear()
controller.abort()
expect(await choose(SPEC)).toBe(SPEC)
expect(run).toHaveBeenCalledTimes(1)
})
@@ -23,6 +23,7 @@ import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import { Group } from '@deepseek-ai/cordis-plugin-loader'
import * as operations from '../src/operations.ts'
import * as githubConnection from '../src/github-connection.ts'
import * as gitTransport from '../src/git-transport.ts'
import { parse, parseDocument } from 'yaml'
import { isolateGitCommandLineConfig } from './git-environment.ts'
@@ -138,6 +139,47 @@ it('cancels an active GitHub check before starting pnpm', async () => {
expect(pnpm).not.toHaveBeenCalled()
})
it('installs the spec the Git transport probe chooses with the profile Git environment and deadline', async () => {
const env = { GIT_CONFIG_GLOBAL: '/application/git.config' }
const { manager, dir } = await fixture('live', false, undefined, { gitTransportTimeoutMs: 7000 }, { command: 'pnpm', args: [], env })
const spec = 'git+https://git.example/team/sub/plugin.git#main'
const transport = vi.spyOn(gitTransport, 'chooseGitTransport').mockResolvedValue('git+ssh://git@git.example/team/sub/plugin.git#main')
const pnpm = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({ exitCode: 1, output: 'stop', truncated: false, logPath: 'pnpm.log', kind: 'unknown' })
onTestFinished(() => { transport.mockRestore(); pnpm.mockRestore() })
expect((await manager.installBundle(spec)).application).toBe('failed')
expect(transport).toHaveBeenCalledWith(expect.objectContaining({ kind: 'git', spec }), dir, expect.objectContaining({ timeoutMs: 7000, env }))
expect(pnpm.mock.calls[0]?.[1]).toEqual(['add', 'git+ssh://git@git.example/team/sub/plugin.git#main'])
})
it('attributes a failure to the host of the SSH spec the probe chose, which carries no HTTPS port', async () => {
const { manager } = await fixture()
const transport = vi.spyOn(gitTransport, 'chooseGitTransport').mockResolvedValue('git+ssh://git@git.example/team/plugin.git#main')
const pnpm = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({
exitCode: 1, output: 'ssh: Could not resolve hostname git.example: nodename nor servname provided', truncated: false, logPath: 'pnpm.log',
})
onTestFinished(() => { transport.mockRestore(); pnpm.mockRestore() })
expect(await manager.installBundle('git+https://git.example:8443/team/plugin.git#main')).toMatchObject({ application: 'failed', failedAt: 'spec-host' })
expect(pnpm).toHaveBeenCalledOnce()
})
it('cancels an active Git transport probe before starting pnpm', async () => {
const { manager } = await fixture()
const entered = Promise.withResolvers<undefined>()
const transport = vi.spyOn(gitTransport, 'chooseGitTransport').mockImplementation(async (spec, _dir, options) => {
entered.resolve(undefined)
await new Promise<void>((resolve) => { options.signal.addEventListener('abort', () => { resolve() }, { once: true }) })
return spec.spec
})
const pnpm = vi.spyOn(operations, 'runProfilePnpm')
onTestFinished(() => { transport.mockRestore(); pnpm.mockRestore() })
const requestId = '5f0c1f8e-3f5e-4a59-9a43-1d0c5c3f7a21' as PluginInstallRequestId
const installing = manager.installBundle('git+https://git.example/team/plugin.git', { requestId })
await entered.promise
expect(await manager.cancelInstall(requestId)).toEqual({ status: 'cancelled' })
expect(await installing).toMatchObject({ application: 'cancelled', changed: false })
expect(pnpm).not.toHaveBeenCalled()
})
it('waits for the GitHub check before installing and activating the bundle', async () => {
const { manager, dir, bundle, connection } = await fixture()
const entered = Promise.withResolvers<undefined>()
@@ -833,6 +875,10 @@ it('refuses to install over an installed package, by name, version or local dire
expect(await manager.installBundle(join(dir, '..', 'extra-1.0.0.tgz'), { approvedBuilds: ['native-provider'] }))
.toMatchObject({ application: 'failed', changed: false, error: { code: 'already-installed' } })
expect(existsSync(workspace) ? readFileSync(workspace, 'utf8') : undefined).toBe(settings)
// A self-hosted HTTPS Git spec matches the SSH form an earlier installation saved.
writeFileSync(join(dir, 'package.json'), JSON.stringify({ ...manifest, dependencies: { extra: 'git+ssh://git@git.example/team/sub/extra.git#main' } }))
expect(await manager.installBundle('git+https://git.example/team/sub/extra.git#main'))
.toMatchObject({ application: 'failed', changed: false, error: { code: 'already-installed' } })
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
expect(install).not.toHaveBeenCalled()
expect(readProfileManifest('test', dir).dependencies).toEqual({ extra: '1.0.0' })
+2 -2
View File
@@ -15,8 +15,8 @@
en: 42038fdc402b9c2a
zh: 99890f8f908624ce
/deepseek-ai-dsh-client-ui-dockkit/the-two-layers:
en: 99356ea8e3d1e7aa
zh: 870f746730d0e6cf
en: 718f52cfce426009
zh: e8282e2e3713d242
/deepseek-ai-dsh-client-ui-dockkit/embedding-it:
en: c4ba08038bfa6f30
zh: 95db30618b52babe
+3 -1
View File
@@ -37,10 +37,12 @@ A docking layout kit: a split tree of tabbed panes with invertible operations, a
- `planSettle` is the opt-in rule that keeps every docked pane populated after an intent: panes an intent emptied are merged away, and an emptied root pane is reseeded through the embedder's factory — withholding the factory keeps the merge and leaves the root pane empty. An embedder that wants empty panes simply does not call it, or calls it without a factory. `planDropTab` takes the same factory: with one, a sole tab released on its own pane's edge splits and the factory's tab backfills the pane it vacates (the dragged tab stays focused); without one that release changes nothing.
- `DockController` is the intent layer and an observable source (`subscribe` + `getSnapshot`, whose reference only changes when the layout does).
**The components** render a layout snapshot and report settled intents — one per gesture, never a drag frame. A drag previews in local state while the gesture's own facts stay in its closure; on release the net result leaves through one `DockIntents` call — a strip release reports the caret slot as drawn, the dragged chip counted, and `planPlaceTab` turns that into the reorder or the move. That is what lets an embedder record exactly one history entry per gesture. The strip follows the WAI-ARIA tabs pattern with manual activation: the selected chip is in the tab order; Left and Right (wrapping), Home, and End move focus between chips without selecting; Enter or Space selects the focused chip through the same intent as a click. A chip is a capsule carrying one control, its close; the context menu (a secondary press on the chip) carries the same close plus the embedder's items — a menu that would hold no item at all never shows — and renders in a portal positioned against the chip because the chip box clips its overflow on purpose (see below). After the chips sits the add control, which asks the embedder (`DockIntents.addTab`) to seat its seeded tab; the embedder's `canAddTab(paneId)` decides per pane whether the control is drawn at all. Copying a tab has no kit control — it is the embedder's API — and floating is the drag released clear of the surface.
**The components** render a layout snapshot and report settled intents — one per gesture, never a drag frame. A drag previews in local state while the gesture's own facts stay in its closure; on release the net result leaves through one `DockIntents` call — a strip release reports a placement only when a caret is drawn, the dragged chip counted, and `planPlaceTab` turns that into the reorder or the move. That is what lets an embedder record exactly one history entry per gesture. The strip follows the WAI-ARIA tabs pattern with manual activation: the selected chip is in the tab order; Left and Right (wrapping), Home, and End move focus between chips without selecting; Enter or Space selects the focused chip through the same intent as a click. A chip is a capsule carrying one control, its close; the context menu (a secondary press on the chip) carries the same close plus the embedder's items — a menu that would hold no item at all never shows — and renders in a portal positioned against the chip because the chip box clips its overflow on purpose (see below). After the chips sits the add control, which asks the embedder (`DockIntents.addTab`) to seat its seeded tab; the embedder's `canAddTab(paneId)` decides per pane whether the control is drawn at all. Copying a tab has no kit control — it is the embedder's API — and floating is the drag released clear of the surface.
Middle-clicking anywhere on a closable tab chip, including its close control, closes it through the same intent as its close button, without selecting it first. The chip owns the middle click and suppresses nested title content’s default action. Closing a background tab preserves the selected tab. Only the primary button starts tab dragging; a middle click while another mouse button is held does not close the tab.
Strip slots that preserve the dragged tab’s position show no insertion caret and report no placement; the tab can still be dragged to split a pane or float.
Tab context menus show the close action name without a shortcut hint: they act on the clicked tab, while shortcuts can target another focused pane. An unmodified Escape dismisses the foreground tab menu without closing the tab and returns focus from a menu item to its tab without drawing a focus outline. Tab and arrow navigation retain the tab’s visible focus indicator. Composition and held-key repeats do not dismiss it.
<a id="embedding-it"></a>
+3 -1
View File
@@ -37,10 +37,12 @@ kind: "package-reference"
- `planSettle` 是可选加入的规则,保证意图之后每个停靠格都有内容:被意图清空的格会被并掉,被清空的根格通过嵌入方的工厂重新播种——不传工厂则只并格、让根格保持为空。想要空格的嵌入方只需不调用它,或不带工厂调用。`planDropTab` 接受同一工厂:带工厂时,唯一 tab 放到本格边缘会分栏,工厂的 tab 回填它腾出的格(被拖的 tab 保持聚焦);不带工厂时这种释放不改变任何东西。
- `DockController` 是意图层,也是一个可观察源(`subscribe` + `getSnapshot`,其引用只在布局变化时才变)。
**组件**渲染布局快照并上报已落定的意图——每次手势一条,绝不上报拖动帧。拖动过程中在本地状态里预览,手势自身的事实留在它的闭包里;松手时净结果通过一次 `DockIntents` 调用离开——在标签条上松手上报的是按绘制顺序数出的插入槽位(被拖的 chip 也计入),由 `planPlaceTab` 换算成重排或移动。正是这一点让嵌入方能为每次手势记录恰好一条历史。标签条遵循 WAI-ARIA tabs 模式的手动激活:选中的 chip 在 Tab 键序里;左右方向键(循环)、Home、End 只在 chip 之间移动焦点而不选中;Enter 或空格选中当前聚焦的 chip,走与点击相同的意图。chip 是一个胶囊,携带唯一的控件——它的关闭按钮;上下文菜单(在 chip 上的次键按下)携带同样的关闭项加上嵌入方的条目——一个连一项都没有的菜单绝不展示——并渲染在按 chip 定位的 portal 里,因为 chip 盒会故意裁掉溢出(见下文)。chip 之后是添加控件,它请嵌入方(`DockIntents.addTab`)安放其种子 tab;嵌入方的 `canAddTab(paneId)` 按格决定是否绘制该控件。复制 tab 没有套件控件——那是嵌入方的 API——而浮出就是把拖动松手在停靠区之外。
**组件**渲染布局快照并上报已落定的意图——每次手势一条,绝不上报拖动帧。拖动过程中在本地状态里预览,手势自身的事实留在它的闭包里;松手时净结果通过一次 `DockIntents` 调用离开——在标签条上松手时,仅当定位条可见才上报插入槽位(被拖的 chip 也计入),由 `planPlaceTab` 换算成重排或移动。正是这一点让嵌入方能为每次手势记录恰好一条历史。标签条遵循 WAI-ARIA tabs 模式的手动激活:选中的 chip 在 Tab 键序里;左右方向键(循环)、Home、End 只在 chip 之间移动焦点而不选中;Enter 或空格选中当前聚焦的 chip,走与点击相同的意图。chip 是一个胶囊,携带唯一的控件——它的关闭按钮;上下文菜单(在 chip 上的次键按下)携带同样的关闭项加上嵌入方的条目——一个连一项都没有的菜单绝不展示——并渲染在按 chip 定位的 portal 里,因为 chip 盒会故意裁掉溢出(见下文)。chip 之后是添加控件,它请嵌入方(`DockIntents.addTab`)安放其种子 tab;嵌入方的 `canAddTab(paneId)` 按格决定是否绘制该控件。复制 tab 没有套件控件——那是嵌入方的 API——而浮出就是把拖动松手在停靠区之外。
中键点击可关闭标签页 chip 的任意位置(包括关闭控件)时,通过与关闭按钮相同的意图关闭,无需先选中。chip 接管中键点击,并阻止嵌套标题内容的默认动作。关闭后台标签页会保留当前选中的标签页。只有主键会开始拖动标签页;其它鼠标按键仍按住时,中键点击不会关闭标签页。
不改变所拖标签位置的标签条落点不显示插入定位条,也不上报放置操作;标签仍可拖出分栏或浮动。
标签上下文菜单只显示关闭操作名,不显示快捷键:菜单操作针对被点击的标签,快捷键则可能作用于其他焦点分栏。无修饰键的 Esc 关闭前台标签菜单而不关闭标签,并将菜单项的焦点交还标签,不绘制焦点外轮廓线。Tab 和方向键导航保留标签页的可见焦点提示。输入法组合输入和长按重复事件不关闭菜单。
<a id="embedding-it"></a>
@@ -5,8 +5,8 @@
*
* A gesture only previews until it ends, then leaves through one intent, so the
* embedder's operation sequence stays the single source of truth. Releasing a tab
* clear of this surface floats it; releasing inside it but on no pane is not a
* move at all.
* clear of this surface floats it; releasing inside it without an effective
* drop target changes nothing.
*/
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
@@ -110,12 +110,15 @@ const ALWAYS = (): boolean => true
/**
* Resolve where a pointer sits inside the docked surface. An edge zone is only
* offered where the split it would make is allowed: within the pane budget and
* with room for two halves; otherwise the release is not a move at all.
* with room for two halves. Strip slots that preserve the dragged tab's position
* offer no caret or placement intent. An undefined target inside the surface
* leaves the layout unchanged on release.
*/
function hitTest(
root: HTMLElement,
x: number,
y: number,
tabId: TabId,
canSplit: boolean,
fits: ReadonlyMap<PaneId, HalvesFit>,
dropZones: 'edges' | 'horizontal',
@@ -126,7 +129,10 @@ function hitTest(
const strip = pane.querySelector<HTMLElement>('[data-dockkit-strip]')
if (strip !== null && containsPoint(strip.getBoundingClientRect(), x, y)) {
const tabs = [...strip.querySelectorAll<HTMLElement>('[data-dockkit-tab]')]
return { kind: 'strip', paneId, index: insertionIndex(tabs.map(tab => tab.getBoundingClientRect()), x) }
const index = insertionIndex(tabs.map(tab => tab.getBoundingClientRect()), x)
const sourceIndex = tabs.findIndex(tab => tab.dataset.dockkitTab === tabId)
if (sourceIndex >= 0 && (index === sourceIndex || index === sourceIndex + 1)) return undefined
return { kind: 'strip', paneId, index }
}
const zone = dropZones === 'horizontal'
? canSplit && fitOf(fits, paneId).row
@@ -229,12 +235,12 @@ function Surface({
setPreview({
...NO_PREVIEW,
draggingTabId: tabId,
dropTarget: hitTest(root, moved.clientX, moved.clientY, canSplit, fits, dropZones),
dropTarget: hitTest(root, moved.clientX, moved.clientY, tabId, canSplit, fits, dropZones),
})
},
up: (released) => {
if (!dragging) return
const target = hitTest(root, released.clientX, released.clientY, canSplit, fits, dropZones)
const target = hitTest(root, released.clientX, released.clientY, tabId, canSplit, fits, dropZones)
if (target === undefined) {
if (containsPoint(root.getBoundingClientRect(), released.clientX, released.clientY)) return
intents.floatTab(tabId, floatRectAt(released.clientX, released.clientY, FLOAT_DEFAULT_SIZE))
@@ -999,21 +999,42 @@ describe('tab drags', () => {
expect(document.querySelector('[data-dockkit-caret]')).toBeNull()
})
it('previews a caret at the slot under the pointer and reports that slot on release', () => {
it('previews only strip slots that change the tab order', () => {
const { intents, first, fileTabId, chip } = twoPanes()
// Left of the first chip's midpoint: slot 0.
drag(chip(fileTabId), FILE_CHIP, [30, 18], false)
expect(document.querySelector('[data-dockkit-caret]')?.getAttribute('data-dockkit-caret')).toBe('0')
// Past both chips: the slot after the last one, drawn at the strip's end.
fireEvent.pointerMove(window, { pointerId: 7, clientX: 300, clientY: 18 })
expect(document.querySelector('[data-dockkit-caret]')?.getAttribute('data-dockkit-caret')).toBe('2')
fireEvent.pointerUp(window, { pointerId: 7, clientX: 300, clientY: 18 })
expect(document.querySelector('[data-dockkit-caret]')).toBeNull()
fireEvent.pointerMove(window, { pointerId: 7, clientX: 120, clientY: 18 })
expect(document.querySelector('[data-dockkit-caret]')).toBeNull()
fireEvent.pointerMove(window, { pointerId: 7, clientX: 30, clientY: 18 })
fireEvent.pointerUp(window, { pointerId: 7, clientX: 30, clientY: 18 })
// The slot counts the dragged chip itself; the planner turns it into a reorder.
expect(intents.placeTab).toHaveBeenCalledWith(fileTabId, first, 2)
expect(intents.placeTab).toHaveBeenCalledWith(fileTabId, first, 0)
expect(gestureIntents(intents)).toEqual(['placeTab'])
expect(document.querySelector('[data-dockkit-caret]')).toBeNull()
})
it.each([30, 300])('does not offer a strip placement for the lone Start tab at x=%s', (x) => {
const controller = seededController()
controller.setExpanded(true)
const panes = dockPaneIds(controller.getSnapshot().state)
layOut(panes, 520)
const intents = spyIntents()
renderSurface(controller, intents)
const chip = screen.getByRole('tab')
drag(chip, [51, 18], [x, 18], false)
expect(document.querySelector('[data-dockkit-caret]')).toBeNull()
fireEvent.pointerUp(window, { pointerId: 7, clientX: x, clientY: 18 })
expect(gestureIntents(intents)).toEqual([])
drag(chip, [51, 18], [260, 590], false)
expect(document.querySelector('[data-dockkit-drop-active]')?.getAttribute('data-dockkit-dock-zone')).toBe('bottom')
fireEvent.pointerUp(window, { pointerId: 7, clientX: 260, clientY: 590 })
expect(intents.dropTab).toHaveBeenCalledExactlyOnceWith(chip.getAttribute('data-dockkit-tab'), panes[0], 'bottom')
})
it('reports a slot in another pane\'s strip as a placement there', () => {
const { intents, second, fileTabId, chip } = twoPanes()
drag(chip(fileTabId), FILE_CHIP, [420 + 30, 18])
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package:
en: fd8e3ad7cda08270
zh: cce22df78619f63d
en: 216dcea8d6903007
zh: 160888842501694e
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle:
en: fcb2e69c8b028c0a
zh: d505a075679974eb
+1 -1
View File
@@ -31,7 +31,7 @@ Use the **Plugins** entry in the Web sidebar to manage the profile's installed b
Select **Plugins** in the sidebar. The information button uses an 11px caption-colored icon in a 20px square hover area, with a 4px gap after the subtitle. It explains that this page configures official plugins and installs or manages other plugins, while **Settings → Built-in plugins** shows the built-in inventory and runtime status. Hover or focus shows the shared tooltip; clicking keeps it open until another click, Escape, Tab, or an outside click. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Built-in plugin settings** lists official configuration pages by their configured order: Subagent, web search, agent loop, then shell. **Experimental plugins** shows the primary optional bundles, with **More** beside its heading opening a separate page of the same title. That page lists the official bundles eligible for display, including those on the main list, with Codex and Claude Code subagents last. Its back button returns to the main list, where **Installed plugins** remains below Experimental plugins. Bundle details return to the list they were opened from. Catalog entries keep their position after installation or removal. **Installed plugins** lists other profile bundles. Installation-carried optional bundles have no uninstall action. Experimental packages retain the **Experimental** marker on both lists. Switching on a bundle that page holds back adds it to the main list in the position that page shows it, and switching it off leaves it on that page alone unless its manifest hides unselected bundles; other bundles keep their place on both. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Built-in plugins** section.
The heading moves its complete action group to another line when space is insufficient. Action labels stay on one line, and the information button follows the subtitle text. Hover styling applies only where the pointer can hover, so a tap leaves no card, row, or menu option in its hover state; cards and icon actions answer a touch-down with the pressed fill instead. The install, registry, and filter fields take a 16px face under a coarse pointer, which keeps iOS Safari from zooming a focused field.
As available header width decreases, Refresh and Add plugin first become icon-only controls with accessible names and tooltips; the tabs then shrink from their usual minimum width to fit their labels, and the action group wraps only when necessary. The information button follows the subtitle text. The main page header and list share one scroll container. Header controls move with the scroll position until they reach their compact positions; scrolling back follows the same coordinates. The header retains its resting layout height, so moving its controls does not change the list's scroll range. A barely scrolling list can stop before the header is fully compact. The More list's back-and-title row stays fixed, and detail pages scroll their head row with the content. The Installed/Discover tabs use the compact 34px control with a 2px inset around the selection indicator. The subtitle scrolls out of view; with tabs present, the title also scrolls out, and the tabs settle beside Refresh and Add plugin where space permits. On narrow pages, the tabs and icon actions share a row beneath the heading where they fit; otherwise the tabs stay above the actions. Their relative order stays the same throughout scrolling. The resting and compact layouts retain their 28px and 16px page-top insets respectively, with 16px below the compact controls. macOS adds the frame's titlebar clearance while resting; its compact bar keeps 16px above and below with the sidebar expanded, and with the sidebar collapsed it also clears the frame's top strip so the controls stay below the native window controls. Only on macOS, when the sidebar is collapsed, the compact controls keep 8px rather than 16px above them in addition to the unchanged frame clearance, at every width. The resting layout and compact bottom spacing stay unchanged. Windows relies on the frame's caption offset without adding it again. The divider spans the page at the bottom of the compact header; the list's initial top spacing scrolls with its content. Hover styling applies only where the pointer can hover, so a tap leaves no card, row, or menu option in its hover state; cards and icon actions answer a touch-down with the pressed fill instead. The install, registry, and filter fields take a 16px face under a coarse pointer, which keeps iOS Safari from zooming a focused field.
Installed bundles and their plugin rows display their own title and description in the current UI language on cards and detail pages. Each field falls back from exported locale `meta` to the accessible `package.json` of a package-root plugin; subpath plugins never read one; the final title is the full package or module name, with no package description when both sources omit it. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. Bundle cards, details, and component rows display the package root's manifest `icon` or exported `./icon`, or a subpath plugin's exported `<subpath>/icon`; absent or undecodable images retain the default artwork. Installation previews still use registry or manifest information.
@@ -31,7 +31,7 @@ kind: "package-reference"
在侧栏选择**插件**。信息按钮使用 11px 的 caption 色图标和 20px 方形悬停区域,与副标题间隔 4px。它说明当前页用于配置官方插件、安装和管理其他插件,**设置 → 内置插件**则展示内置插件列表及运行状态。悬停或聚焦显示共享 tooltip;点击后保持展开,直至再次点击、Escape、Tab 或外部点击。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**内置插件配置**按配置的顺序列出官方配置页:子智能体、网页搜索、Agent 循环、终端。**实验性插件**展示主要的可选组合包,标题旁的**更多**打开同名的独立页面。该页列出符合展示条件的官方组合包,也包含主列表已有的条目,Codex 和 Claude Code 子智能体排在最后。返回按钮回到主列表,**已安装插件**保持在实验性插件下方。组合包详情返回打开它的列表。目录条目在安装或移除后保持原位置。**已安装插件**列出其他 profile 组合包。安装随附的可选组合包没有卸载操作。实验性包在两个列表中均保留**实验性**标签。开启仅在该页列出的组合包会按该页中的位置把它加入主列表,关闭后它只留在该页,但 manifest 声明默认隐藏的组合包会从该页消失;其他组合包在两个列表中均保持原位。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**内置插件**分区中编辑。
空间不足时,页头将整个操作组移到下一行。操作文案保持单行,信息按钮紧随副标题文字。悬停样式只在可悬停的指针下生效,点按不会让卡片、行或菜单项停留在悬停态;卡片与图标操作改以按下填充回馈触摸。安装、注册表与筛选输入框在粗指针下使用 16px 字号,iOS Safari 不会因聚焦而放大页面。
页头可用宽度缩小时,“刷新”和“添加插件”先变为保留可访问名称与 tooltip 的图标按钮;标签再从常规最小宽度收紧为文案所需宽度,仍不足时操作组才换行。信息按钮紧随副标题文字。主页面的页头和列表共用一个滚动容器。页头控件随滚动位置移动,到达紧凑态位置后停住;反向滚动沿同样的坐标返回。页头保留静止态的布局高度,因此控件移动不会改变列表的滚动范围。只能滚动少量距离的列表可能在页头完全收拢前到达底部。“更多”列表的返回与标题行固定,详情页的页头随内容滚动。“已安装/发现”标签使用总高 34px 的紧凑控件,选中指示块与外框之间留 2px。副标题随滚动离开视口;有标签时标题也随之滚出,标签在空间足够时停靠到“刷新”“添加插件”旁边。窄页面中,标签与图标操作优先在标题下方共用一行;放不下时,标签始终在操作组上方。滚动过程中两组控件的相对顺序保持不变。静止态与紧凑态分别保留 28px 和 16px 的页面顶部留白,紧凑态控件下方保留 16px。macOS 静止态保留框架的标题栏安全区;折叠态在侧栏展开时上下各留 16px,侧栏收起时再避让框架顶部安全区,使控件位于原生窗口控件下方。仅在 macOS 侧栏收起时,紧凑态控件在原有框架安全区之外的顶部留白统一由 16px 减为 8px,不受窗口宽度影响;静止态布局与紧凑态底部留白均不变。Windows 使用框架已有的标题栏偏移,不重复叠加。分隔线在紧凑页头底部横跨页面,列表初始的顶部留白随内容滚动。悬停样式只在可悬停的指针下生效,点按不会让卡片、行或菜单项停留在悬停态;卡片与图标操作改以按下填充回馈触摸。安装、注册表与筛选输入框在粗指针下使用 16px 字号,iOS Safari 不会因聚焦而放大页面。
已安装的组合包及其插件行在卡片和详情页中,按当前界面语言显示各自的标题与描述。每个字段先读取导出的 locale `meta`,缺失时回退到包根插件可访问的 `package.json`,子路径插件从不读取;标题最终使用完整包名或模块名,两处都没有描述时不提供包描述。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。组合包卡片、详情和组件行显示包根的清单 `icon` 或导出的 `./icon`,或子路径插件导出的 `<子路径>/icon`;未声明或无法解码时保留默认插画。安装预览仍使用注册表或 manifest 信息。
@@ -1,4 +1,5 @@
/* Plugin manager page: a centred column with its header, notices, and the package cards.
/* Plugin manager page: the main header and content share one scroller; the header retains its flow height.
The More list has a separate fixed head, and detail views scroll with their content.
Hover affordances sit under @media (hover: hover): a tap latches :hover, which
would strand a card, row, or control in its pointer-only state. Text fields take
@@ -6,27 +7,144 @@
for a focused field under 16px and leaves the page pannable afterwards. */
.page {
--plugin-page-inset: clamp(24px, 4vw, 48px);
/* Every head row's top inset; the collapsed page bar narrows it. */
--plugin-page-top-inset: 28px;
--bundle-progress-size: 14px;
--bundle-toggle-gap: 8px;
display: flex;
flex-direction: column;
align-items: center;
box-sizing: border-box;
height: 100%;
/* Inline inset only: the 28px top inset belongs to the head row
(`.pageHead`, `.detailTop`, `.moreHead`). macOS window dragging is owned by a row's own box,
so an inset above that box leaves a top strip no drag region covers. */
padding: 0 var(--plugin-page-inset);
color: var(--dsw-alias-label-primary);
}
.page > * {
flex: none;
width: 100%;
max-width: 960px;
}
.pageBody > .pageBar {
container: plugin-page-bar / inline-size;
align-self: flex-start;
position: relative;
display: flex;
flex-direction: column;
width: calc(100% + 2 * var(--plugin-page-inset) + var(--plugin-page-scrollbar-width, 0px));
max-width: none;
margin-inline: calc(-1 * var(--plugin-page-inset));
box-sizing: border-box;
padding-inline: var(--plugin-page-inset);
}
.pageBar > .pageHead {
align-self: center;
width: 100%;
max-width: 960px;
}
/* Spans the page's full width, so its scrollbar sits at the page edge, and re-centres the column inside. */
.page > .pageBody {
flex: 1;
min-height: 0;
width: calc(100% + 2 * var(--plugin-page-inset));
max-width: none;
margin-inline: calc(-1 * var(--plugin-page-inset));
display: flex;
flex-direction: column;
align-items: center;
gap: 32px;
box-sizing: border-box;
height: 100%;
overflow: auto;
/* Inline and bottom insets only: the 28px top inset belongs to the head row
(`.pageHead`, `.detailTop`). macOS window dragging is owned by a row's own box,
so an inset above that box leaves a top strip no drag region covers. */
padding: 0 clamp(24px, 4vw, 48px) 48px;
color: var(--dsw-alias-label-primary);
padding: 0 var(--plugin-page-inset) 48px;
overflow-x: hidden;
overflow-y: auto;
position: relative;
scroll-padding-top: var(--plugin-header-scroll-padding, 0px);
}
.page > * {
/* The initial list inset scrolls away; the scroll viewport meets the fixed bar directly. */
.page > :not(.pageBody) + .pageBody {
padding-top: 32px;
}
.pageBody > * {
flex: none;
width: 100%;
max-width: 960px;
}
/* Endpoint copies never enter the scroll layout or the accessibility tree. */
.pageBody > .pageBar[data-measuring] {
position: absolute;
top: 0;
left: 0;
margin: 0;
visibility: hidden;
pointer-events: none;
}
.pageBody > .pageBar[data-natural] {
position: sticky;
top: 0;
z-index: 2;
pointer-events: none;
}
.pageBar[data-natural] .pageHead {
position: relative;
height: var(--plugin-header-rest-height);
clip-path: inset(0 0 max(0px, calc(var(--plugin-header-rest-height) - var(--plugin-header-paint-height))) 0);
pointer-events: auto;
}
.pageBar[data-natural] .pageHeading {
display: contents;
}
.pageBar[data-natural] [data-plugin-header-part] {
position: absolute;
left: var(--plugin-part-x);
top: var(--plugin-part-y);
width: var(--plugin-part-width);
margin: 0;
pointer-events: auto;
}
.pageBar[data-natural] .pageTabs {
z-index: 2;
pointer-events: none;
}
.pageBar[data-natural] .pageTabList {
pointer-events: auto;
}
.pageBar[data-natural]::before {
content: '';
position: absolute;
inset: 0 0 auto;
height: var(--plugin-header-paint-height, var(--plugin-header-rest-height));
background: var(--dsw-alias-bg-base);
pointer-events: none;
}
.pageBar[data-natural][data-compact]::after {
content: '';
position: absolute;
inset-inline: 0;
top: 0;
height: var(--plugin-header-compact-height);
box-sizing: border-box;
border-bottom: 0.5px solid var(--dsw-alias-border-l3);
}
.pageHead {
display: flex;
flex-wrap: wrap;
@@ -36,7 +154,7 @@
box-sizing: border-box;
/* The page's former top inset: the row starts at the window's top edge, so the whole
strip above the page content drags the window. */
padding-top: 28px;
padding-top: var(--plugin-page-top-inset);
}
/* macOS hiddenInset titlebar: the frame's top clearance keeps the head below
@@ -44,12 +162,13 @@
owns the window drag over its own box — clearance included — so this page's
top strip drags without a frame band. */
:global([data-platform='darwin']) .pageHead {
padding-top: calc(28px + var(--dsh-frame-top-clearance, 0px));
padding-top: calc(var(--plugin-page-top-inset) + var(--dsh-frame-top-clearance, 0px));
}
.pageHeading {
min-width: 0;
max-width: 100%;
order: 1;
}
.pageTitle {
@@ -86,6 +205,7 @@
gap: 8px 16px;
min-width: 0;
white-space: nowrap;
order: 2;
}
.addMenu {
@@ -1263,9 +1383,9 @@ label.registryOption {
.detailTop,
.moreHead {
display: flex;
/* The same 28px top inset `.pageHead` carries, so the row starts at the window's
/* The same top inset `.pageHead` carries, so the row starts at the window's
top edge and the strip above the detail content drags the window. */
padding-top: 28px;
padding-top: var(--plugin-page-top-inset);
}
.detailTop {
@@ -1274,7 +1394,7 @@ label.registryOption {
:global([data-platform='darwin']) .detailTop,
:global([data-platform='darwin']) .moreHead {
padding-top: calc(28px + var(--dsh-frame-top-clearance, 0px));
padding-top: calc(var(--plugin-page-top-inset) + var(--dsh-frame-top-clearance, 0px));
}
.crumb {
@@ -1565,11 +1685,64 @@ label.registryOption {
display: none;
}
/* Installed and contributed tabs under the page header, aligned with the title; the negative margin halves
the page's 32px row gap, so the tabs read as part of the header. */
/* Installed and contributed tabs under the page header at rest; the scrolled bar
pulls them onto the actions' row. */
.pageTabs {
display: flex;
margin-top: -16px;
order: 3;
flex: 1 1 100%;
}
/* Keep spacing inside the compact bar, above its divider rather than outside the scroll viewport. */
.pageBar[data-compact]:not([data-natural]) {
--plugin-page-top-inset: 16px;
padding-bottom: 16px;
}
/* With the sidebar open, native window controls sit in the sidebar rather than above the page. */
:global([data-platform='darwin']) .pageBar[data-compact]:not([data-natural]) .pageHead {
padding-top: var(--plugin-page-top-inset);
}
/* Collapsing the sidebar moves those controls above the page; the compact bar then clears them. */
:global([data-platform='darwin']) :global([data-sidebar-collapsed]) .pageBar[data-compact]:not([data-natural]) .pageHead {
--plugin-page-top-inset: 8px;
padding-top: calc(var(--plugin-page-top-inset) + var(--dsh-frame-top-clearance, 0px));
}
/* The collapsed bar's divider spans the page like the scrolled list and its scrollbar. */
.pageBar[data-compact]:not([data-natural])::after {
content: '';
position: absolute;
inset-inline: 0;
bottom: 0;
border-bottom: 0.5px solid var(--dsw-alias-border-l3);
pointer-events: none;
}
.pageBar[data-compact]:not([data-natural]) .pageHead {
align-items: center;
}
.pageBar[data-compact]:not([data-natural]) .pageIntro {
display: none;
}
/* Visually hidden rather than removed, so the page keeps its only heading for assistive technology. */
.pageBarWithTabs[data-compact]:not([data-natural]) .pageHeading {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.pageBarWithTabs[data-compact]:not([data-natural]) .pageTabs {
order: 1;
flex: 0 0 auto;
}
.pageTabList {
@@ -1577,6 +1750,56 @@ label.registryOption {
min-width: 240px;
}
@container plugin-page-bar (width < 600px) {
.pageBarWithTabs .pageHead {
align-items: center;
}
.pageBarWithTabs .pageHeading {
flex-basis: 100%;
}
.pageBarWithTabs .pageTabs {
order: 2;
flex: 0 0 auto;
}
.pageBarWithTabs .toolbar {
order: 3;
}
.toolbarLabel {
display: none;
}
.pageHead .addButton,
.pageHead .addPrimary {
width: 32px;
min-width: 32px;
padding-inline: 0;
}
/* The icon actions keep their width; a wrapped update note stays within the head. */
.toolbar {
flex-shrink: 0;
max-width: 100%;
}
.updatesNote {
order: 3;
flex-basis: 100%;
min-width: 0;
white-space: normal;
}
}
/* Keep the usual tab width until the icon actions and their gaps need its space. */
@container plugin-page-bar (width < 372px) {
.pageTabList {
min-width: 0;
}
}
/* The Installed groups keep the page's row gap inside their tab panel. */
.installedPanel {
display: flex;
@@ -22,6 +22,7 @@ import {
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import type { createNavigationStore } from './navigation-store.ts'
import type { PluginHeaderLayoutFace } from './header-scroll.ts'
import { rowConfigKey, type OfficialItem } from './config-ledger.ts'
import { INSTALL_GIT_EXAMPLE, INSTALL_PATH_EXAMPLE, type PluginManagerLocaleKey } from './locales.ts'
import {
@@ -45,7 +46,7 @@ export type PluginManagerPageProps =
| 'plugins.add.actions' | 'plugins.tab'
| 'plugins.detail.actions' | 'plugins.detail.badge' | 'plugins.detail.section'
>
& InjectFace<PluginManagerFace>
& InjectFace<PluginManagerFace & PluginHeaderLayoutFace>
& PropsStore<ReturnType<typeof createNavigationStore>>
/** The page's slot renderer, narrowed to the configuration slots. */
@@ -65,10 +66,12 @@ function AddPluginMenu({ t, disabled, openInstall, renderSlot }: {
const onDismiss = (): void => { setOpen(false) }
return (
<span className={css.addGroup} role="group" aria-label={t('addPlugin')}>
<Button variant="primary" size="sm" className={css.addPrimary} icon={<IconPlusOutlineRegular size={13} />}
disabled={disabled} onClick={() => { onDismiss(); openInstall() }}>
{t('addPlugin')}
</Button>
<Tooltip label={t('addPlugin')} side="bottom" delayMs={500} focusDelayMs={500} portal disabled={disabled}>
<Button variant="primary" size="sm" className={css.addPrimary} icon={<IconPlusOutlineRegular size={13} />}
aria-label={t('addPlugin')} disabled={disabled} onClick={() => { onDismiss(); openInstall() }}>
<span className={css.toolbarLabel}>{t('addPlugin')}</span>
</Button>
</Tooltip>
<Menu open={open} onClose={onDismiss} align="end" portal autoFocus listClassName={css.addMenu}
anchor={(
<Button variant="primary" size="sm" className={css.addMore}
@@ -1551,6 +1554,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
useEffect(() => { if (tab !== selectedTab) setTab(tab) }, [tab, selectedTab, setTab])
const tabsId = useId()
const [activation, setActivation] = useState<string | null>(null)
const [headerRef] = useState(props.createHeaderRef)
useEffect(() => { ensure() }, [ensure])
// Installation highlights visible cards without changing the user's current page.
const { highlight, clearHighlight } = { highlight: state.highlight, clearHighlight: props.clearHighlight }
@@ -1645,64 +1649,6 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
return (
<section className={css.page} data-plugin-panel aria-busy={state.status === 'loading' || refreshing}>
{showsMainList
? (
<header className={css.pageHead} data-window-drag>
<div className={css.pageHeading}>
<h1 className={css.pageTitle}>{t('title')}</h1>
<div className={css.pageIntro}>
<span>{t('intro')}</span>
<Tooltip label={t('infoDescription')} side="bottom" delayMs={300} maxWidth={300} portal openOnClick>
<Button variant="ghost" size="sm" className={css.infoButton} aria-label={t('infoLabel')}>
<IconInfoOutlineRegular size={11} aria-hidden="true" />
</Button>
</Tooltip>
</div>
</div>
<div className={css.toolbar} hidden={!onInstalled}>
{updates === undefined ? null : <span className={css.updatesNote} data-tone={updates.tone}>{updates.text}</span>}
<Tooltip label={t('refresh')} delayMs={500} focusDelayMs={500} side="bottom" portal disabled={!loaded || refreshing}>
<Button variant="outline" size="sm" className={css.addButton} aria-label={t('refresh')} aria-busy={refreshing}
disabled={!loaded || refreshing} onClick={props.refresh}
icon={refreshing ? <StateDot state="ongoing" size={13} /> : <IconRefreshOutlineRegular size={13} />}>
{t('refresh')}
</Button>
</Tooltip>
{state.install.requestId === undefined
? <AddPluginMenu t={t} disabled={!loaded} openInstall={props.openInstall} renderSlot={renderSlot} />
: <Button variant="primary" size="sm" className={css.addButton} icon={<IconPlusOutlineRegular size={13} />} disabled={!loaded} onClick={props.openInstall}>
{t('installViewTask')}
</Button>}
</div>
</header>
)
: null}
{showsMainList && tabs.length > 0
? (
<div className={css.pageTabs}>
<SegmentedTabs
className={css.pageTabList}
label={t('tabsLabel')}
value={tab}
onChange={(next) => { setTab(next) }}
items={[
{ value: INSTALLED_TAB, label: t('installedTab'), id: `${tabsId}-${INSTALLED_TAB}`, panelId: `${tabsId}-${INSTALLED_TAB}-panel` },
...tabs.map(item => ({ value: item.id, label: item.label, id: `${tabsId}-${item.id}`, panelId: `${tabsId}-${item.id}-panel` })),
]}
/>
</div>
)
: null}
{!onInstalled
? (
<div role="tabpanel" id={`${tabsId}-${tab}-panel`} aria-labelledby={`${tabsId}-${tab}`} className={css.tabPanel}>
{renderSlot('plugins.tab', {
install: props.installFrom,
openPackage: (name) => { setTab(INSTALLED_TAB); setView({ kind: 'package', name, returnTab: tab }) },
}, { only: tab })}
</div>
)
: null}
{showsMoreList ? (
<header className={css.moreHead} data-window-drag>
<Button size="sm" className={css.moreBack} aria-label={t('backToList')} onClick={() => { setView({ kind: 'list' }) }}>
@@ -1711,126 +1657,199 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
<h1 className={css.pageTitle}>{t('moreTitle')}</h1>
</header>
) : null}
{installedCards && !loaded ? (
<div className={css.installedPanel} {...installedPanel}>
{state.status === 'loading' ? <ListSkeleton label={t('loading')} /> : null}
{state.status === 'unavailable' ? (
<p className={`${css.status} ${css.statusWithDot}`} role="status">
<StateDot state="idle" />{t('unavailable')}
</p>
) : null}
</div>
) : null}
{state.notice === null || noticeLine === null
? null
: (
<Toast
key={state.notice.seq}
text={noticeLine}
icon={<IconWarningOutlineRegular />}
holdMs={toastHoldMs(noticeLine)}
onDone={props.dismissNotice}
/>
)}
{!showsCards && state.status === 'error' && !refreshing
? (
<div className={css.failure}>
<p className={css.statusWithDot} role="alert">
<StateDot state="error" />{t(state.refreshStatus === 'failed' ? 'refreshError' : 'error')}
</p>
<Button variant="outline" size="sm" onClick={props.refresh}>{t('retry')}</Button>
</div>
)
: null}
{onInstalled && loaded && openPkg !== undefined && openRow !== undefined
? (
<RowDetail
pkg={openPkg}
row={openRow}
form={formFor(openRow.rowId)}
t={t}
resolveText={resolveText}
renderSlot={renderSlot}
onBack={() => { setView({ kind: 'package', name: openPkg.name, ...pkgOrigin }) }}
/>
)
: null}
{onInstalled && loaded && openPkg !== undefined && openRow === undefined
? (
<PackageDetail
pkg={openPkg}
t={t}
resolveText={resolveText}
busy={packageBusy(openPkg)}
rowBusy={row => row.entryId !== undefined && state.busy.includes(rowKey(row.entryId))}
configured={ledger.bundles.has(openPkg.name)}
configure={configure(openPkg)}
renderSlot={renderSlot}
inMore={backToMore}
detail={(
<BundleDetailSection name={openPkg.name} version={openPkg.version} fromNpm={openPkg.availability === 'missing'}
language={language} details={bundleDetails} t={t}
load={props.loadDetail} retry={props.retryDetail} image={props.detailImage} />
)}
onBack={() => {
if (returnTab !== undefined) setTab(returnTab)
setView({ kind: backToMore ? 'more' : 'list' })
}}
onSetEnabled={(enabled) => { props.setEnabled(openPkg.name, enabled) }}
onUninstall={() => { props.uninstall(openPkg.name) }}
onUpdate={() => { props.update(openPkg.name) }}
onSetRowEnabled={setRowEnabled}
versions={state.versions[openPkg.name]}
onEnsureVersions={props.ensureVersions}
onCheckVersions={() => { props.checkVersions(openPkg.name) }}
onUpdateVersion={(version) => { props.openUpdate(openPkg.name, version) }}
/>
)
: null}
{onInstalled && loaded && openItem !== undefined
? <ItemDetail form={formFor(openItem.id)} item={openItem} t={t} renderSlot={renderSlot} onBack={() => { setView({ kind: 'list' }) }} />
: null}
{loaded && installedCards
? basicCards.length === 0 && official.length === 0 && mine.length === 0 && state.status !== 'error'
? <div className={css.installedPanel} {...installedPanel}><p className={css.empty}>{t('empty')}</p></div>
: (
<div className={css.installedPanel} {...installedPanel}>
{showsMoreList ? (
<section className={css.group} data-plugin-scope="global" data-plugin-group="more">
<ul className={css.cards}>{more.map(packageCard)}</ul>
</section>
) : (
<>
{renderGroup('basic', t('basicTitle'), basicCards)}
{renderGroup('extensions', t('extensionsTitle'), extensions.map(packageCard))}
{renderGroup('bundles', t('bundlesTitle'), mine.map(packageCard))}
</>
)}
{/* A failed package read trails the groups it left incomplete: right under the groups on a
first-load failure, and after the kept cards when a refresh fails over stale data. */}
{state.status === 'error' && !refreshing
? (
<div className={css.failure}>
<p className={css.statusWithDot} role="alert">
<StateDot state="error" />{t(state.refreshStatus === 'failed' ? 'refreshError' : 'error')}
</p>
<Button variant="outline" size="sm" onClick={props.refresh}>{t('retry')}</Button>
<div className={css.pageBody} data-plugin-body>
{showsMainList
? (
<div
className={`${css.pageBar}${tabs.length > 0 ? ` ${css.pageBarWithTabs}` : ''}`}
data-plugin-bar
ref={headerRef}
>
<header className={css.pageHead} data-window-drag>
<div className={css.pageHeading}>
<h1 className={css.pageTitle} data-plugin-header-part="title">{t('title')}</h1>
<div className={css.pageIntro} data-plugin-header-part="intro">
<span>{t('intro')}</span>
<Tooltip label={t('infoDescription')} side="bottom" delayMs={300} maxWidth={300} portal openOnClick>
<Button variant="ghost" size="sm" className={css.infoButton} aria-label={t('infoLabel')}>
<IconInfoOutlineRegular size={11} aria-hidden="true" />
</Button>
</Tooltip>
</div>
)
: null}
</div>
<div className={css.toolbar} data-plugin-toolbar data-plugin-header-part="toolbar" hidden={!onInstalled}>
{updates === undefined ? null : <span className={css.updatesNote} data-tone={updates.tone}>{updates.text}</span>}
<Tooltip label={t('refresh')} delayMs={500} focusDelayMs={500} side="bottom" portal disabled={!loaded || refreshing}>
<Button variant="outline" size="sm" className={css.addButton} aria-label={t('refresh')} aria-busy={refreshing}
disabled={!loaded || refreshing} onClick={props.refresh}
icon={refreshing ? <StateDot state="ongoing" size={13} /> : <IconRefreshOutlineRegular size={13} />}>
<span className={css.toolbarLabel}>{t('refresh')}</span>
</Button>
</Tooltip>
{state.install.requestId === undefined
? <AddPluginMenu t={t} disabled={!loaded} openInstall={props.openInstall} renderSlot={renderSlot} />
: (
<Tooltip label={t('installViewTask')} side="bottom" delayMs={500} focusDelayMs={500} portal disabled={!loaded}>
<Button variant="primary" size="sm" className={css.addButton} icon={<IconPlusOutlineRegular size={13} />}
aria-label={t('installViewTask')} disabled={!loaded} onClick={props.openInstall}>
<span className={css.toolbarLabel}>{t('installViewTask')}</span>
</Button>
</Tooltip>
)}
</div>
{tabs.length > 0
? (
<div className={css.pageTabs} data-plugin-header-part="tabs">
<SegmentedTabs
size="sm"
className={css.pageTabList}
label={t('tabsLabel')}
value={tab}
onChange={(next) => { setTab(next) }}
items={[
{ value: INSTALLED_TAB, label: t('installedTab'), id: `${tabsId}-${INSTALLED_TAB}`, panelId: `${tabsId}-${INSTALLED_TAB}-panel` },
...tabs.map(item => ({ value: item.id, label: item.label, id: `${tabsId}-${item.id}`, panelId: `${tabsId}-${item.id}-panel` })),
]}
/>
</div>
)
: null}
</header>
</div>
)
: null}
{/* Activation guidance belongs beside the Installed list that holds the enabled bundle's card. */}
{installedCards && activated !== undefined && !state.install.open
? renderSlot('plugins.bundle.activation', {
packageName: activated.name,
onDismiss: () => { setActivation(null) },
onOpenDetails: () => {
setActivation(null)
setView({ kind: 'package', name: activated.name, ...showsMoreList ? { from: 'more' } : {} })
},
}, { entryKey: activated.name }) : null}
: null}
{!onInstalled
? (
<div role="tabpanel" id={`${tabsId}-${tab}-panel`} aria-labelledby={`${tabsId}-${tab}`} className={css.tabPanel}>
{renderSlot('plugins.tab', {
install: props.installFrom,
openPackage: (name) => { setTab(INSTALLED_TAB); setView({ kind: 'package', name, returnTab: tab }) },
}, { only: tab })}
</div>
)
: null}
{installedCards && !loaded ? (
<div className={css.installedPanel} {...installedPanel}>
{state.status === 'loading' ? <ListSkeleton label={t('loading')} /> : null}
{state.status === 'unavailable' ? (
<p className={`${css.status} ${css.statusWithDot}`} role="status">
<StateDot state="idle" />{t('unavailable')}
</p>
) : null}
</div>
) : null}
{state.notice === null || noticeLine === null
? null
: (
<Toast
key={state.notice.seq}
text={noticeLine}
icon={<IconWarningOutlineRegular />}
holdMs={toastHoldMs(noticeLine)}
onDone={props.dismissNotice}
/>
)}
{!showsCards && state.status === 'error' && !refreshing
? (
<div className={css.failure}>
<p className={css.statusWithDot} role="alert">
<StateDot state="error" />{t(state.refreshStatus === 'failed' ? 'refreshError' : 'error')}
</p>
<Button variant="outline" size="sm" onClick={props.refresh}>{t('retry')}</Button>
</div>
)
: null}
{onInstalled && loaded && openPkg !== undefined && openRow !== undefined
? (
<RowDetail
pkg={openPkg}
row={openRow}
form={formFor(openRow.rowId)}
t={t}
resolveText={resolveText}
renderSlot={renderSlot}
onBack={() => { setView({ kind: 'package', name: openPkg.name, ...pkgOrigin }) }}
/>
)
: null}
{onInstalled && loaded && openPkg !== undefined && openRow === undefined
? (
<PackageDetail
pkg={openPkg}
t={t}
resolveText={resolveText}
busy={packageBusy(openPkg)}
rowBusy={row => row.entryId !== undefined && state.busy.includes(rowKey(row.entryId))}
configured={ledger.bundles.has(openPkg.name)}
configure={configure(openPkg)}
renderSlot={renderSlot}
inMore={backToMore}
detail={(
<BundleDetailSection name={openPkg.name} version={openPkg.version} fromNpm={openPkg.availability === 'missing'}
language={language} details={bundleDetails} t={t}
load={props.loadDetail} retry={props.retryDetail} image={props.detailImage} />
)}
onBack={() => {
if (returnTab !== undefined) setTab(returnTab)
setView({ kind: backToMore ? 'more' : 'list' })
}}
onSetEnabled={(enabled) => { props.setEnabled(openPkg.name, enabled) }}
onUninstall={() => { props.uninstall(openPkg.name) }}
onUpdate={() => { props.update(openPkg.name) }}
onSetRowEnabled={setRowEnabled}
versions={state.versions[openPkg.name]}
onEnsureVersions={props.ensureVersions}
onCheckVersions={() => { props.checkVersions(openPkg.name) }}
onUpdateVersion={(version) => { props.openUpdate(openPkg.name, version) }}
/>
)
: null}
{onInstalled && loaded && openItem !== undefined
? <ItemDetail form={formFor(openItem.id)} item={openItem} t={t} renderSlot={renderSlot} onBack={() => { setView({ kind: 'list' }) }} />
: null}
{loaded && installedCards
? basicCards.length === 0 && official.length === 0 && mine.length === 0 && state.status !== 'error'
? <div className={css.installedPanel} {...installedPanel}><p className={css.empty}>{t('empty')}</p></div>
: (
<div className={css.installedPanel} {...installedPanel}>
{showsMoreList ? (
<section className={css.group} data-plugin-scope="global" data-plugin-group="more">
<ul className={css.cards}>{more.map(packageCard)}</ul>
</section>
) : (
<>
{renderGroup('basic', t('basicTitle'), basicCards)}
{renderGroup('extensions', t('extensionsTitle'), extensions.map(packageCard))}
{renderGroup('bundles', t('bundlesTitle'), mine.map(packageCard))}
</>
)}
{/* A failed package read trails the groups it left incomplete: right under the groups on a
first-load failure, and after the kept cards when a refresh fails over stale data. */}
{state.status === 'error' && !refreshing
? (
<div className={css.failure}>
<p className={css.statusWithDot} role="alert">
<StateDot state="error" />{t(state.refreshStatus === 'failed' ? 'refreshError' : 'error')}
</p>
<Button variant="outline" size="sm" onClick={props.refresh}>{t('retry')}</Button>
</div>
)
: null}
</div>
)
: null}
{/* Activation guidance belongs beside the Installed list that holds the enabled bundle's card. */}
{installedCards && activated !== undefined && !state.install.open
? renderSlot('plugins.bundle.activation', {
packageName: activated.name,
onDismiss: () => { setActivation(null) },
onOpenDetails: () => {
setActivation(null)
setView({ kind: 'package', name: activated.name, ...showsMoreList ? { from: 'more' } : {} })
},
}, { entryKey: activated.name }) : null}
</div>
{state.update === null ? null : (
<UpdateDialog
update={state.update}
@@ -0,0 +1,163 @@
/** Scroll-linked header geometry keeps both responsive layouts without changing the list's flow height. */
/** Entry-owned measurement callbacks for the plugin page. */
export interface PluginHeaderLayoutFace {
/** Create one element-ref lifecycle for each mounted page header. */
createHeaderRef: () => (element: HTMLDivElement | null) => void
}
interface Box {
x: number
y: number
width: number
height: number
}
interface Part {
element: HTMLElement
from: Box
to: Box
distance: number
clipStart?: number
clipEnd?: number
}
const PARTS = ['title', 'intro', 'toolbar', 'tabs'] as const
/**
* Measure the existing resting and compact CSS layouts in an inert, out-of-flow copy.
* Their independent flex wrapping requires pixel measurements; interpolating only positioned
* contents leaves the real header's resting footprint and the scroll range unchanged.
* @returns A ref callback that disposes its element observers and scroll listener on detachment.
*/
export function createPluginHeaderRef(): (element: HTMLDivElement | null) => void {
let dispose: (() => void) | undefined
return (element) => {
dispose?.()
dispose = element === null ? undefined : attach(element)
}
}
function attach(bar: HTMLDivElement): () => void {
const body = bar.parentElement as HTMLElement
const parts: Part[] = []
let distance = 1
let restHeight = 0
let compactHeight = 0
let disposed = false
const scroll = (): void => {
const offset = Math.max(0, body.scrollTop)
bar.toggleAttribute('data-compact', offset >= distance)
bar.toggleAttribute('data-scrolled', offset > 0)
for (const part of parts) {
const progress = Math.min(1, offset / part.distance)
part.element.style.translate = `${(part.to.x - part.from.x) * progress}px ${(part.to.y - part.from.y) * progress}px`
part.element.style.width = `${part.from.width + (part.to.width - part.from.width) * progress}px`
if (part.clipStart !== undefined && part.clipEnd !== undefined) {
const clip = Math.max(0, Math.min(1, (offset - part.clipStart) / (part.clipEnd - part.clipStart)))
part.element.style.clipPath = `inset(${clip * 100}% 0 0)`
} else {
part.element.style.removeProperty('clip-path')
}
if (part.element.dataset.pluginHeaderPart === 'intro') {
part.element.inert = offset >= (part.clipEnd ?? part.distance)
}
}
bar.style.setProperty('--plugin-header-paint-height', `${Math.max(compactHeight, restHeight - offset)}px`)
}
const measure = (): void => {
if (disposed) return
body.style.setProperty('--plugin-page-scrollbar-width', `${body.offsetWidth - body.clientWidth}px`)
const clone = bar.cloneNode(true) as HTMLDivElement
clone.removeAttribute('data-natural')
clone.removeAttribute('data-compact')
clone.removeAttribute('data-scrolled')
clone.setAttribute('data-measuring', '')
clone.setAttribute('aria-hidden', 'true')
clone.inert = true
clone.style.width = `${bar.getBoundingClientRect().width}px`
for (const element of clone.querySelectorAll<HTMLElement>('[id], [data-plugin-header-part]')) {
element.removeAttribute('id')
if (element.hasAttribute('data-plugin-header-part')) element.removeAttribute('style')
}
body.append(clone)
const snapshot = (): { height: number; boxes: Map<string, Box> } => {
const head = (clone.querySelector('header') as HTMLElement).getBoundingClientRect()
const boxes = new Map<string, Box>()
for (const name of PARTS) {
const element = clone.querySelector<HTMLElement>(`[data-plugin-header-part="${name}"]`)
if (element === null) continue
const box = element.getBoundingClientRect()
boxes.set(name, { x: box.x - head.x, y: box.y - head.y, width: box.width, height: box.height })
}
return { height: clone.getBoundingClientRect().height, boxes }
}
let resting: ReturnType<typeof snapshot>
let compact: ReturnType<typeof snapshot>
try {
resting = snapshot()
clone.setAttribute('data-compact', '')
compact = snapshot()
} finally {
clone.remove()
}
restHeight = resting.height
compactHeight = compact.height
distance = Math.max(1, restHeight - compactHeight)
parts.length = 0
const hasTabs = resting.boxes.has('tabs')
const compactTitle = compact.boxes.get('title') as Box
for (const name of PARTS) {
const from = resting.boxes.get(name)
if (from === undefined) continue
const element = bar.querySelector(`[data-plugin-header-part="${name}"]`) as HTMLElement
let to = compact.boxes.get(name) as Box
let clipStart: number | undefined
let clipEnd: number | undefined
if (name === 'intro' || (name === 'title' && hasTabs)) {
to = { ...from, y: -from.height }
if (name === 'intro' && !hasTabs) {
clipStart = Math.max(0, from.y - compactTitle.y - compactTitle.height)
clipEnd = clipStart + from.height
to.y = from.y - clipEnd
}
}
const travel = Math.max(1, Math.abs(to.y - from.y))
distance = Math.max(distance, travel)
element.style.setProperty('--plugin-part-x', `${from.x}px`)
element.style.setProperty('--plugin-part-y', `${from.y}px`)
element.style.setProperty('--plugin-part-width', `${from.width}px`)
parts.push({
element, from, to, distance: travel,
...clipStart !== undefined && clipEnd !== undefined ? { clipStart, clipEnd } : {},
})
}
bar.style.setProperty('--plugin-header-rest-height', `${restHeight}px`)
bar.style.setProperty('--plugin-header-compact-height', `${compactHeight}px`)
bar.style.setProperty('--plugin-header-travel', `${distance}px`)
body.style.setProperty('--plugin-header-scroll-padding', `${compactHeight + 8}px`)
bar.setAttribute('data-natural', '')
scroll()
}
measure()
const resize = new ResizeObserver(measure)
resize.observe(body)
const mutation = new MutationObserver(measure)
mutation.observe(bar.querySelector('header') as HTMLElement, {
childList: true, characterData: true, subtree: true, attributes: true, attributeFilter: ['hidden'],
})
body.addEventListener('scroll', scroll, { passive: true })
document.fonts.addEventListener('loadingdone', measure)
return () => {
disposed = true
resize.disconnect()
mutation.disconnect()
body.removeEventListener('scroll', scroll)
document.fonts.removeEventListener('loadingdone', measure)
body.style.removeProperty('--plugin-header-scroll-padding')
body.style.removeProperty('--plugin-page-scrollbar-width')
}
}
@@ -24,6 +24,7 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client'
// through the owning package's client-safe types subpath).
import type {} from '@deepseek-ai/dsh-plugin-manager/types'
import { PluginManagerPage } from './PluginManagerPage.tsx'
import { createPluginHeaderRef } from './header-scroll.ts'
import { PluginRefreshToast, type PluginRefreshToastFace } from './PluginRefreshToast.tsx'
import { PluginsPanelIcon } from './PluginsPanelIcon.tsx'
import { configLedgerSource } from './config-ledger.ts'
@@ -133,7 +134,7 @@ export function apply(ctx: ClientContext): void {
key: PANEL_ID,
locale: NS,
store,
inject: () => face,
inject: () => ({ ...face, createHeaderRef: createPluginHeaderRef }),
children: {
'plugins.add.actions': { kind: 'list', scope: 'root' },
'plugins.tab': { kind: 'list', scope: 'root' },
@@ -162,6 +162,7 @@ function renderTab(
}
const navigation = createNavigationStore().create()
const props: PluginManagerPageProps = {
createHeaderRef: vi.fn(() => vi.fn()),
useStore: bindSnapshotSelector(navigation), actions: navigation.actions,
...standard,
t,
@@ -240,10 +241,13 @@ describe('PluginManagerPage', () => {
expect(screen.queryByRole('tablist')).toBeNull()
setTabs([{ id: 'discover', label: 'Discover' }])
expect(screen.getByRole('tab', { name: en.installedTab }).getAttribute('aria-selected')).toBe('true')
// The tabs stay in the pinned page head with the title; the tab panel scrolls in the page body.
expect(screen.getByRole('tablist').closest('header')?.closest('[data-plugin-bar]')).not.toBeNull()
expect(document.querySelector('[data-plugin-group="bundles"]')).not.toBeNull()
fireEvent.click(screen.getByRole('tab', { name: 'Discover' }))
expect(navigation.getSnapshot().tab).toBe('discover')
expect(screen.getByRole('tabpanel').getAttribute('aria-labelledby')).toBe(screen.getByRole('tab', { name: 'Discover' }).id)
expect(screen.getByRole('tabpanel').closest('[data-plugin-body]')).not.toBeNull()
expect(document.querySelector('[data-plugin-group="bundles"]')).toBeNull()
expect(screen.queryByRole('button', { name: en.refresh })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Discover install' }))
@@ -277,6 +281,22 @@ describe('PluginManagerPage', () => {
expect(screen.getByRole('tabpanel', { name: 'Discover' })).toBeTruthy()
})
it('keeps the header controls in the scrolling page without remounting them', () => {
const { setTabs, props, hidePage } = renderTab({ packages: [pkg()] })
const bar = document.querySelector('[data-plugin-bar]')
const body = document.querySelector('[data-plugin-body]')!
const refresh = screen.getByRole('button', { name: en.refresh })
expect(bar?.parentElement).toBe(body)
expect(props.createHeaderRef).toHaveBeenCalledTimes(1)
setTabs([{ id: 'discover', label: 'Discover' }])
expect(screen.getByRole('tablist').closest('header')?.closest('[data-plugin-bar]')).toBe(bar)
act(() => { body.scrollTop = 160; fireEvent.scroll(body) })
expect(screen.getByRole('button', { name: en.refresh })).toBe(refresh)
expect(props.createHeaderRef).toHaveBeenCalledTimes(1)
hidePage()
expect(document.querySelector('[data-plugin-bar]')).toBeNull()
})
it('keeps the Installed tab panel through loading and the More list out of the tabs, and returns a More plugin opened from Discover to Discover', () => {
const worktree = '@deepseek-ai/dsh-experimental-tool-worktree'
const { navigation, set } = renderTab({ status: 'loading', packages: [] }, {}, {
@@ -0,0 +1,390 @@
// @vitest-environment jsdom
/** Deterministic layout endpoints exercise scroll geometry without jsdom's absent layout engine. */
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import { createPluginHeaderRef } from '../src/client/header-scroll.ts'
type PartName = 'title' | 'intro' | 'toolbar' | 'tabs'
interface Box { x: number; y: number; width: number; height: number }
interface Layout {
width: number
resting: { height: number; parts: Partial<Record<PartName, Box>> }
compact: { height: number; parts: Partial<Record<PartName, Box>> }
}
function box(x: number, y: number, width: number, height: number): Box {
return { x, y, width, height }
}
function wide(tabs = true): Layout {
return {
width: 960,
resting: {
height: tabs ? 140 : 90,
parts: {
title: box(0, 28, 120, 28), intro: box(0, 60, 320, 20), toolbar: box(600, 28, 220, 32),
...tabs ? { tabs: box(0, 100, 240, 30) } : {},
},
},
compact: {
height: 64,
parts: {
title: box(0, 16, 120, 28), intro: box(0, 0, 0, 0), toolbar: box(600, 16, 220, 32),
...tabs ? { tabs: box(0, 16, 180, 30) } : {},
},
},
}
}
function narrow(): Layout {
return {
width: 320,
resting: {
height: 210,
parts: {
title: box(0, 28, 120, 28), intro: box(0, 60, 280, 20),
toolbar: box(0, 90, 280, 32), tabs: box(0, 160, 280, 30),
},
},
compact: {
height: 70,
parts: {
title: box(0, 16, 120, 28), intro: box(0, 0, 0, 0),
toolbar: box(200, 16, 80, 32), tabs: box(0, 16, 160, 30),
},
},
}
}
function harness() {
const fontsDescriptor = Object.getOwnPropertyDescriptor(document, 'fonts')
const fonts = new EventTarget()
const releases: (() => void)[] = []
onTestFinished(() => {
for (const release of releases.reverse()) release()
if (fontsDescriptor === undefined) Reflect.deleteProperty(document, 'fonts')
else Object.defineProperty(document, 'fonts', fontsDescriptor)
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
Object.defineProperty(document, 'fonts', { configurable: true, value: fonts })
const resizes: ControlledResize[] = []
const mutations: ControlledMutation[] = []
class ControlledResize implements ResizeObserver {
observe = vi.fn<(target: Element, options?: ResizeObserverOptions) => void>()
unobserve = vi.fn<(target: Element) => void>()
disconnect = vi.fn<() => void>()
constructor(private readonly callback: ResizeObserverCallback) { resizes.push(this) }
deliver(): void { this.callback([], this) }
}
class ControlledMutation implements MutationObserver {
observe = vi.fn<(target: Node, options?: MutationObserverInit) => void>()
disconnect = vi.fn<() => void>()
takeRecords(): MutationRecord[] { return [] }
constructor(private readonly callback: MutationCallback) { mutations.push(this) }
deliver(): void { this.callback([], this) }
}
vi.stubGlobal('ResizeObserver', ControlledResize)
vi.stubGlobal('MutationObserver', ControlledMutation)
function fixture(initial: Layout = wide(), ref = createPluginHeaderRef()) {
const body = document.createElement('div')
const bar = document.createElement('div')
const header = document.createElement('header')
const content = document.createElement('div')
const index = releases.length
const elements = new Map<PartName, HTMLDivElement>()
for (const name of Object.keys(initial.resting.parts) as PartName[]) {
const part = document.createElement('div')
part.dataset.pluginHeaderPart = name
part.id = `header-${index}-${name}`
if (name === 'toolbar' || name === 'intro') {
const button = document.createElement('button')
button.id = `header-${index}-${name}-button`
button.textContent = name
part.append(button)
} else part.textContent = name
header.append(part)
elements.set(name, part)
}
bar.append(header)
content.textContent = 'Plugin cards'
body.append(bar, content)
document.body.append(body)
releases.push(() => { ref(null); body.remove() })
const state = { layout: initial }
const clones: HTMLDivElement[] = []
const measurements: {
compact: boolean
inert: boolean
ariaHidden: string | null
ids: number
styles: number
natural: boolean
scrolled: boolean
connected: boolean
width: string
}[] = []
vi.spyOn(bar, 'getBoundingClientRect').mockImplementation(() => new DOMRect(50, 100, state.layout.width, state.layout.resting.height))
const cloneNode = bar.cloneNode.bind(bar)
vi.spyOn(bar, 'cloneNode').mockImplementation((deep) => {
const clone = cloneNode(deep) as HTMLDivElement
clones.push(clone)
const cloneHeader = clone.querySelector('header')!
// A different viewport origin ensures only header-relative coordinates drive the live controls.
vi.spyOn(cloneHeader, 'getBoundingClientRect').mockImplementation(() => {
measurements.push({
compact: clone.hasAttribute('data-compact'), inert: clone.inert,
ariaHidden: clone.getAttribute('aria-hidden'), ids: clone.querySelectorAll('[id]').length,
styles: clone.querySelectorAll('[data-plugin-header-part][style]').length,
natural: clone.hasAttribute('data-natural'), scrolled: clone.hasAttribute('data-scrolled'),
connected: clone.isConnected, width: clone.style.width,
})
return new DOMRect(180, 240, state.layout.width, 0)
})
vi.spyOn(clone, 'getBoundingClientRect').mockImplementation(() =>
new DOMRect(180, 240, state.layout.width, endpoint().height))
function endpoint() { return clone.hasAttribute('data-compact') ? state.layout.compact : state.layout.resting }
for (const name of elements.keys()) {
const part = clone.querySelector<HTMLElement>(`[data-plugin-header-part="${name}"]`)!
vi.spyOn(part, 'getBoundingClientRect').mockImplementation(() => {
const bounds = endpoint().parts[name]!
return new DOMRect(180 + bounds.x, 240 + bounds.y, bounds.width, bounds.height)
})
}
return clone
})
function part(name: PartName): HTMLDivElement {
const element = elements.get(name)
if (element === undefined) throw new Error(`Fixture has no ${name}`)
return element
}
function scroll(top: number): void {
body.scrollTop = top
body.dispatchEvent(new Event('scroll'))
}
return { body, bar, header, content, state, clones, measurements, part, scroll, ref, attach: () => { ref(bar) } }
}
return { fonts, resizes, mutations, fixture }
}
describe('plugin header scroll geometry', () => {
it.each([0, 12, 600])('clamps each trajectory and reverses without using a %ipx scroll range', (range) => {
const h = harness(), f = h.fixture()
Object.defineProperties(f.body, { clientHeight: { value: 500 }, scrollHeight: { value: 500 + range } })
f.attach()
expect(f.bar.style.getPropertyValue('--plugin-header-travel')).toBe('84px')
expect(f.bar.style.getPropertyValue('--plugin-header-rest-height')).toBe('140px')
expect(f.body.style.getPropertyValue('--plugin-header-scroll-padding')).toBe('72px')
f.scroll(6)
expect(f.part('toolbar').style.translate).toBe('0px -6px')
expect(f.part('tabs').style.translate).toBe('0px -6px')
expect(f.bar.hasAttribute('data-compact')).toBe(false)
expect(f.bar.hasAttribute('data-scrolled')).toBe(true)
f.scroll(20)
expect(f.part('toolbar').style.translate).toBe('0px -12px')
expect(f.part('tabs').style.translate).toBe('0px -20px')
expect(f.bar.style.getPropertyValue('--plugin-header-paint-height')).toBe('120px')
f.scroll(83)
expect(f.bar.hasAttribute('data-compact')).toBe(false)
f.scroll(84)
expect(f.bar.hasAttribute('data-compact')).toBe(true)
expect(f.part('title').style.translate).toBe('0px -56px')
expect(f.part('intro').style.translate).toBe('0px -80px')
expect(f.part('tabs').style.translate).toBe('0px -84px')
f.scroll(1_000)
expect(f.part('tabs').style.translate).toBe('0px -84px')
expect(f.bar.style.getPropertyValue('--plugin-header-paint-height')).toBe('64px')
f.scroll(6)
expect(f.part('toolbar').style.translate).toBe('0px -6px')
expect(f.part('intro').inert).toBe(false)
expect(f.bar.hasAttribute('data-compact')).toBe(false)
f.scroll(-10)
expect(f.part('tabs').style.translate).toBe('0px 0px')
expect(f.bar.hasAttribute('data-scrolled')).toBe(false)
expect(f.bar.style.getPropertyValue('--plugin-header-paint-height')).toBe('140px')
expect(f.bar.style.getPropertyValue('--plugin-header-rest-height')).toBe('140px')
expect(f.body.scrollHeight - f.body.clientHeight).toBe(range)
expect(f.content.style.cssText).toBe('')
})
it('accounts for a layout scrollbar without retaining its sizing property after detachment', () => {
const h = harness(), f = h.fixture()
Object.defineProperties(f.body, { offsetWidth: { value: 960 }, clientWidth: { value: 955 } })
f.attach()
expect(f.body.style.getPropertyValue('--plugin-page-scrollbar-width')).toBe('5px')
f.ref(null)
expect(f.body.style.getPropertyValue('--plugin-page-scrollbar-width')).toBe('')
})
it('interpolates narrow wrapped controls to their measured horizontal and width endpoints', () => {
const h = harness(), f = h.fixture(narrow())
f.attach()
expect(f.part('toolbar').style.getPropertyValue('--plugin-part-y')).toBe('90px')
expect(f.part('toolbar').style.getPropertyValue('--plugin-part-width')).toBe('280px')
f.scroll(37)
expect(f.part('toolbar').style.translate).toBe('100px -37px')
expect(f.part('toolbar').style.width).toBe('180px')
f.scroll(144)
expect(f.part('toolbar').style.translate).toBe('200px -74px')
expect(f.part('toolbar').style.width).toBe('80px')
expect(f.part('tabs').style.translate).toBe('0px -144px')
expect(f.part('tabs').style.width).toBe('160px')
expect(f.bar.style.getPropertyValue('--plugin-header-paint-height')).toBe('70px')
f.scroll(0)
expect(f.part('toolbar').style.translate).toBe('0px 0px')
expect(f.part('toolbar').style.width).toBe('280px')
expect(f.part('tabs').style.width).toBe('280px')
})
it('clips the no-tabs intro beneath the pinned title and restores interaction when scrolling back', () => {
const h = harness(), f = h.fixture(wide(false))
f.attach()
f.scroll(16)
expect(f.part('title').style.translate).toBe('0px -12px')
expect(f.part('intro').style.clipPath).toBe('inset(0% 0 0)')
expect(f.part('intro').inert).toBe(false)
f.scroll(26)
expect(f.part('intro').style.translate).toBe('0px -26px')
expect(f.part('intro').style.clipPath).toBe('inset(50% 0 0)')
expect(f.part('intro').inert).toBe(false)
f.scroll(36)
expect(f.part('intro').style.clipPath).toBe('inset(100% 0 0)')
expect(f.part('intro').inert).toBe(true)
expect(f.bar.hasAttribute('data-compact')).toBe(true)
f.scroll(0)
expect(f.part('intro').style.clipPath).toBe('inset(0% 0 0)')
expect(f.part('intro').inert).toBe(false)
})
it('scrolls the tabs intro fully out without the no-tabs clipping treatment', () => {
const h = harness(), f = h.fixture()
f.part('intro').style.clipPath = 'inset(100% 0 0)'
f.attach()
f.scroll(79)
expect(f.part('intro').style.clipPath).toBe('')
expect(f.part('intro').inert).toBe(false)
f.scroll(80)
expect(f.part('intro').style.translate).toBe('0px -80px')
expect(f.part('intro').inert).toBe(true)
f.scroll(40)
expect(f.part('intro').inert).toBe(false)
expect(f.part('intro').style.translate).toBe('0px -40px')
})
it.each(['resize', 'mutation', 'font'] as const)('remeasures changed endpoints on %s without replacing focused controls', (event) => {
const h = harness(), f = h.fixture()
const button = f.part('toolbar').querySelector('button')!
const clicked = vi.fn()
button.addEventListener('click', clicked)
f.attach()
f.scroll(144)
button.focus()
f.state.layout = narrow()
if (event === 'resize') h.resizes[0]!.deliver()
else if (event === 'mutation') h.mutations[0]!.deliver()
else h.fonts.dispatchEvent(new Event('loadingdone'))
expect(f.part('toolbar').style.translate).toBe('200px -74px')
expect(f.part('toolbar').style.width).toBe('80px')
expect(f.bar.style.getPropertyValue('--plugin-header-rest-height')).toBe('210px')
expect(f.body.style.getPropertyValue('--plugin-header-scroll-padding')).toBe('78px')
expect(document.activeElement).toBe(button)
expect(f.part('toolbar').querySelector('button')).toBe(button)
button.click()
expect(clicked).toHaveBeenCalledTimes(1)
expect(f.clones).toHaveLength(2)
expect(f.clones.every(clone => !clone.isConnected)).toBe(true)
expect(f.body.querySelector('[data-measuring]')).toBeNull()
expect(f.measurements.slice(-2)).toEqual([false, true].map(compact => ({
compact, inert: true, ariaHidden: 'true', ids: 0, styles: 0,
natural: false, scrolled: false, connected: true, width: '320px',
})))
const ids = [...f.body.querySelectorAll('[id]')].map(element => element.id)
expect(new Set(ids).size).toBe(ids.length)
expect(h.resizes[0]!.observe).toHaveBeenCalledWith(f.body)
expect(h.mutations[0]!.observe).toHaveBeenCalledWith(f.header, {
childList: true, characterData: true, subtree: true, attributes: true, attributeFilter: ['hidden'],
})
})
it('removes the inert measurement copy when a compact layout read fails', () => {
const h = harness(), f = h.fixture()
const failure = new Error('Layout read interrupted')
const resting = f.state.layout.resting
Object.defineProperty(f.state, 'layout', {
get: () => ({
width: 960,
resting,
get compact(): Layout['compact'] { throw failure },
}),
})
expect(() => { f.attach() }).toThrow(failure)
expect(f.clones).toHaveLength(1)
expect(f.clones[0]!.isConnected).toBe(false)
expect(f.body.querySelector('[data-measuring]')).toBeNull()
expect(h.resizes).toHaveLength(0)
expect(h.mutations).toHaveLength(0)
})
it('disconnects observers and listeners and ignores callbacks already queued before detachment', () => {
const h = harness(), f = h.fixture()
f.attach()
f.scroll(20)
const before = f.bar.outerHTML
f.ref(null)
expect(h.resizes[0]!.disconnect).toHaveBeenCalledTimes(1)
expect(h.mutations[0]!.disconnect).toHaveBeenCalledTimes(1)
expect(f.body.style.getPropertyValue('--plugin-header-scroll-padding')).toBe('')
f.state.layout = narrow()
f.scroll(144)
h.resizes[0]!.deliver()
h.mutations[0]!.deliver()
h.fonts.dispatchEvent(new Event('loadingdone'))
expect(f.bar.outerHTML).toBe(before)
expect(f.clones).toHaveLength(1)
f.ref(null)
expect(h.resizes[0]!.disconnect).toHaveBeenCalledTimes(1)
})
it('rebinds one ref without allowing the old element or its callbacks to change the replacement', () => {
const h = harness(), ref = createPluginHeaderRef(), first = h.fixture(wide(), ref), second = h.fixture(narrow(), ref)
first.attach()
first.scroll(20)
const oldMarkup = first.bar.outerHTML
second.attach()
second.scroll(37)
const newMarkup = second.bar.outerHTML
first.scroll(144)
h.resizes[0]!.deliver()
h.mutations[0]!.deliver()
expect(first.bar.outerHTML).toBe(oldMarkup)
expect(second.bar.outerHTML).toBe(newMarkup)
expect(first.body.style.getPropertyValue('--plugin-header-scroll-padding')).toBe('')
expect(second.body.style.getPropertyValue('--plugin-header-scroll-padding')).toBe('78px')
expect(first.clones).toHaveLength(1)
expect(second.clones).toHaveLength(1)
})
it('keeps two mounted refs independent when scrolling, remeasuring, and disposing either instance', () => {
const h = harness(), first = h.fixture(), second = h.fixture(narrow())
first.attach()
second.attach()
first.scroll(20)
expect(second.part('toolbar').style.translate).toBe('0px 0px')
second.scroll(37)
expect(first.part('toolbar').style.translate).toBe('0px -12px')
expect(second.part('toolbar').style.translate).toBe('100px -37px')
first.ref(null)
const oldMarkup = first.bar.outerHTML
second.state.layout = wide()
h.fonts.dispatchEvent(new Event('loadingdone'))
expect(first.bar.outerHTML).toBe(oldMarkup)
expect(first.clones).toHaveLength(1)
expect(second.clones).toHaveLength(2)
expect(second.part('toolbar').style.translate).toBe('0px -12px')
expect(second.body.style.getPropertyValue('--plugin-header-scroll-padding')).toBe('72px')
expect(h.resizes[1]!.disconnect).not.toHaveBeenCalled()
second.scroll(0)
expect(second.part('toolbar').style.translate).toBe('0px 0px')
})
})
@@ -18,8 +18,8 @@
en: d2f5701274be5110
zh: d60e2617136a6f26
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
en: 8e9f354ef0f81d22
zh: 8ee59b6bb7a7198e
en: 75e1681dcfc61a14
zh: 3eda3143730f83e1
/deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons:
en: dbd7f0e74c45a762
zh: 7efbfbf5500cc835
+1 -1
View File
@@ -50,7 +50,7 @@ Check this table before writing a control in a feature package. A plugin cannot
| `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. |
| `MenuGroup`, `observeStickyMenuGroups` | Localized, accessible groups for custom menus and listboxes, with shared sticky-heading styling and native intersection/size observation; the caller owns the observer's lifetime. |
| `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. |
| `SegmentedTabs` | Controlled equal-width tabs with a sliding indicator and Left/Right, Home, and End navigation. The caller supplies labels, tab/panel ids, and panel content. |
| `SegmentedTabs` | Controlled equal-width tabs with a sliding indicator and Left/Right, Home, and End navigation. The caller supplies labels, tab/panel ids, and panel content. Default `size="md"` is 42px tall with a 4px inset; `size="sm"` is 34px tall with a 2px inset. |
| `Tag` | Read-only capsule badge; `tone` selects one of eight palettes. |
| `PathLabel` | Single-line file path with subdued directories, a primary filename, and the full path on hover. Fitting paths align left; clipped paths preserve their suffix with a left-edge fade that updates on path and size changes. |
| `StateDot` | Solid green `done`, amber `warning`, red `error`, and neutral-grey `idle` marks in a 10px slot, plus a tertiary-grey 14px rotating `ongoing` loader whose animations pin to document time zero on mount and on every animation restart, such as a list reorder, so every visible loader rotates in phase. `aria-hidden`, so the render site owns the name. `appearance="step"` shows a filled check for completion and a hollow pending circle. |
+1 -1
View File
@@ -50,7 +50,7 @@ kind: "package-library"
| `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 |
| `MenuGroup`、`observeStickyMenuGroups` | 为自定义菜单与列表框提供本地化、可访问的分组,共用吸顶标题样式与原生交叉/尺寸观察;调用方负责观察器的生命周期。 |
| `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 |
| `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。 |
| `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。默认 `size="md"` 总高 42px、内边距 4px;`size="sm"` 总高 34px、内边距 2px。 |
| `Tag` | 只读胶囊徽章;`tone` 选择八种配色之一。 |
| `PathLabel` | 单行文件路径:目录使用弱化颜色,文件名使用主色,悬停可查看完整路径。空间足够时靠左显示;溢出时保留尾部并在左侧渐隐,路径或尺寸变化时更新。 |
| `StateDot` | 10px 槽内的绿色 `done`、琥珀色 `warning`、红色 `error`、中性灰色 `idle` 圆点,以及 tertiary 灰色 14px 旋转 `ongoing` loading,其动画在挂载时以及每次动画重启(如列表重排)时固定到文档时间零点,所以所有可见 loading 同相旋转。它是 `aria-hidden` 的,名称由渲染点提供。 `appearance="step"` 以实心勾表示完成、空心圆表示等待。 |
@@ -1,14 +1,16 @@
.tabs {
--segmented-tabs-inset: 4px;
position: relative;
display: grid;
padding: 4px;
padding: var(--segmented-tabs-inset);
border-radius: var(--dsw-radius-lg);
background: var(--dsw-alias-bg-module-platform);
}
.indicator {
position: absolute;
inset: 4px auto 4px 4px;
inset: var(--segmented-tabs-inset) auto var(--segmented-tabs-inset) var(--segmented-tabs-inset);
box-sizing: border-box;
border: 0.5px solid var(--dsw-alias-border-l3);
border-radius: var(--dsw-radius-md);
@@ -46,6 +48,20 @@
font-weight: 600;
}
.tabs[data-size='sm'] {
--segmented-tabs-inset: 2px;
border-radius: calc(var(--dsw-radius-md) + var(--segmented-tabs-inset));
}
.tabs[data-size='sm'] .tab {
height: 30px;
}
.tabs[data-size='sm'] .tab[aria-selected='true'] {
font-weight: 500;
}
.tab:focus-visible {
outline: none;
text-decoration: underline;
@@ -19,14 +19,16 @@ export interface SegmentedTab<Value extends string = string> {
* Keyboard selection also moves focus; only the selected tab is a tab stop.
* @param props.label - localized accessible name for the tab list.
* @param props.className - layout placement; panels remain caller-owned.
* @param props.size - `sm` draws 30px tabs with a 2px inset (34px tall); the default `md` draws 34px tabs with a 4px inset (42px tall).
* @returns the tab list, without its panels.
*/
export function SegmentedTabs<Value extends string>({ items, value, onChange, label, className }: {
export function SegmentedTabs<Value extends string>({ items, value, onChange, label, className, size = 'md' }: {
items: readonly [SegmentedTab<Value>, ...SegmentedTab<Value>[]]
value: Value
onChange: (value: Value) => void
label: string
className?: string | undefined
size?: 'sm' | 'md' | undefined
}): ReactNode {
const selectedIndex = items.findIndex(item => item.value === value)
@@ -52,6 +54,7 @@ export function SegmentedTabs<Value extends string>({ items, value, onChange, la
return (
<div
role="tablist"
data-size={size}
aria-label={label}
className={clsx(css.tabs, className)}
style={{ gridTemplateColumns: `repeat(${items.length}, minmax(0, 1fr))` }}
@@ -59,7 +62,7 @@ export function SegmentedTabs<Value extends string>({ items, value, onChange, la
<span
className={css.indicator}
aria-hidden="true"
style={{ width: `calc((100% - 8px) / ${items.length})`, transform: `translateX(${selectedIndex * 100}%)` }}
style={{ width: `calc((100% - 2 * var(--segmented-tabs-inset)) / ${items.length})`, transform: `translateX(${selectedIndex * 100}%)` }}
/>
{items.map((item, index) => (
<Pill
@@ -31,11 +31,11 @@ describe('SegmentedTabs', () => {
expect(screen.getByRole('tablist', { name: 'Help' })).toBeTruthy()
})
it('moves focus and selection with arrows, Home, and End, and leaves Tab alone', () => {
it.each(['sm', 'md'] as const)('%s moves focus and selection with arrows, Home, and End, and leaves Tab alone', (size) => {
const onKeyDown = vi.fn()
function Harness() {
const [value, setValue] = useState<string>('details')
return <div onKeyDown={onKeyDown}><SegmentedTabs items={items} value={value} onChange={setValue} label="Help" /></div>
return <div onKeyDown={onKeyDown}><SegmentedTabs size={size} items={items} value={value} onChange={setValue} label="Help" /></div>
}
render(<Harness />)
const details = screen.getByRole('tab', { name: 'Details' })
@@ -67,6 +67,18 @@
transition: transform var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
/* An item mounted by the opening has no closed style to slide from; it starts
at the closed position like the items already mounted. Only an opening
marks the panel, so items that mount while it stays open, or that a
Session switch shows, appear in place. */
@starting-style {
.panel[data-sidebar-right-opening] :global([data-dockkit-host='dock']),
.panel[data-sidebar-right-opening] :global([data-dockkit-empty]),
.panel[data-sidebar-right-opening] :global([data-dockkit-divider]) {
transform: translateX(var(--dsh-sidebar-width));
}
}
.panel[data-sidebar-right-panel='fullscreen'] {
--dsh-dockkit-dock-layer: 40;
}
@@ -110,7 +122,10 @@
@media (prefers-reduced-motion: reduce) {
.panel :global([data-dockkit-host='dock']),
.panel :global([data-dockkit-empty]),
.panel :global([data-dockkit-divider]) {
.panel :global([data-dockkit-divider]),
.panel[data-sidebar-right-open] :global([data-dockkit-host='dock']),
.panel[data-sidebar-right-open] :global([data-dockkit-empty]),
.panel[data-sidebar-right-open] :global([data-dockkit-divider]) {
transition: none;
}
}
@@ -8,9 +8,11 @@
*
* Docked content stays mounted while collapsed, translated off the frame's right
* edge, so opening and closing are one gesture in both presentations: a slide
* from and to that edge. Normal presentation moves the frame's tracks with
* the panel. A fullscreen opening reserves its underlying track only after
* the panel covers the frame, without animating those hidden columns.
* from and to that edge. An item mounted by the opening, such as the first tab
* of an empty column, starts at that edge, so it joins the slide. Normal
* presentation moves the frame's tracks with the panel. A fullscreen opening
* reserves its underlying track only after the panel covers the frame, without
* animating those hidden columns.
*
* The panel has no header of its own: its two controls — presentation switch
* and collapse — ride the docking kit's chrome seat at the end of the top-right
@@ -27,7 +29,7 @@
* signal, actions — is read through the slot-owned useTabInfo hook. The Tab
* domain follows each session's store commits, including sessions off screen.
*/
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { CSSProperties, ReactNode, RefObject } from 'react'
import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client'
import { IconPanelLeftOutlineRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -303,6 +305,17 @@ function SidebarPanel(panel: PanelProps & { width: number; panelRef: RefObject<H
const { sessionId, surface, actions, t, renderSlot, openTab, width, reportRoom, fullscreen, panelRef } = panel
const { expanded } = surface.layout
const types = panel.useTabTypes(value => value)
// True from the render that opens the on-screen panel until a transition in
// it ends, the panel closes, or it leaves the screen, which cancels the
// slide; the stylesheet starts items mounted meanwhile at the closed position.
const [opened, setOpened] = useState(expanded)
const [opening, setOpening] = useState(false)
if (opened !== expanded) {
setOpened(expanded)
setOpening(expanded && panel.active)
} else if (opening && !panel.active) {
setOpening(false)
}
return (
<div
ref={panelRef}
@@ -312,6 +325,8 @@ function SidebarPanel(panel: PanelProps & { width: number; panelRef: RefObject<H
data-sidebar-right-session={sessionId}
data-sidebar-right-panel={fullscreen ? 'fullscreen' : 'push'}
data-sidebar-right-open={expanded || undefined}
data-sidebar-right-opening={opening || undefined}
onTransitionEnd={opening ? () => { setOpening(false) } : undefined}
// Dockkit hides individual docked hosts; floating descendants remain accessible.
aria-hidden={(!expanded && surface.layout.floats.length === 0) || undefined}
>
@@ -557,6 +557,33 @@ describe('RightbarSeat presentation', () => {
expect(h.frame.closeRightbar).toHaveBeenCalled()
})
it('marks the panel as opening from the opening render until its slide ends', async () => {
const h = await mountSeat()
const panel = element(h.view.container, '[data-sidebar-right-panel]')
expect(panel.hasAttribute('data-sidebar-right-opening')).toBe(false)
h.open()
// The tab the opening seeds mounts under the mark, so it starts at the closed position.
const host = element(h.view.container, "[data-dockkit-host='dock']")
expect(panel.hasAttribute('data-sidebar-right-opening')).toBe(true)
fireEvent.transitionEnd(host)
expect(panel.hasAttribute('data-sidebar-right-opening')).toBe(false)
act(() => { h.actions.splitPane(SESSION) })
expect(panel.hasAttribute('data-sidebar-right-opening')).toBe(false)
fireEvent.click(element(h.view.container, '[data-sidebar-right-toggle]'))
expect(panel.hasAttribute('data-sidebar-right-opening')).toBe(false)
act(() => { h.actions.setExpanded(SESSION, true) })
expect(panel.hasAttribute('data-sidebar-right-opening')).toBe(true)
// Closing before the slide ends clears the mark.
fireEvent.click(element(h.view.container, '[data-sidebar-right-toggle]'))
expect(panel.hasAttribute('data-sidebar-right-opening')).toBe(false)
// Leaving the screen cancels the slide, so returning shows the items in place.
act(() => { h.actions.setExpanded(SESSION, true) })
expect(panel.hasAttribute('data-sidebar-right-opening')).toBe(true)
act(() => { h.runtime.panelInfo.set({ activePanelId: 'other-panel' as MainPanelId }) })
act(() => { h.runtime.panelInfo.set({ activePanelId: null }) })
expect(panel.hasAttribute('data-sidebar-right-opening')).toBe(false)
})
it('fills the viewport without replacing the content tree or releasing the wide track', async () => {
const h = await mountSeat()
const tab = h.open()
@@ -0,0 +1,20 @@
/** Right sidebar slide style contracts. */
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const css = readFileSync(fileURLToPath(new URL('../src/client/shell/SidebarRight.module.css', import.meta.url)), 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, ' ')
const host = String.raw`:global\(\[data-dockkit-host='dock'\]\)`
describe('SidebarRight.module.css', () => {
it('starts docked items mounted by an opening at the closed position', () => {
expect(css).toMatch(new RegExp(String.raw`@starting-style\s*\{\s*\.panel\[data-sidebar-right-opening\] ${host},[^{}]*\{`
+ String.raw`\s*transform: translateX\(var\(--dsh-sidebar-width\)\);\s*}\s*}`))
})
it('removes the open slide as well as the close slide for reduced motion', () => {
expect(css).toMatch(new RegExp(String.raw`@media \(prefers-reduced-motion: reduce\)\s*\{[^{}]*`
+ String.raw`\.panel\[data-sidebar-right-open\] ${host}[^{}]*\{\s*transition: none;\s*}`))
})
})
@@ -2,7 +2,7 @@ import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit'
import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { describe, expect, it } from 'vitest'
import { unsupportedModel, provideWorkingDirectoryFixture, unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
import { createInboxStub, unsupportedModel, provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit'
import { Context } from '@deepseek-ai/cordis'
import { ToolCallId } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
@@ -25,7 +25,7 @@ async function fakeAgent(ctx: Context, rawId: string): Promise<Agent> {
const session = Session.create(id)
const agent: Agent = {
id, options: {}, session, model: unsupportedModel(),
inbox: unsupportedInbox(),
inbox: createInboxStub(),
status: 'idle',
ctx: scope.ctx,
send: () => {},
@@ -1887,7 +1887,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: '@Remote installBundle(spec: string, options?: InstallBundleOptions): Promise<ChangeResult>',
description: 'Install a package using the same pnpm implementation as dsh plugin. GitHub repositories get a connection check bounded by githubConnectionTimeoutMs before pnpm starts; only network failures or timeouts stop installation, while pnpm owns authentication and transport fallback. A run that fails, is cancelled, or adds a package without a bundle patch restores `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.',
description: 'Install a package using the same pnpm implementation as dsh plugin. GitHub repositories get a connection check bounded by githubConnectionTimeoutMs before pnpm starts; only network failures or timeouts stop installation, while pnpm owns authentication and transport fallback. An HTTP(S) Git URL on another host is installed over SSH instead when access probes bounded by gitTransportTimeoutMs find that only SSH can read it. A run that fails, is cancelled, or adds a package without a bundle patch restores `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.',
parameters: [{ name: 'spec', description: 'One package spec, including local paths relative to the invocation directory.' }, { name: 'options', description: 'Whether to activate the installed bundle (defaults to true), the request id a cancellation names, the pending build scripts to allow, whether to save an exact dependency, and the registry asked first.' }],
returns: 'Package-manager diagnostics, the registries asked, and the observed activation outcome.',
},
+16 -16
View File
@@ -9,8 +9,8 @@
en: 256894de7b318e7e
zh: a6f3cb0060fe2c4d
/deepseek-ai-dsh-tool-jobs/summary:
en: bc06571d7b64ab38
zh: 9521cdee055d3b84
en: ad22af3fba41f06a
zh: a098b203c6a70b76
/deepseek-ai-dsh-tool-jobs/table-of-contents:
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
@@ -21,11 +21,11 @@
en: 6f20f54e60bcf637
zh: 6279c685d9d0bb16
/deepseek-ai-dsh-tool-jobs/use-this-package/completion-notices:
en: cc910d28085a106a
zh: 367612e77453dc73
en: 99577924f3d65112
zh: 521dac5540f9c1b7
/deepseek-ai-dsh-tool-jobs/use-this-package/minimal-configuration:
en: fc08838f1843b8c7
zh: e5d3c11216967aca
en: da2fb742af042b52
zh: 4afa3918b44b00b9
/deepseek-ai-dsh-tool-jobs/use-this-package/what-can-go-wrong:
en: 177f79c7c446a5e1
zh: 3e9c1852f8046763
@@ -36,14 +36,14 @@
en: e1d89b73fdcc5b24
zh: 38dd176ae111561f
/deepseek-ai-dsh-tool-jobs/understand-the-implementation/source-map:
en: 6f006ff2d901bc92
zh: 779d18ba7bec4202
en: e6278ea246410c01
zh: d202575c3e53852e
/deepseek-ai-dsh-tool-jobs/understand-the-implementation/output-capping:
en: ca18e4bdfb12a1e2
zh: 6592697c561c6e6d
/deepseek-ai-dsh-tool-jobs/understand-the-implementation/notice-delivery-lanes:
en: 2c9f9999a285fe12
zh: 724a6ee419c155ce
en: fa233a922bf44051
zh: 1fa567b33d2b0513
/deepseek-ai-dsh-tool-jobs/further-exploration:
en: 11250fc631d55d99
zh: 7885ce173bd1cd5f
@@ -81,17 +81,17 @@
en: 8160e01d6bfc5e0c
zh: 570dc95be7f13260
/deepseek-ai-dsh-tool-jobs/model-experience/results-and-notices/what-the-model-sees:
en: 174e992436bbb13d
zh: 3a2b3541042f9eec
en: fff929f198d39939
zh: d3a738483c542819
/deepseek-ai-dsh-tool-jobs/model-experience/results-and-notices/token-effect:
en: 16efd9cb948ca4bf
zh: 563cc4c78dc63b4d
en: 40aeb5a93409f4a4
zh: 6f894bb247ffff14
/deepseek-ai-dsh-tool-jobs/model-experience/results-and-notices/kv-cache-effect:
en: 2fbf2175168e8244
zh: f681f89596f500f5
/deepseek-ai-dsh-tool-jobs/known-limitations-and-deferred-work:
en: 1971e3b84c568c3c
zh: 3e357265178a8b00
en: eec6635d3d95b2ec
zh: f61f11ea8d405509
/deepseek-ai-dsh-tool-jobs/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 3bb0c20c93f16f98
+9 -5
View File
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
Use `dsh-tool-jobs` to inspect and control background commands and PTY work through `job_output`, `job_list`, and `job_kill`. Reads can wait within a configured timeout, and a wait yields early to a user steer; list results identify each job's kind and status, and cancellation settles after the work stops. When owned work finishes, the agent receives an in-session notice: busy agents receive it in their next step, while idle agents are woken by a follow-up turn. Configuration controls wait limits, completion delivery, and an optional consecutive-wakeup cap. Stream output is consumed by one reader, and pending notices do not survive owner disposal.
Use `dsh-tool-jobs` to inspect and control background commands and PTY work through `job_output`, `job_list`, and `job_kill`. Reads can wait within a configured timeout, and a wait yields early to a user steer; list results identify each job's kind and status, and cancellation settles after the work stops. When owned work finishes, the agent receives an in-session notice carrying short output: busy agents get it next step, and idle agents are woken by a follow-up turn. Configuration controls wait limits, completion delivery, and an optional consecutive-wakeup cap. Stream output has one reader, and pending notices do not survive owner disposal.
## Table of Contents
@@ -37,7 +37,7 @@ The three tools return `{ text, job }`, `PublicJobSnapshot[]`, and `{ outcome: '
### Completion notices
When a job finishes, the owning agent receives `background job <id> (<kind>: <label>) finished [status: ...]. Read its output with job_output.` as an in-session message. A busy agent has the notice injected into its next step — the turn cannot close while the inbox holds it, so several jobs settling together cost one step rather than one turn each. An idle agent is instead woken with a follow-up turn, because an unclaimed notice is a completion the model never learns about. Completions the model already collected get no notice: the registry reports a settlement that released a live `wait` as `awaited` — whether a `job_output` wait or a shell tool waiting on its own foreground command — and the plugin remembers the kills the model requested through `job_kill`; a settlement caused by owner or service teardown is skipped because nobody is left to read it. A settlement that lands after a user steer ended the wait but before that call's read carries the terminal state is delivered by that read, so the plugin withholds the notice instead of reporting the completion twice.
When a job finishes, the owning agent receives `background job <id> (<kind>: <label>) finished [status: ...]. Output:` followed by the output it has not read and the job's final result, or `. No new output.` when there is none. A notice is inlined only when the complete text fits `inlineOutputBytes` (2048 bytes by default) and the producer's `outputLimitBytes`; otherwise it ends `. Read its output with job_output.` instead, so large output and large final results reach the model as a `job_output` result, which the tool pipeline's spill policy bounds. The label is its first line, bounded to 120 characters. A `job_output` call that collects the job while its notice is still pending withdraws the notice, and a notice that never reaches the model leaves its output for `job_output`. A busy agent has the notice injected into its next step — the turn cannot close while the inbox holds it, so several jobs settling together cost one step rather than one turn each. An idle agent is instead woken with a follow-up turn, because an unclaimed notice is a completion the model never learns about. Completions the model already collected get no notice: the registry reports a settlement that released a live `wait` as `awaited` — whether a `job_output` wait or a shell tool waiting on its own foreground command — and the plugin remembers the kills the model requested through `job_kill`; a settlement caused by owner or service teardown is skipped because nobody is left to read it. A settlement that lands after a user steer ended the wait but before that call's read carries the terminal state is delivered by that read, so the plugin withholds the notice instead of reporting the completion twice.
Waking is unbounded by default: an unattended agent that chains background commands and one-shot subagents is woken for every completion. `maxConsecutiveWakes` caps that: each owner may be woken that many times before further notices degrade to injection, and claiming any user-authored message restores the budget. The cap bounds the self-exciting chain — a woken turn may start the background job whose completion wakes it again — but a notice past it waits silently until the next user input, so a session that relies on wakes to finish its work stalls there. `completionDelivery: quiet` keeps even idle owners on the injection lane, which deterministic transcripts need.
@@ -55,6 +55,7 @@ Loading the plugin with no config is the common path; a `waitTimeoutMs` above `m
| `maxWaitTimeoutMs` | `600,000` | Cap for model-supplied waits; larger values clamp down to it |
| `completionDelivery` | `wakeup` | `wakeup` opens a turn on an idle owner; `quiet` leaves the notice pending |
| `maxConsecutiveWakes` | unset | Turns one owner may open by wake before notices degrade to injection; unset means no cap |
| `inlineOutputBytes` | `2048` | Largest complete notice that carries the job's output and final result inline; `0` never inlines |
The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-tool-jobs) is the exhaustive source for every accepted field and its JSDoc.
@@ -82,7 +83,7 @@ This section explains the design decisions behind the tools and points at the co
| File | Role |
|---|---|
| [`src/index.ts`](src/index.ts) | Plugin entry: tool registrations, the settlement subscription and model-kill set, prompt section, output capping |
| [`src/index.ts`](src/index.ts) | Plugin entry: tool registrations, the settlement subscription, model-kill set, and held settlement reads, prompt section, output capping |
| [`src/render.ts`](src/render.ts) | Model-facing rendering: the public projection, status lines, and the consuming delta (stdout, `[stderr]` section, dropped-output notice) |
### Output capping
@@ -93,6 +94,8 @@ This section explains the design decisions behind the tools and points at the co
The settlement subscription (`{ owners: 'scope' }`) skips settlements the registry reports as `awaited`, jobs the model killed through `job_kill`, unowned jobs, and teardown settlements, then resolves the agent registered for the owner session. A `wakeup` delivery opens a turn on an idle owner; with `maxConsecutiveWakes` set, only while the budget lasts, tracked per exact `Agent` in a `WeakMap`, and claiming a user-authored message (`agent/inbox/claimed`) resets that owner's budget. A busy owner — or any notice past a configured budget, or `quiet` delivery — is injected into the next-step inbox instead. The registry counts only a wait still owed the projection at settlement, so a wait that timed out or was aborted leaves a later settlement to notify as usual; a removal drops the job from the model-kill set, which only ever holds live jobs the model killed. A wait this plugin ended on next-step input holds a claim from that interruption until its own read commits: a settlement landing inside that window reaches the model through the read's terminal state and result, so the notice is withheld, while a settlement after a read that reported a running job notifies as usual. The tool releases the wait by aborting its own controller — the registry keeps its abort-as-cancellation contract — so a cancelled call discards that read and the withheld notice is delivered when the claim releases.
A job whose whole output stream (`JobView.output.total`) exceeds `inlineOutputBytes` cannot fit, so its read stays in the registry. Otherwise the notice takes the consuming read at settlement. An inline notice holds that read until it is logged as a `user/message` (`session/event`); a notice claimed by a step that is then rejected or cancelled is never logged, so `job_output` returns its held read. A notice the read did not fit holds it for `job_output`. `job_output` also removes a pending notice from the inbox of the agent registered for the owner session. A job removal drops the entry. A plugin reload drops every held read, so the model then reads `(no new output)` for that job.
</details>
-----
@@ -153,11 +156,11 @@ Prefix-stable while tool definitions and visibility are unchanged. Registration
#### What the model sees
Reads return output or `(no new output)` followed by `[status: <status>]` and optional detail. An empty list returns `(no background jobs)`. Kill returns `requested cancellation of job <id>` or the existing terminal status. An owned completion the model has not already collected uses the notice above. A `wait: true` read returns `[status: running]` when the wait yielded to a user steer or timed out — a yield names its cause beside the progress line, as `[status: running, user steered]` — and the terminal status when it collected the settlement.
Reads return output or `(no new output)` followed by `[status: <status>]` and optional detail. An empty list returns `(no background jobs)`. Kill returns `requested cancellation of job <id>` or the existing terminal status. An owned completion the model has not already collected uses the notice above, with the job's unread output when it fits. A `wait: true` read returns `[status: running]` when the wait yielded to a user steer or timed out — a yield names its cause beside the progress line, as `[status: running, user steered]` — and the terminal status when it collected the settlement.
#### Token effect
Results and notices remain in parent history until compaction. Stream reads do not repeat consumed output; a producer-supplied `outputLimitBytes` bounds each complete read or notice. Under `wakeup`, a notice reaching an idle owner also buys a model request the user did not ask for, capped per owner only when `maxConsecutiveWakes` is set; a notice reaching a busy owner adds a step to the turn it is already paying for.
Results and notices remain in parent history until compaction. Stream reads do not repeat consumed output, and an inline notice saves the `job_output` step that would otherwise follow it; a producer-supplied `outputLimitBytes` bounds each complete read or notice. Under `wakeup`, a notice reaching an idle owner also buys a model request the user did not ask for, capped per owner only when `maxConsecutiveWakes` is set; a notice reaching a busy owner adds a step to the turn it is already paying for.
#### KV Cache effect
@@ -171,6 +174,7 @@ Append-only; newly visible content follows the reusable request prefix and does
These limits define when the tools are a poor fit. They are current package constraints, not a task backlog.
- **A settlement inside the driver's retirement window still strands its notice** — between the turn loop's last inbox check and the driver committing its idle phase the owner still reads as busy, so the notice is injected and nothing wakes. Steering has the same hole; closing it belongs to `agent-loop`.
- **A plugin reload drops output read at settlement** — a notice that took the job's read holds it in plugin memory until it is logged or collected by `job_output`, and the registry has already handed that read out, so a reload in between leaves `job_output` with `(no new output)`.
- **A spent wake budget is not restored by time** — with `maxConsecutiveWakes` set, only user-authored input refills it, so an unattended agent whose budget ran out collects its remaining notices on the next turn something else opens, and nothing in the client shows that a notice is waiting.
- **A notice pending on an idle owner does not survive that owner's disposal** — the disposal cancel clears the unclaimed inbox, and the log keeps the insert/cancel pair as the record.
- **Model reads are single-consumer** — independent observers use the registry's non-consuming `readAt` (the Web client's `job.follow`), not these tools.
+9 -5
View File
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
使用 `dsh-tool-jobs`,可通过 `job_output`、`job_list` 与 `job_kill` 检查和控制后台命令、PTY 工作与 subagent。读取可在配置的超时内等待,而等待会因用户 steer 提前结束;列表结果标识各任务的 kind 与状态,而取消只有在工作停止后才结算。归属明确的工作完成时,agent(智能体)会收到会话内通知:繁忙的 agent 在下一步收到通知,空闲的 agent 则由 follow-up 轮次唤醒。配置控制等待上限、完成投递与可选的连续唤醒上限。流输出仅供单一读取方消费,待领通知无法在所有者释放后存活。
使用 `dsh-tool-jobs`,可通过 `job_output`、`job_list` 与 `job_kill` 检查和控制后台命令、PTY 工作与 subagent。读取可在配置的超时内等待,而等待会因用户 steer 提前结束;列表结果标识各任务的 kind 与状态,而取消只有在工作停止后才结算。归属明确的工作完成时,agent(智能体)会收到携带简短输出的会话内通知:繁忙的 agent 在下一步收到,空闲的 agent 则由 follow-up 轮次唤醒。配置控制等待上限、完成投递与可选的连续唤醒上限。流输出只有一个读取方,待领通知无法在所有者释放后存活。
## 目录
@@ -37,7 +37,7 @@ kind: "package-reference"
### 完成通知
任务完成时,拥有它的 agent 会收到会话内消息 `background job <id> (<kind>: <label>) finished [status: ...]. Read its output with job_output.`。繁忙的 agent 会在下一步收到注入的通知——inbox 尚有内容时轮次无法结束,因此同时结算的多个任务只花掉一步,而不是各占一轮。空闲的 agent 则被一个 follow-up 轮次唤醒,因为无人领取的通知就是模型永远不会知道的完成。模型已经收走的完成不再通知:注册表把释放了存活 `wait` 的结算报告为 `awaited`——无论是 `job_output` 的等待,还是 shell 工具在等待自己的前台命令——插件则记着模型经 `job_kill` 请求的杀停;所有者或服务 teardown 导致的结算会被跳过,因为没有人能读它。若结算发生在用户 steer 结束等待之后、该次调用的读取之前,终态由那次读取携带,插件因此不再发出通知,避免把同一个完成报告两次。
任务完成时,拥有它的 agent 会收到 `background job <id> (<kind>: <label>) finished [status: ...]. Output:`,其后是它尚未读取的输出和任务的最终结果;没有输出时则以 `. No new output.` 结尾。只有完整文本同时不超过 `inlineOutputBytes`(默认 2048 字节)和生产方的 `outputLimitBytes` 时,通知才会内联;否则改以 `. Read its output with job_output.` 结尾,因此较大的输出和较大的最终结果会以 `job_output` 结果的形式抵达模型,由工具管线的 spill 策略限制其大小。label 取其首行,上限 120 个字符。在通知仍待领时收走该任务的 `job_output` 调用会撤回该通知;从未抵达模型的通知会把输出留给 `job_output`。繁忙的 agent 会在下一步收到注入的通知——inbox 尚有内容时轮次无法结束,因此同时结算的多个任务只花掉一步,而不是各占一轮。空闲的 agent 则被一个 follow-up 轮次唤醒,因为无人领取的通知就是模型永远不会知道的完成。模型已经收走的完成不再通知:注册表把释放了存活 `wait` 的结算报告为 `awaited`——无论是 `job_output` 的等待,还是 shell 工具在等待自己的前台命令——插件则记着模型经 `job_kill` 请求的杀停;所有者或服务 teardown 导致的结算会被跳过,因为没有人能读它。若结算发生在用户 steer 结束等待之后、该次调用的读取之前,终态由那次读取携带,插件因此不再发出通知,避免把同一个完成报告两次。
唤醒默认不设上限:无人值守的 agent 连续串起后台命令与一次性 subagent 时,每次完成都会唤醒它。`maxConsecutiveWakes` 可以封顶:每个所有者最多被唤醒这么多次,此后的通知降级为注入;领取任何用户撰写的消息都会恢复预算。上限用来约束自激链——被唤醒的一轮可能启动某个后台任务,而它的完成又会唤醒同一个所有者——但超出上限的通知会静默等到用户下一次输入,依赖唤醒完成工作的会话会停在那里。`completionDelivery: quiet` 让空闲所有者也在注入通道上,确定性 transcript(文本记录)需要的正是这一点。
@@ -55,6 +55,7 @@ kind: "package-reference"
| `maxWaitTimeoutMs` | `600,000` | 模型所给等待时间的上限;更大的值向下收敛到它 |
| `completionDelivery` | `wakeup` | `wakeup` 为空闲所有者开启一轮;`quiet` 让通知继续待领 |
| `maxConsecutiveWakes` | 未设置 | 一个所有者可由唤醒开启的轮数,超出后通知降级为注入;未设置即不封顶 |
| `inlineOutputBytes` | `2048` | 内联携带任务输出和最终结果的完整通知的最大长度;`0` 表示从不内联 |
生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-tool-jobs)是每个受支持字段及其 JSDoc 的穷尽式真源。
@@ -82,7 +83,7 @@ kind: "package-reference"
| 文件 | 职责 |
|---|---|
| [`src/index.ts`](src/index.ts) | 插件入口:工具注册、结算订阅与模型杀停集合、提示词区段、输出上限 |
| [`src/index.ts`](src/index.ts) | 插件入口:工具注册、结算订阅、模型杀停集合与暂存的结算读取、提示词区段、输出上限 |
| [`src/render.ts`](src/render.ts) | 面向模型的渲染:公共投影、状态行与消耗式增量(stdout、`[stderr]` 段、丢失输出提示) |
### 输出上限
@@ -93,6 +94,8 @@ kind: "package-reference"
结算订阅(`{ owners: 'scope' }`)跳过注册表报告为 `awaited` 的结算、模型经 `job_kill` 杀停的任务、无所有者的任务与 teardown 结算,然后解析登记在拥有者会话下的 agent。`wakeup` 投递为空闲所有者开启一轮;设置了 `maxConsecutiveWakes` 时只在预算内开启,按确切 `Agent` 记录在 `WeakMap` 中,领取用户撰写的消息(`agent/inbox/claimed`)会重置该所有者的预算。繁忙的所有者——或超出所配预算的任何通知,以及 `quiet` 投递——改为注入 next-step inbox。注册表只计入结算时仍被欠着投影的等待,因此超时或中止的等待会让之后的结算照常通知;移除会把任务从模型杀停集合里删掉,该集合只保有模型杀停过的存活任务。本插件因 next-step 输入结束的等待,从被打断起持有一次占用,直到它自己的读取提交:落在这个窗口内的结算经该读取的终态与结果到达模型,因此不再发通知;而在读取报告任务仍在运行之后才发生的结算照常通知。工具通过中止自己的 controller 结束等待(注册表保持“中止即取消”的契约),因此被取消的调用会丢弃该次读取,被抑制的通知在占用释放时补发。
完整输出流(`JobView.output.total`)超过 `inlineOutputBytes` 的任务无法放入通知,因此其读取留在注册表中。否则,通知会在结算时取走消费式读取。内联通知暂存该读取,直到它被记录为 `user/message`(`session/event`);被某一步领取、但该步随后被拒绝或取消的通知不会被记录,因此 `job_output` 会返回其暂存的读取。读取放不下的通知会为 `job_output` 暂存该读取。`job_output` 还会将待领通知从为所有者会话注册的 agent 的 inbox 中移除。任务移除会丢弃该条目。插件重新加载会丢弃所有暂存读取,因此模型随后读取该任务时得到 `(no new output)`。
</details>
-----
@@ -153,11 +156,11 @@ Track every background job id you start. You are notified in-session when a job
#### 模型看到什么
读取会返回输出或 `(no new output)`,随后是 `[status: <status>]` 和可选 detail。空列表返回 `(no background jobs)`。kill 返回 `requested cancellation of job <id>` 或现有终止状态。模型尚未收走且有所有者归属的完成使用上述通知。`wait: true` 的读取在等待让位于用户 steer 或超时时返回 `[status: running]`(让位时会在进度行旁写明原因,如 `[status: running, user steered]`),在收走结算时返回终止状态。
读取会返回输出或 `(no new output)`,随后是 `[status: <status>]` 和可选 detail。空列表返回 `(no background jobs)`。kill 返回 `requested cancellation of job <id>` 或现有终止状态。模型尚未收走且有所有者归属的完成使用上述通知,并在容纳得下时附带任务的未读输出。`wait: true` 的读取在等待让位于用户 steer 或超时时返回 `[status: running]`(让位时会在进度行旁写明原因,如 `[status: running, user steered]`),在收走结算时返回终止状态。
#### Token 影响
结果与通知在压缩(compaction)前保留于父级历史。流读取不会重复已消费的输出;生产方提供的 `outputLimitBytes` 会限制每次完整读取或通知。在 `wakeup` 下,抵达空闲所有者的通知还会额外买下一次用户并未要求的模型请求,其数量仅在设置了 `maxConsecutiveWakes` 时按所有者封顶;抵达繁忙所有者的通知则只是给它已经在支付的那一轮加一步。
结果与通知在压缩(compaction)前保留于父级历史。流读取不会重复已消费的输出,内联通知省去了原本随后需要的 `job_output` 一步;生产方提供的 `outputLimitBytes` 会限制每次完整读取或通知。在 `wakeup` 下,抵达空闲所有者的通知还会额外买下一次用户并未要求的模型请求,其数量仅在设置了 `maxConsecutiveWakes` 时按所有者封顶;抵达繁忙所有者的通知则只是给它已经在支付的那一轮加一步。
#### KV Cache 影响
@@ -171,6 +174,7 @@ Track every background job id you start. You are notified in-session when a job
这些限制说明工具何时不合适。它们是当前包约束,不是任务积压。
- **落在 driver 退休窗口内的结算仍会让通知搁浅**——在轮次循环最后一次检查 inbox 与 driver 提交 idle 相位之间,所有者读起来仍是繁忙,因此通知走注入且无人唤醒。steering(中途引导)存在同样的问题;修复它属于 `agent-loop`。
- **插件重新加载会丢弃结算时取走的输出**——取走任务读取的通知会把该读取暂存在插件内存中,直到通知被记录或被 `job_output` 收走;而注册表已经交出该读取,因此在此期间重新加载会让 `job_output` 只得到 `(no new output)`。
- **已花掉的唤醒预算不会随时间恢复**——设置了 `maxConsecutiveWakes` 时,只有用户撰写的输入才能补充,因此预算耗尽的无人值守 agent 要等到其他原因开启下一轮时才收走剩余通知,且客户端不会显示有通知在等待。
- **待领于空闲所有者的通知无法在该所有者释放后存活**——释放时的取消会清空未领取的 inbox,日志保留插入/取消这一对作为记录。
- **模型读取只有单一消费方**——独立观察者使用注册表非消耗的 `readAt`(Web 客户端的 `job.follow`),而不是这些工具。
+89 -6
View File
@@ -4,14 +4,16 @@
* producers. It also delivers completions the model has not already
* collected to the owning agent: injected into a busy owner's next step, or
* opening a turn on an idle one under the default `wakeup` delivery, unbounded
* unless `maxConsecutiveWakes` caps it per owner.
* unless `maxConsecutiveWakes` caps it per owner. A notice carries the job's
* output and result when it fits `inlineOutputBytes`, and `job_output`
* withdraws a notice still pending.
* @module @deepseek-ai/dsh-tool-jobs
*/
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { boundContextSummary, createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm'
import type { ContextFormed } from '@deepseek-ai/dsh-llm'
import type { ContextFormed, MessageId } from '@deepseek-ai/dsh-llm'
import { TextRetainer } from '@deepseek-ai/dsh-output-retention'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView, ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools'
@@ -54,6 +56,13 @@ export interface Config {
* the next user input.
*/
maxConsecutiveWakes?: number
/**
* Largest complete completion notice, in UTF-8 bytes, that carries the
* job's output and final result inline (default 2048); a larger one, or one
* over the producer's `outputLimitBytes`, leaves them for `job_output`. 0
* never inlines.
*/
inlineOutputBytes?: number
}
export const Config: z<Config> = z.object({
@@ -61,6 +70,7 @@ export const Config: z<Config> = z.object({
maxWaitTimeoutMs: z.number().min(1).default(600_000),
completionDelivery: z.union(['quiet', 'wakeup'] as const).default('wakeup'),
maxConsecutiveWakes: z.number().min(1),
inlineOutputBytes: z.natural().default(2048),
})
/** Shared schema for job-control outputs. */
@@ -119,9 +129,37 @@ function completionSummary(job: JobView): string {
return boundContextSummary(`${job.kind} ${job.label} ${statusLine(publicJob(job))}`)
}
/**
* The job's label as a notice names it: the first non-empty line, bounded
* like a notice summary. The model wrote the full command and holds the job id.
* @param job - the settled job.
* @returns ` (kind: label) finished [status: …]`.
*/
function noticeDetail(job: JobView): string {
const text = job.label.trimStart()
const newline = text.indexOf('\n')
const label = boundContextSummary(newline === -1 ? text : `${text.slice(0, newline)}…`)
return ` (${job.kind}: ${label}) finished ${statusLine(publicJob(job))}`
}
/**
* The notice that carries the job's output and final result, when the
* complete text fits both `maxBytes` and the producer's cap.
* @param job - the settled job.
* @param output - the consuming read taken at settlement.
* @param maxBytes - the configured inline bound.
* @returns the notice text, or undefined when it does not fit.
*/
function inlineCompletionNotice(job: JobView, output: string, maxBytes: number): string | undefined {
const head = `background job ${job.id}${noticeDetail(job)}`
const text = output.length === 0 ? `${head}. No new output.` : `${head}. Output:\n${output.replace(/\n$/, '')}`
const bytes = encoder.encode(text).byteLength
return bytes <= maxBytes && bytes <= (job.outputLimitBytes ?? Infinity) ? text : undefined
}
function fitCompletionNotice(job: JobView): string {
const prefix = `background job ${job.id}`
const detail = ` (${job.kind}: ${job.label}) finished ${statusLine(publicJob(job))}`
const detail = noticeDetail(job)
const action = '\nDone; job_output.'
const complete = `${prefix}${detail}. Read its output with job_output.`
const maxBytes = job.outputLimitBytes
@@ -180,6 +218,16 @@ function presentJobCall(title: string, kind: 'read' | 'execute', rawInput?: stri
return { card: 'generic', title, kind, ...rawInput !== undefined ? { rawInput } : {} }
}
/** A completion notice not yet logged; see the `pending` map in {@link apply}. */
interface PendingNotice {
readonly owner: NonNullable<JobView['owner']>
readonly messageId: MessageId
/** Whether the notice carries `held`; otherwise `job_output` returns it. */
readonly inline: boolean
/** The consuming read taken at settlement, absent when the read stayed in the registry. */
readonly held?: string
}
/** The consuming read as the model sees it: the delta, then the result once, then the status line. */
function readBody(read: JobRead): { text: string; job: PublicJobSnapshot } {
const delta = renderModelDelta(read.chunks, read.lossy, read.job.output.spillPaths ?? [])
@@ -194,6 +242,7 @@ export function apply(ctx: Context, config: Config): void {
const waitCap = config.maxWaitTimeoutMs ?? 600_000
const delivery = config.completionDelivery ?? 'wakeup'
const wakeBudget = config.maxConsecutiveWakes
const inlineBytes = config.inlineOutputBytes ?? 2048
// Turns this plugin opened on each owner since that owner last consumed
// human input. Keyed by the exact Agent, so a same-session replacement
@@ -207,8 +256,16 @@ export function apply(ctx: Context, config: Config): void {
if (wakeBudget !== undefined && !Number.isSafeInteger(wakeBudget)) {
throw new Error(`tool-jobs: maxConsecutiveWakes (${wakeBudget}) must be a whole number of turns`)
}
// Nothing spends the budget under quiet delivery or without a cap, so
// nothing needs to refill it.
// Each completion notice until the model receives its output. A notice
// whose job wrote at most `inlineOutputBytes` took the consuming read at
// settlement. When the complete notice fits, it carries that read until it
// is logged as a `user/message`; a notice claimed by a step that is then
// rejected or cancelled leaves the read for `job_output`. Otherwise
// `job_output` returns the held read. `job_output` also withdraws a notice
// still pending, which would only repeat its result.
const pending = new Map<JobId, PendingNotice>()
if (delivery === 'wakeup' && wakeBudget !== undefined) {
ctx.on('agent/inbox/claimed', ({ agent, message }) => {
// Claiming is the point the human's input actually enters a step; a notice
@@ -216,6 +273,14 @@ export function apply(ctx: Context, config: Config): void {
if (message.source.kind === 'user') spentWakes.delete(agent)
})
}
ctx.on('session/event', (session, event) => {
if (event.type !== 'user/message' || event.data.source.kind !== 'tool-jobs') return
for (const [id, notice] of pending) {
if (notice.messageId !== event.data.id || notice.owner !== session.id) continue
// A logged notice keeps only a read it did not carry, for `job_output`.
if (notice.inline || notice.held === undefined) pending.delete(id)
}
})
const outputLimits = new WeakMap<ToolExecution, number>()
ctx.on('tools/pre-execute', (exec, next) => {
@@ -309,10 +374,17 @@ export function apply(ctx: Context, config: Config): void {
// absent here when it left before settlement — and then no inbox is left.
const owner = ctx.get('agents')?.get(event.job.owner)
if (owner === undefined) return
// A stream longer than the bound cannot fit, so the read stays in the
// registry. A read whose result makes the notice too long is held for
// `job_output`, whose result the tool pipeline bounds and spills.
const held = inlineBytes > 0 && event.job.output.total <= inlineBytes
? readBody(ctx.jobs.read(event.job.id, event.job.owner)).text
: undefined
const inline = held === undefined ? undefined : inlineCompletionNotice(event.job, held, inlineBytes)
const message = createUserMessage({
content: [{
type: 'text',
text: fitCompletionNotice(event.job),
text: inline ?? fitCompletionNotice(event.job),
}],
source: {
kind: 'tool-jobs',
@@ -320,6 +392,9 @@ export function apply(ctx: Context, config: Config): void {
summary: completionSummary(event.job),
},
})
pending.set(event.job.id, {
owner: event.job.owner, messageId: message.id, inline: inline !== undefined, ...held === undefined ? {} : { held },
})
if (delivery === 'wakeup' && owner.status === 'idle') {
if (wakeBudget === undefined) {
owner.followup(message)
@@ -338,6 +413,7 @@ export function apply(ctx: Context, config: Config): void {
ctx.jobs.events.subscribe({ owners: 'scope' }, (event) => {
if (event.type === 'removed') {
killedByModel.delete(event.job.id)
pending.delete(event.job.id)
return
}
if (event.type !== 'settled') return
@@ -441,6 +517,13 @@ export function apply(ctx: Context, config: Config): void {
const body = readBody(jobs.read(id, exec.agent?.id))
// This result carries any settlement the released wait suppressed.
if (releasedBy !== undefined) suppressedSettlements.delete(id)
const notice = pending.get(id)
if (notice !== undefined) {
pending.delete(id)
// This result covers everything the pending notice reports.
ctx.get('agents')?.get(notice.owner)?.inbox.remove(notice.messageId)
body.text = `${notice.held ?? ''}${body.text}`
}
// The result, not the schema, explains an interruption: the model sees
// why a running job came back early beside its unchanged progress line.
if (releasedBy !== undefined && (body.job.status === 'running' || body.job.status === 'stopping')) {
@@ -121,9 +121,10 @@ describe('next-step input during a background result wait', () => {
})
await settled.promise
unsubscribe()
expect(h.ctx.jobs.read(h.id, h.agent.id).result).toBe('final output')
expect(h.ctx.jobs.read(h.id, h.agent.id).result).toBeUndefined()
// The settlement's notice carries the result inline, so the registry has nothing left to hand out.
expect(h.agent.inbox.nextStep).toHaveLength(1)
expect(JSON.stringify(h.agent.inbox.nextStep[0]!.content)).toContain('Output:\\nfinal output')
expect(h.ctx.jobs.read(h.id, h.agent.id).result).toBeUndefined()
} finally { await h.dispose() }
})
+215 -3
View File
@@ -9,6 +9,8 @@ import AgentRegistry, { emitAgentEvent } from '@deepseek-ai/dsh-agent'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { UserMessage } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { SessionId } from '@deepseek-ai/dsh-session'
import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope'
import { JobId } from '@deepseek-ai/dsh-jobs'
@@ -70,6 +72,7 @@ async function setup(config: ToolJobs.Config = {}, registry: RegistryConfig = {}
interface FakeDelivery {
inject?: (...args: unknown[]) => void
followup?: (...args: unknown[]) => void
remove?: (...args: unknown[]) => boolean
/** Defaults to `running`, the lane that never wakes, so notice-content tests pin one lane. */
status?: 'idle' | 'running'
}
@@ -89,8 +92,10 @@ async function fakeAgent(ctx: Context, sessionId: string, delivery: FakeDelivery
status: delivery.status ?? 'running',
session: { id, header: { version: 0, id, createdAt: 0 } },
} as unknown as Agent
// Session projections fold the fake Session when a test logs a notice into it.
Object.assign(agent.session, { inheritedEventCount: 0, snapshotEvents: () => [] })
const nextStep: ReturnType<typeof createUserMessage>[] = []
Object.assign(agent, { inbox: { nextStep, nextTurn: [] } })
Object.assign(agent, { inbox: { nextStep, nextTurn: [], remove: delivery.remove ?? (() => true) } })
fakeInboxes.set(agent, nextStep)
agentRegistryDisposers.set(agent, await ctx.agents.register(agent))
agentScopeFibers.set(agent, scopeFiber)
@@ -807,7 +812,7 @@ describe('completion notice delivery', () => {
const message = inject.mock.calls[0]![0] as { content: readonly { type: string; text: string }[] }
expect(message.content[0]!.text).toBe(
`background job ${startedId(ctx, 'bash-1')} (bash: pnpm run watch) finished `
+ '[status: killed, signal: SIGTERM; cancelled by the user]. Read its output with job_output.',
+ '[status: killed, signal: SIGTERM; cancelled by the user]. No new output.',
)
})
@@ -950,7 +955,7 @@ describe('completion notices', () => {
expect(inject).toHaveBeenCalledWith({
id: expect.any(String) as unknown,
role: 'user',
content: [{ type: 'text', text: `background job ${startedId(ctx, 'bash-1')} (bash: pnpm test) finished [status: completed, exit code: 0]. Read its output with job_output.` }],
content: [{ type: 'text', text: `background job ${startedId(ctx, 'bash-1')} (bash: pnpm test) finished [status: completed, exit code: 0]. No new output.` }],
source: {
kind: 'tool-jobs',
form: 'notice',
@@ -1273,6 +1278,213 @@ describe('completion notices', () => {
})
})
describe('completion notice output', () => {
/** The notice message the fake owner's inject received. */
function notice(inject: ReturnType<typeof vi.fn>, index = 0): UserMessage & { content: [{ type: 'text'; text: string }] } {
return inject.mock.calls[index]![0] as UserMessage & { content: [{ type: 'text'; text: string }] }
}
/** Report the notice as logged into the owner's Session, the point it reaches the model. */
function logged(ctx: Context, owner: Agent, message: UserMessage): void {
ctx.emit('session/event', owner.session, { type: 'user/message', seq: 1, time: 0, data: message } as SessionEvent)
}
it('carries unread output that fits, and the read is spent once the notice is logged', async () => {
const { ctx } = await setup()
const inject = vi.fn()
const owner = await fakeAgent(ctx, 'sess-1', { inject })
const p = producer({ owner: owner.id, label: 'pnpm test' })
const id = ctx.jobs.start(p.spec)
p.append('ok 3 tests\n', { channel: 'stdout' })
p.settle({ status: 'completed', detail: 'exit code: 0' })
await tick()
expect(notice(inject).content[0].text).toBe(`background job ${id} (bash: pnpm test) finished [status: completed, exit code: 0]. Output:\nok 3 tests`)
logged(ctx, owner, notice(inject))
expect(text(await call(ctx, 'job_output', { job_id: id }, owner))).toBe('(no new output)\n[status: completed, exit code: 0]')
})
it('inlines a notice of exactly inlineOutputBytes, counted in UTF-8 bytes, and points a longer one at job_output', async () => {
// Job ids have a fixed length, so a placeholder id sizes the notice exactly.
// Three characters, nine bytes: a character count would admit the longer notice too.
const bound = Buffer.byteLength(`background job bash-${'0'.repeat(16)} (bash: exact) finished [status: completed]. Output:\n日本語`)
const bounded = await setup({ inlineOutputBytes: bound })
const boundedInject = vi.fn()
const boundedOwner = await fakeAgent(bounded.ctx, 'sess-1', { inject: boundedInject })
const fits = producer({ owner: boundedOwner.id, label: 'exact' })
const fitsId = bounded.ctx.jobs.start(fits.spec)
fits.append('日本語')
fits.settle({ status: 'completed' })
const over = producer({ owner: boundedOwner.id, label: 'exact' })
const overId = bounded.ctx.jobs.start(over.spec)
over.append('日本語x')
over.settle({ status: 'completed' })
await tick()
expect(notice(boundedInject, 0).content[0].text).toBe(`background job ${fitsId} (bash: exact) finished [status: completed]. Output:\n日本語`)
expect(Buffer.byteLength(notice(boundedInject, 0).content[0].text)).toBe(bound)
expect(notice(boundedInject, 1).content[0].text).toBe(`background job ${overId} (bash: exact) finished [status: completed]. Read its output with job_output.`)
logged(bounded.ctx, boundedOwner, notice(boundedInject, 1))
expect(text(await call(bounded.ctx, 'job_output', { job_id: overId }, boundedOwner))).toBe('日本語x\n[status: completed]')
})
it('points a notice one byte over inlineOutputBytes at job_output even when the stream fits', async () => {
const bound = Buffer.byteLength(`background job bash-${'0'.repeat(16)} (bash: exact) finished [status: completed]. Output:\n日本語`)
const { ctx } = await setup({ inlineOutputBytes: bound - 1 })
const inject = vi.fn()
const owner = await fakeAgent(ctx, 'sess-1', { inject })
const p = producer({ owner: owner.id, label: 'exact' })
const id = ctx.jobs.start(p.spec)
p.append('日本語')
p.settle({ status: 'completed' })
await tick()
expect(notice(inject).content[0].text).toBe(`background job ${id} (bash: exact) finished [status: completed]. Read its output with job_output.`)
logged(ctx, owner, notice(inject))
expect(text(await call(ctx, 'job_output', { job_id: id }, owner))).toBe('日本語\n[status: completed]')
})
it('leaves a large final result for job_output instead of inlining it', async () => {
const { ctx } = await setup()
const inject = vi.fn()
const owner = await fakeAgent(ctx, 'sess-1', { inject })
const p = producer({ owner: owner.id, kind: 'test-task', label: 'answer' })
const id = ctx.jobs.start(p.spec)
p.settle({ status: 'completed', result: 'r'.repeat(5_000) })
await tick()
expect(notice(inject).content[0].text).toBe(`background job ${id} (test-task: answer) finished [status: completed]. Read its output with job_output.`)
// A logged pointer notice keeps the read: the model collects it through job_output.
logged(ctx, owner, notice(inject))
expect(text(await call(ctx, 'job_output', { job_id: id }, owner))).toBe(`${'r'.repeat(5_000)}\n[status: completed]`)
expect(text(await call(ctx, 'job_output', { job_id: id }, owner))).toBe('(no new output)\n[status: completed]')
})
it('never inlines when inlineOutputBytes is 0', async () => {
const { ctx } = await setup({ inlineOutputBytes: 0 })
const inject = vi.fn()
const owner = await fakeAgent(ctx, 'sess-1', { inject })
const p = producer({ owner: owner.id, label: 'quiet' })
const id = ctx.jobs.start(p.spec)
p.append('kept in the registry\n')
p.settle({ status: 'completed' })
await tick()
expect(notice(inject).content[0].text).toBe(`background job ${id} (bash: quiet) finished [status: completed]. Read its output with job_output.`)
expect(text(await call(ctx, 'job_output', { job_id: id }, owner))).toBe('kept in the registry\n[status: completed]')
})
it('leaves a notice over the producer cap for job_output', async () => {
const { ctx } = await setup()
const inject = vi.fn()
const owner = await fakeAgent(ctx, 'sess-1', { inject })
const p = producer({ owner: owner.id, kind: 'test-task', label: 'capped', outputLimitBytes: 140 })
const id = ctx.jobs.start(p.spec)
p.settle({ status: 'completed', result: `${'r'.repeat(499)}z` })
await tick()
expect(notice(inject).content[0].text).toBe(`background job ${id} (test-task: capped) finished [status: completed]. Read its output with job_output.`)
const read = text(await call(ctx, 'job_output', { job_id: id }, owner))
expect(Buffer.byteLength(read)).toBeLessThanOrEqual(140)
expect(read).toMatch(/r+z\n\[output truncated\]\n\[status: completed\]$/)
})
it('withdraws a pending notice when job_output returns the same output', async () => {
const { ctx } = await setup()
const inject = vi.fn()
const remove = vi.fn(() => true)
const owner = await fakeAgent(ctx, 'sess-1', { inject, remove })
const p = producer({ owner: owner.id, kind: 'test-task' })
const id = ctx.jobs.start(p.spec)
p.append('partial\n')
p.settle({ status: 'completed', result: 'answer' })
await tick()
expect(text(await call(ctx, 'job_output', { job_id: id, wait: true }, owner))).toBe('partial\nanswer\n[status: completed]')
expect(remove).toHaveBeenCalledWith(notice(inject).id)
expect(text(await call(ctx, 'job_output', { job_id: id }, owner))).toBe('(no new output)\n[status: completed]')
expect(remove).toHaveBeenCalledTimes(1)
})
it('keeps the output of an inline notice that was claimed but never logged', async () => {
const { ctx } = await setup()
const inject = vi.fn()
const remove = vi.fn(() => false)
const owner = await fakeAgent(ctx, 'sess-1', { inject, remove })
const p = producer({ owner: owner.id })
const id = ctx.jobs.start(p.spec)
p.append('lost line\n')
p.settle({ status: 'completed' })
await tick()
// A rejected or cancelled step claims the notice without logging it.
emitAgentEvent(ctx, owner, 'agent/inbox/claimed', { message: notice(inject), turn: 1 })
expect(text(await call(ctx, 'job_output', { job_id: id }, owner))).toBe('lost line\n[status: completed]')
expect(remove).toHaveBeenCalledWith(notice(inject).id)
})
it('spends only the inline read of the logged notice in its own session', async () => {
const { ctx } = await setup()
const inject = vi.fn()
const owner = await fakeAgent(ctx, 'sess-1', { inject })
const other = await fakeAgent(ctx, 'sess-2')
const p = producer({ owner: owner.id })
const id = ctx.jobs.start(p.spec)
p.append('kept\n')
p.settle({ status: 'completed' })
await tick()
logged(ctx, other, notice(inject))
logged(ctx, owner, createUserMessage({ content: [{ type: 'text', text: 'x' }], source: { kind: 'tool-jobs', form: 'notice', summary: 'x' } }))
logged(ctx, owner, createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }))
ctx.emit('session/event', owner.session, { type: 'turn/start', seq: 2, time: 0, data: { turn: 1 } } as SessionEvent)
expect(text(await call(ctx, 'job_output', { job_id: id }, owner))).toBe('kept\n[status: completed]')
})
it('withdraws the notice from the agent now registered for the owner session', async () => {
const { ctx } = await setup()
const oldRemove = vi.fn(() => true)
const inject = vi.fn()
const oldOwner = await fakeAgent(ctx, 'shared', { inject, remove: oldRemove })
const p = producer({ owner: oldOwner.id })
const id = ctx.jobs.start(p.spec)
p.settle({ status: 'completed' })
await tick()
await detachAgent(oldOwner)
const remove = vi.fn(() => true)
const replacement = await fakeAgent(ctx, 'shared', { remove })
expect(text(await call(ctx, 'job_output', { job_id: id }, replacement))).toBe('(no new output)\n[status: completed]')
expect(oldRemove).not.toHaveBeenCalled()
expect(remove).toHaveBeenCalledWith(notice(inject).id)
})
it('withdraws nothing when the owner session has no live agent', async () => {
const { ctx } = await setup()
const owner = await fakeAgent(ctx, 'gone')
const p = producer({ owner: owner.id })
const id = ctx.jobs.start(p.spec)
p.settle({ status: 'completed' })
await tick()
await detachAgent(owner)
expect(text(await call(ctx, 'job_output', { job_id: id }, owner))).toBe('(no new output)\n[status: completed]')
})
it('names a multi-line label by its first line', async () => {
const { ctx } = await setup()
const inject = vi.fn()
const owner = await fakeAgent(ctx, 'sess-1', { inject })
const p = producer({ owner: owner.id, label: "cat > a.mjs <<'EOF'\nconsole.log(1)\nEOF" })
const id = ctx.jobs.start(p.spec)
p.settle({ status: 'completed' })
await tick()
expect(notice(inject).content[0].text).toBe(`background job ${id} (bash: cat > a.mjs <<'EOF'…) finished [status: completed]. No new output.`)
})
})
describe('released waits crossed by a settlement', () => {
/** A tool-jobs mount at `quiet` delivery with one fake owner to observe notices. */
async function releaseSetup() {
+2 -2
View File
@@ -27,8 +27,8 @@
en: a5eecea7bb7a75e8
zh: fae7f08e0e11594e
/deepseek-ai-dsh-tool-bash/use-this-package/foreground-commands-as-jobs:
en: 5eef538028ca60d6
zh: dc1c8ec24c81d2ca
en: d6da2d08afdca324
zh: 606b7b1da7447874
/deepseek-ai-dsh-tool-bash/use-this-package/sandboxed-execution-and-escalation:
en: 472beeb895ca46cf
zh: ba110c9f62f7d5b8
+1 -1
View File
@@ -63,7 +63,7 @@ Passing `run_in_background: true` admits a job and returns its id immediately; c
### Foreground commands as jobs
With a job registry composed, a foreground command is registered with `ctx.jobs` at its start and the call waits on that job: the command is listed, streams through `job.list` and `job.follow`, and can be stopped from the Web task list for as long as it runs. A command that finishes within the timeout returns the ordinary foreground result and its job record leaves the registry with it, so the model never sees an id. A command that outlives the timeout keeps running as the job it already was, and the call returns `[still running after <timeoutMs>ms; moved to background job <id>]` plus the job hand-off guidance, seeded with one consuming read of the output so far — `job_output` continues exactly after it. A kill from outside the call (the human stopping the job) settles the foreground result with `[stopped: <reason>]` ahead of the signal marker, so the model reads the reason instead of a command failure; cancelling the call itself kills the job. Registration is best-effort: `promoteOnTimeout: false`, a missing job registry, or a registry that refuses the job at its start (the owner's job limit, no controller) run the command under the executor's deadline kill instead, and the `timeoutMs` parameter description advertises the hand-over only when it holds.
With a job registry composed, a foreground command is registered with `ctx.jobs` at its start and the call waits on that job: the command is listed, streams through `job.list` and `job.follow`, and can be stopped from the Web task list for as long as it runs. A command that finishes within the timeout returns the ordinary foreground result and its job record leaves the registry with it, so the model never sees an id. A command that outlives the timeout keeps running as the job it already was, and the call returns `[still running after <timeoutMs>ms; moved to background job <id>]`, seeded with one consuming read of the output so far — `job_output` continues exactly after it. A kill from outside the call (the human stopping the job) settles the foreground result with `[stopped: <reason>]` ahead of the signal marker, so the model reads the reason instead of a command failure; cancelling the call itself kills the job. Registration is best-effort: `promoteOnTimeout: false`, a missing job registry, or a registry that refuses the job at its start (the owner's job limit, no controller) run the command under the executor's deadline kill instead, and the `timeoutMs` parameter description advertises the hand-over only when it holds.
### Sandboxed execution and escalation
+1 -1
View File
@@ -63,7 +63,7 @@ kind: "package-reference"
### 前台命令即任务
组合中有 job 注册表时,前台命令一启动就登记到 `ctx.jobs`,调用等待该任务:命令在运行期间始终被列出、经 `job.list` 与 `job.follow` 流式观看,并可从 Web 任务列表停止。在超时内完成的命令返回普通前台结果,其任务记录随结果一起离开注册表,模型从不看到 id。超过超时仍在运行的命令继续作为它本来就是的那个任务运行,调用返回 `[still running after <timeoutMs>ms; moved to background job <id>]` 加任务交接指引,并以一次消费式读取带上目前为止的输出——`job_output` 恰好从此处接续。来自调用之外的杀停(人在界面上停止任务)会让前台结果在信号标记之前带上 `[stopped: <reason>]`,模型读到的是原因而不是命令失败;取消调用本身则杀掉任务。登记是尽力而为的:`promoteOnTimeout: false`、缺少 job 注册表,或注册表在启动时拒绝该任务(持有者的任务上限、没有控制器)都会改为在执行器的 deadline 杀下运行命令,`timeoutMs` 参数描述也只在交接语义成立时才宣传它。
组合中有 job 注册表时,前台命令一启动就登记到 `ctx.jobs`,调用等待该任务:命令在运行期间始终被列出、经 `job.list` 与 `job.follow` 流式观看,并可从 Web 任务列表停止。在超时内完成的命令返回普通前台结果,其任务记录随结果一起离开注册表,模型从不看到 id。超过超时仍在运行的命令继续作为它本来就是的那个任务运行,调用返回 `[still running after <timeoutMs>ms; moved to background job <id>]`,并以一次消费式读取带上目前为止的输出——`job_output` 恰好从此处接续。来自调用之外的杀停(人在界面上停止任务)会让前台结果在信号标记之前带上 `[stopped: <reason>]`,模型读到的是原因而不是命令失败;取消调用本身则杀掉任务。登记是尽力而为的:`promoteOnTimeout: false`、缺少 job 注册表,或注册表在启动时拒绝该任务(持有者的任务上限、没有控制器)都会改为在执行器的 deadline 杀下运行命令,`timeoutMs` 参数描述也只在交接语义成立时才宣传它。
### 沙箱执行与升权
+2 -4
View File
@@ -69,7 +69,7 @@ export function renderResult(
* Shape a foreground call that stopped waiting into the text the model sees:
* the output captured so far (one consuming registry read taken at that
* point, so `job_output` continues exactly after it), then the still-running
* marker and the job hand-off guidance.
* marker. The `tool:jobs` system-prompt section explains how to follow up.
* @param promoted - the promoted result value: the job id, the wait that
* expired, and the output so far.
* @returns the model-facing text for a promoted call.
@@ -78,9 +78,7 @@ export function renderPromoted(promoted: { jobId: string; timeoutMs: number; out
const body = promoted.output.length > 0
? promoted.output.endsWith('\n') ? promoted.output : `${promoted.output}\n`
: ''
return `${body}[still running after ${promoted.timeoutMs}ms; moved to background job ${promoted.jobId}]\n`
+ 'The command keeps running in the background. You will be notified when it finishes; '
+ 'read newer output with job_output, stop it with job_kill.'
return `${body}[still running after ${promoted.timeoutMs}ms; moved to background job ${promoted.jobId}]`
}
/**
@@ -231,7 +231,7 @@ describe('foreground commands as jobs', () => {
const body = text(result)
expect(body).toContain('early-output')
expect(body).toContain(`[still running after 250ms; moved to background job ${jobId}]`)
expect(body).toContain('read newer output with job_output, stop it with job_kill')
expect(body.endsWith(`moved to background job ${jobId}]`)).toBe(true)
expect(body.indexOf('early-output')).toBeLessThan(body.indexOf('[still running'))
const jobs = ctx.jobs
@@ -461,9 +461,7 @@ describe('foreground commands as jobs', () => {
describe('renderPromoted', () => {
it('pins the promoted text with and without pre-promotion output', () => {
expect(renderPromoted({ jobId: 'bash-7', timeoutMs: 120_000, output: '' })).toBe(
'[still running after 120000ms; moved to background job bash-7]\n'
+ 'The command keeps running in the background. You will be notified when it finishes; '
+ 'read newer output with job_output, stop it with job_kill.',
'[still running after 120000ms; moved to background job bash-7]',
)
// A partial line gains the separating newline exactly once.
expect(renderPromoted({ jobId: 'bash-7', timeoutMs: 250, output: 'partial' }))
@@ -210,7 +210,7 @@ describe('bash tool through the agent loop', () => {
expect(resultText(toolResult)).toContain('[exit code: 9]')
})
it('background: start ack → completion wakes the idle agent → job_output collects it', async () => {
it('background: start ack → completion wakes the idle agent with the output inline', async () => {
// The command blocks on a sentinel this test creates only after the agent
// has gone idle, so settlement cannot fold into the still-running turn.
// Without that fence a fast command can settle before step 2's pre-step
@@ -257,25 +257,26 @@ describe('bash tool through the agent loop', () => {
const found = events(agent).findLast(event => event.type === 'tool/result')
return found === undefined ? '' : resultText(found)
}
await pollUntil(() => events(agent).some(isNotice) && lastResultText().includes('bg-ok'))
await pollUntil(() => events(agent).some(isNotice) && lastResultText().includes('[status: completed'))
// Two turns: the user's, then the one the completion opened by itself.
expect(events(agent).filter(event => event.type === 'turn/start')).toHaveLength(2)
// The notice carries the gated command as its label, so this pins the id,
// the terminal status, and the producer identity; the verbatim notice text
// and its bounding are pinned in the tool-jobs unit tests.
// The notice carries the gated command as its label and the job's output,
// so this pins the id, the terminal status, the output, and the producer
// identity; the verbatim notice text and its bounding are pinned in the
// tool-jobs unit tests.
const notice = events(agent).find(isNotice)!
const noticeText = notice.data.content
.filter(block => block.type === 'text').map(block => block.text).join('')
expect(noticeText).toContain(`background job ${jobId} (bash: `)
expect(noticeText).toContain('finished [status: completed, exit code: 0]')
expect(noticeText).toContain('finished [status: completed, exit code: 0]. Output:\nbg-ok')
expect(notice.data.source).toMatchObject({
kind: 'tool-jobs',
form: 'notice',
})
const readResult = findEvent(events(agent), 'tool/result', 'last')
expect(readResult.data.message.isError).toBe(false)
expect(resultText(readResult)).toContain('bg-ok')
expect(resultText(readResult)).toContain('[status: completed, exit code: 0]')
// The notice already delivered the output, so the read has nothing new.
expect(resultText(readResult)).toBe('(no new output)\n[status: completed, exit code: 0]')
})
})
+2 -2
View File
@@ -27,8 +27,8 @@
en: 80195c9b61b29737
zh: 0a187f3f52814b90
/deepseek-ai-dsh-tool-pwsh/use-this-package/foreground-commands-as-jobs:
en: 4a487e48ef146846
zh: 0a34dc49bffb03af
en: e2ac7a741c287b92
zh: a9e95c0a84671f45
/deepseek-ai-dsh-tool-pwsh/use-this-package/windows-specific-sandbox-behavior:
en: 76f3f791f53d1005
zh: e5b262db24e6eb83
+1 -1
View File
@@ -58,7 +58,7 @@ The tool executes `pwsh -Command <command>` and returns the combined output. Com
### Foreground commands as jobs
With a job registry composed, a foreground command is registered with `ctx.jobs` at its start and the call waits on that job: the command is listed, streams through `job.list` and `job.follow`, and can be stopped from the Web task list for as long as it runs. A command that finishes within the timeout returns the ordinary foreground result and its job record leaves the registry with it, so the model never sees an id. A command that outlives the timeout keeps running as the job it already was, and the call returns `[still running after <timeoutMs>ms; moved to background job <id>]` plus the job hand-off guidance, seeded with one consuming read of the output so far — `job_output` continues exactly after it. A kill from outside the call (the human stopping the job) settles the foreground result with `[stopped: <reason>]` ahead of the exit marker, so the model reads the reason instead of a command failure; cancelling the call itself kills the job. Registration is best-effort: `promoteOnTimeout: false`, a missing job registry, or a registry that refuses the job at its start (the owner's job limit, no controller) run the command under the executor's deadline kill instead, and the `timeoutMs` parameter description advertises the hand-over only when it holds.
With a job registry composed, a foreground command is registered with `ctx.jobs` at its start and the call waits on that job: the command is listed, streams through `job.list` and `job.follow`, and can be stopped from the Web task list for as long as it runs. A command that finishes within the timeout returns the ordinary foreground result and its job record leaves the registry with it, so the model never sees an id. A command that outlives the timeout keeps running as the job it already was, and the call returns `[still running after <timeoutMs>ms; moved to background job <id>]`, seeded with one consuming read of the output so far — `job_output` continues exactly after it. A kill from outside the call (the human stopping the job) settles the foreground result with `[stopped: <reason>]` ahead of the exit marker, so the model reads the reason instead of a command failure; cancelling the call itself kills the job. Registration is best-effort: `promoteOnTimeout: false`, a missing job registry, or a registry that refuses the job at its start (the owner's job limit, no controller) run the command under the executor's deadline kill instead, and the `timeoutMs` parameter description advertises the hand-over only when it holds.
### Windows-specific sandbox behavior
+1 -1
View File
@@ -58,7 +58,7 @@ kind: "package-reference"
### 前台命令即任务
组合中有 job 注册表时,前台命令一启动就登记到 `ctx.jobs`,调用等待该任务:命令在运行期间始终被列出、经 `job.list` 与 `job.follow` 流式观看,并可从 Web 任务列表停止。在超时内完成的命令返回普通前台结果,其任务记录随结果一起离开注册表,模型从不看到 id。超过超时仍在运行的命令继续作为它本来就是的那个任务运行,调用返回 `[still running after <timeoutMs>ms; moved to background job <id>]` 加任务交接指引,并以一次消费式读取带上目前为止的输出——`job_output` 恰好从此处接续。来自调用之外的杀停(人在界面上停止任务)会让前台结果在退出标记之前带上 `[stopped: <reason>]`,模型读到的是原因而不是命令失败;取消调用本身则杀掉任务。登记是尽力而为的:`promoteOnTimeout: false`、缺少 job 注册表,或注册表在启动时拒绝该任务(持有者的任务上限、没有控制器)都会改为在执行器的 deadline 杀下运行命令,`timeoutMs` 参数描述也只在交接语义成立时才宣传它。
组合中有 job 注册表时,前台命令一启动就登记到 `ctx.jobs`,调用等待该任务:命令在运行期间始终被列出、经 `job.list` 与 `job.follow` 流式观看,并可从 Web 任务列表停止。在超时内完成的命令返回普通前台结果,其任务记录随结果一起离开注册表,模型从不看到 id。超过超时仍在运行的命令继续作为它本来就是的那个任务运行,调用返回 `[still running after <timeoutMs>ms; moved to background job <id>]`,并以一次消费式读取带上目前为止的输出——`job_output` 恰好从此处接续。来自调用之外的杀停(人在界面上停止任务)会让前台结果在退出标记之前带上 `[stopped: <reason>]`,模型读到的是原因而不是命令失败;取消调用本身则杀掉任务。登记是尽力而为的:`promoteOnTimeout: false`、缺少 job 注册表,或注册表在启动时拒绝该任务(持有者的任务上限、没有控制器)都会改为在执行器的 deadline 杀下运行命令,`timeoutMs` 参数描述也只在交接语义成立时才宣传它。
### Windows 特有的沙箱行为
+2 -4
View File
@@ -89,7 +89,7 @@ export function renderPwshResult(
* Shape a foreground call that stopped waiting into the text the model sees:
* the output captured so far (one consuming registry read taken at that
* point, so `job_output` continues exactly after it), then the still-running
* marker and the job hand-off guidance.
* marker. The `tool:jobs` system-prompt section explains how to follow up.
* @param promoted - the promoted result value: the job id, the wait that
* expired, and the output so far.
* @returns the model-facing text for a promoted call.
@@ -98,9 +98,7 @@ export function renderPwshPromoted(promoted: { jobId: string; timeoutMs: number;
const body = promoted.output.length > 0
? promoted.output.endsWith('\n') ? promoted.output : `${promoted.output}\n`
: ''
return `${body}[still running after ${promoted.timeoutMs}ms; moved to background job ${promoted.jobId}]\n`
+ 'The command keeps running in the background. You will be notified when it finishes; '
+ 'read newer output with job_output, stop it with job_kill.'
return `${body}[still running after ${promoted.timeoutMs}ms; moved to background job ${promoted.jobId}]`
}
/**
@@ -297,7 +297,7 @@ describe('foreground commands as jobs (pwsh)', () => {
const jobId = ctx.jobs.list()[0]?.id
expect(jobId).toMatch(/^pwsh-[0-9a-f]{16}$/)
expect(body).toContain(`moved to background job ${jobId}]`)
expect(body).toContain('read newer output with job_output, stop it with job_kill')
expect(body.endsWith(`moved to background job ${jobId}]`)).toBe(true)
const jobs = ctx.jobs
const job = jobs.list()[0]
@@ -547,9 +547,7 @@ describe('foreground commands as jobs (pwsh)', () => {
describe('renderPwshPromoted', () => {
it('pins the promoted text with and without pre-promotion output', () => {
expect(renderPwshPromoted({ jobId: 'pwsh-7', timeoutMs: 120_000, output: '' })).toBe(
'[still running after 120000ms; moved to background job pwsh-7]\n'
+ 'The command keeps running in the background. You will be notified when it finishes; '
+ 'read newer output with job_output, stop it with job_kill.',
'[still running after 120000ms; moved to background job pwsh-7]',
)
expect(renderPwshPromoted({ jobId: 'pwsh-7', timeoutMs: 250, output: 'partial' }))
.toContain('partial\n[still running after 250ms; moved to background job pwsh-7]')
+2 -2
View File
@@ -69,8 +69,8 @@
en: f7cec22ba9d3fb4f
zh: 67b1d40f9b136f71
/deepseek-ai-dsh-subagent/model-experience/settlement-notice/what-the-model-sees:
en: b61ce5a34e154028
zh: 861b809745f47672
en: 8e28e4542cd6bcb6
zh: c840f65ffb8fcfbd
/deepseek-ai-dsh-subagent/model-experience/settlement-notice/token-effect:
en: f98ad834e8ce8950
zh: cf509805dd934094
+1 -1
View File
@@ -164,7 +164,7 @@ Read these pages when the package-level contract is not enough. They move from t
#### What the model sees
Parent delivery emits a user-role status notice containing the child’s nonempty final text blocks, regardless of `send_message` availability or use. External notices also state that further messages are unsupported. Reasoning and other nontext blocks are excluded. Every parent notice includes available structured output as `Structured result: <JSON>` and the provider’s safe diagnostic as a separate text block. Empty closing text is reported as `It left no closing message.` Caller delivery emits no notice; SDK lifecycle notifications retain complete child output when cleanup succeeds.
Parent delivery emits a user-role status notice containing the child’s nonempty final text blocks. When the child's last action before that text was a message to its parent, with no input claimed and no tool call logged after it, the notice replaces them with `It sent you a message just before finishing, so its closing message is omitted.`; the parent recovers omitted closing text only by messaging the child again. External notices also state that further messages are unsupported. Reasoning and other nontext blocks are excluded. Every parent notice includes available structured output as `Structured result: <JSON>` and the provider’s safe diagnostic as a separate text block. Empty closing text is reported as `It left no closing message.` Caller delivery emits no notice; SDK lifecycle notifications retain complete child output when cleanup succeeds.
#### Token effect
+1 -1
View File
@@ -164,7 +164,7 @@ kind: "package-reference"
#### 模型看到什么
父级投递发送包含子级最终输出中非空文本块的 user-role 状态通知,不受 `send_message` 是否可用或是否调用影响。外部通知还说明不支持后续消息。推理及其他非文本块被排除。每条父级通知都会以 `Structured result: <JSON>` 附带可用的结构化输出,并以独立文本块附带提供方安全诊断。收尾文本为空时,通知使用 `It left no closing message.`。调用方投递不发送通知;清理成功时 SDK 生命周期通知保留完整子级输出。
父级投递发送包含子级最终输出中非空文本块的 user-role 状态通知。若子级在该文本之前的最后一个动作是向父级发送消息,且此后既未领取输入也未记录任何工具调用,通知会以 `It sent you a message just before finishing, so its closing message is omitted.` 替代这些文本块;父级只有再次向子级发送消息才能取回被省略的收尾文本。外部通知还说明不支持后续消息。推理及其他非文本块被排除。每条父级通知都会以 `Structured result: <JSON>` 附带可用的结构化输出,并以独立文本块附带提供方安全诊断。收尾文本为空时,通知使用 `It left no closing message.`。调用方投递不发送通知;清理成功时 SDK 生命周期通知保留完整子级输出。
#### Token 影响
@@ -84,6 +84,12 @@ export type ActivationResource =
failureAt: SessionLogOffset | undefined
/** Renewed when local settlement must recheck input or owned children. */
poke: PromiseWithResolvers<void>
/**
* Whether this child's settlement notice omits its closing text: true while
* the child's last logged tool call was a message to its direct parent and
* no input was claimed since.
*/
omitClosingText: boolean
}
| {
readonly kind: 'external'
@@ -133,12 +133,15 @@ function settlementSummary(childId: SessionId, stopReason: SubagentResult['stopR
* @param childId - durable child session id named in the notice.
* @param terminal - recorded terminal state for the settled Activation.
* @param continuable - whether the child can accept another task.
* @param omitClosingText - replace the closing text with a note that the child's
* last message to the parent already carries it.
* @returns the durable user-message representation delivered to the parent.
*/
export function createSettlementMessage(
childId: SessionId,
terminal: SubagentResult,
continuable = true,
omitClosingText = false,
): ReturnType<typeof createUserMessage> {
const summary = settlementSummary(childId, terminal.stopReason, continuable)
// Parent providers receive this notice as a user message and may reject
@@ -150,9 +153,11 @@ export function createSettlementMessage(
return createUserMessage({
content: [
{ type: 'text' as const, text: summary },
...closingText.length === 0
? [{ type: 'text' as const, text: 'It left no closing message.' }]
: [{ type: 'text' as const, text: 'Its closing message:' }, ...closingText],
...omitClosingText
? [{ type: 'text' as const, text: 'It sent you a message just before finishing, so its closing message is omitted.' }]
: closingText.length === 0
? [{ type: 'text' as const, text: 'It left no closing message.' }]
: [{ type: 'text' as const, text: 'Its closing message:' }, ...closingText],
...terminal.structured !== undefined
? [{ type: 'text' as const, text: `Structured result: ${JSON.stringify(terminal.structured)}` }] : [],
...terminal.diagnostic !== undefined
+24 -4
View File
@@ -921,7 +921,15 @@ export class SubagentManager {
setup,
})
resource = { kind: 'local', handle, outputStart: handle.agent.session.seq, structured, failureAt: undefined, poke: Promise.withResolvers<void>() }
resource = {
kind: 'local',
handle,
outputStart: handle.agent.session.seq,
structured,
failureAt: undefined,
poke: Promise.withResolvers<void>(),
omitClosingText: false,
}
}
const observer = this.host.observeActivation(provider, childId, parent)
@@ -950,8 +958,19 @@ export class SubagentManager {
if (activation.kind === 'local') {
observeLocalFailure(activation, wakeSettlement)
activation.handle.agent.ctx.on('agent/inbox/inserted', wakeSettlement)
activation.handle.agent.ctx.on('agent/inbox/claimed', wakeSettlement)
activation.handle.agent.ctx.on('agent/inbox/claimed', () => {
// Claimed input means the closing text may report work the child's
// last message to its parent did not.
activation.omitClosingText = false
wakeSettlement()
})
activation.handle.agent.ctx.on('agent/inbox/discarded', wakeSettlement)
// A tool call logged after the message also voids it; logged calls
// cannot be skipped by tool policy. `send_message` is not concurrency
// safe, so a later call in its step is logged after it executes.
activation.handle.agent.ctx.on('session/event', (_session, event) => {
if (event.type === 'tool/call') activation.omitClosingText = false
})
}
observer.start(activationAgent(activation))
} catch (error: unknown) {
@@ -1190,7 +1209,7 @@ export class SubagentManager {
const parent = this.ctx.agents.get(activation.parent.id)
if (parent !== activation.parent) return
const message = createSettlementMessage(
activation.childId, terminal, activationAgent(activation) !== undefined,
activation.childId, terminal, activation.kind === 'local', activation.kind === 'local' && activation.omitClosingText,
)
if (this.closingTeardownFor(parent) !== undefined) {
parent.inject(message)
@@ -1282,7 +1301,7 @@ export class SubagentManager {
/** Deliver one resident continuable child's message to its live direct parent. */
private sendToParent(
activation: Activation,
activation: LocalActivation,
sender: Agent,
content: ContentBlock[],
): MessageId {
@@ -1303,6 +1322,7 @@ export class SubagentManager {
}
const message = createAgentMessage(sender, content)
this.sendAgentMessage(parent, message)
activation.omitClosingText = true
return message.id
}
@@ -67,3 +67,15 @@ it('includes closing text alongside structured results and diagnostics', () => {
{ type: 'text', text: 'safe diagnostic' },
])
})
it('omits the closing text after the child messaged its parent, keeping structured results and diagnostics', () => {
expect(createSettlementMessage(childId, {
stopReason: 'completed', output: [{ type: 'text', text: 'final answer, restated' }],
structured: { answer: 42 }, diagnostic: 'safe diagnostic',
}, true, true).content).toEqual([
summary,
{ type: 'text', text: 'It sent you a message just before finishing, so its closing message is omitted.' },
{ type: 'text', text: 'Structured result: {"answer":42}' },
{ type: 'text', text: 'safe diagnostic' },
])
})
@@ -2928,9 +2928,126 @@ describe('continuable settlement delivery', () => {
await waitNoActivation(ctx, started.childId)
// The contract is unconditional precisely so the parent-side tool
// description can promise it; bookkeeping "did it report?" would make the
// promise conditional on a channel this manager does not own.
// description can promise it; only the closing text, which restates the
// message, is left out.
await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) })
expect(settlementNotices(parent)[0]!.text).toBe(
`Background subagent ${started.childId} finished and will do no further work unless you send it more.\n`
+ 'It sent you a message just before finishing, so its closing message is omitted.',
)
})
it('reports the closing message again when the child received input after its message', async () => {
const release = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([
{ chunks: textResponse('interim'), gate: release.promise },
{ chunks: textResponse('parent ack') },
{ chunks: textResponse('the answer') },
{ chunks: textResponse('parent ack') },
])
const { ctx, parent } = await setupWith(adapter)
const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' })
const child = await vi.waitFor(() => {
const live = ctx.agents.get(started.childId)
expect(live).toBeDefined()
expect(adapter.requests).toHaveLength(1)
return live!
})
await ctx.subagents.sendMessage(child, parent.id, message('an interim finding'), { signal: testSignal })
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) })
await parent.whenIdle()
await ctx.subagents.sendMessage(parent, started.childId, message('now finish'), { signal: testSignal })
release.resolve(undefined)
await waitNoActivation(ctx, started.childId)
await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) })
expect(settlementNotices(parent)[0]!.text).toBe(
`Background subagent ${started.childId} finished and will do no further work unless you send it more.\nIts closing message:\nthe answer`,
)
})
it('reports the closing message when the child ran a tool after its message, even past a short-circuiting tool policy', async () => {
const release = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([
{ chunks: toolCallResponse('t1', 'noop', {}), gate: release.promise },
{ chunks: textResponse('parent ack') },
{ chunks: textResponse('the answer') },
{ chunks: textResponse('parent ack') },
])
const { ctx, parent } = await setupWith(adapter)
ctx.tools.register(defineTool({
name: 'noop',
description: 'does nothing',
parameters: {},
output: {
schema: { type: 'object', additionalProperties: false, properties: {} },
render: () => [{ type: 'text', text: 'noop' }],
},
execute: () => Promise.resolve({}),
}))
// A policy that allows the call without delegating hides it from later pre-execute listeners.
ctx.on('tools/pre-execute', (exec, next) => exec.name === 'noop' ? Promise.resolve({ kind: 'allow' as const }) : next(), { prepend: true })
const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' })
const child = await vi.waitFor(() => {
const live = ctx.agents.get(started.childId)
expect(live).toBeDefined()
expect(adapter.requests).toHaveLength(1)
return live!
})
await ctx.subagents.sendMessage(child, parent.id, message('an interim finding'), { signal: testSignal })
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) })
release.resolve(undefined)
await waitNoActivation(ctx, started.childId)
await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) })
expect(settlementNotices(parent)[0]!.text).toBe(
`Background subagent ${started.childId} finished and will do no further work unless you send it more.\nIts closing message:\nthe answer`,
)
})
it.each([
['reports', 'another call follows the message in its step', ['send_message', 'noop'], 'Its closing message:\nthe answer'],
['omits', 'the message is the last call of its step', ['noop', 'send_message'], 'It sent you a message just before finishing, so its closing message is omitted.'],
] as const)('%s the closing message when %s', async (_verb, _case, order, ending) => {
let childTurns = 0
const respond = (options: GenerateOptions): StreamChunk[] => {
const history = JSON.stringify(options.messages)
if (!history.includes('Your parent agent id is')) return textResponse('parent ack')
childTurns += 1
if (childTurns > 1) return textResponse('the answer')
const parentId = /Your parent agent id is \\"([^\\]+)\\"/.exec(history)![1]!
const calls = order.map(name => name === 'noop'
? { id: ToolCallId('n1'), name, arguments: '{}' }
: { id: ToolCallId('s1'), name, arguments: JSON.stringify({ agent_id: parentId, message: 'interim' }) })
return [
...calls.flatMap((call, index): StreamChunk[] => [
{ type: 'block-start', index, blockType: 'tool-call' },
{ type: 'tool-call-delta', index, id: call.id, name: call.name, argumentsDelta: call.arguments },
{ type: 'block-end', index, block: { type: 'tool-call', ...call } },
]),
{ type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } },
{ type: 'finish', reason: { kind: 'tool-calls' } },
]
}
const { ctx, parent } = await setup(Array.from({ length: 6 }, () => respond))
await ctx.plugin(SubagentControl)
ctx.tools.register(defineTool({
name: 'noop',
description: 'does nothing',
parameters: {},
output: {
schema: { type: 'object', additionalProperties: false, properties: {} },
render: () => [{ type: 'text', text: 'noop' }],
},
execute: () => Promise.resolve({}),
}))
const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' })
await waitNoActivation(ctx, started.childId)
await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) })
expect(settlementNotices(parent)[0]!.text).toBe(
`Background subagent ${started.childId} finished and will do no further work unless you send it more.\n${ending}`,
)
})
it('delivers the terminal reason when the child never had a chance to report', async () => {
@@ -1,6 +1,6 @@
import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit'
import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native'
import { unsupportedModel, provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit'
import { createInboxStub, unsupportedModel, provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
@@ -8,7 +8,6 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
import type { ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
import { WorkflowRunId, WorkflowEngine } from '@deepseek-ai/dsh-workflow'
import type {
WorkflowAgentEndInfo, WorkflowAgentInfo, WorkflowResult, WorkflowRun,
@@ -464,7 +463,7 @@ describe('dsh-tool-workflow', () => {
const session = Session.create(SessionId('caller'))
const parent: Agent = {
id: session.id, options: {}, session, model: unsupportedModel(),
inbox: unsupportedInbox(), status: 'idle', ctx,
inbox: createInboxStub(), status: 'idle', ctx,
send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel: () => {},
runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(),
}
@@ -518,9 +517,16 @@ describe('dsh-tool-workflow', () => {
const settled = jobs.get(job.id, parent.id)
expect(settled.detail).toBe('4 agents')
expect(settled.progress).toBeUndefined()
const read = jobs.read(job.id, parent.id)
expect(read.result).toContain('workflow "audit" completed (4 agents)')
expect(read.result).toContain('"findings": 2')
const read = await executeToolFixture(ctx, {
signal: testToolSignal,
callId: ToolCallId('call-settled-read'),
name: 'job_output',
arguments: { job_id: jobId },
agent: parent,
})
const readText = (read.content[0] as { text: string }).text
expect(readText).toContain('workflow "audit" completed (4 agents)')
expect(readText).toContain('"findings": 2')
expect(engine.disposed).toBe(1)
// The durable session record still brackets the background run.
expect(session.snapshotEvents().map(event => event.type)).toEqual([
@@ -686,7 +692,7 @@ describe('dsh-tool-workflow', () => {
const session = Session.create(SessionId('caller'))
const parent: Agent = {
id: session.id, options: {}, session, model: unsupportedModel(),
inbox: unsupportedInbox(), status: 'idle', ctx,
inbox: createInboxStub(), status: 'idle', ctx,
send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel: () => {},
runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(),
}
@@ -706,7 +712,14 @@ describe('dsh-tool-workflow', () => {
expect(jobs.get(jobId, parent.id).detail).toBe('0 agents')
const ring = jobs.readAt(jobId, 0, parent.id).chunks.map(chunk => chunk.text).join('')
expect(ring).toContain('halfway')
expect(jobs.read(jobId, parent.id).result).toContain('"ok": true')
const read = await executeToolFixture(ctx, {
signal: testToolSignal,
callId: ToolCallId('call-ptc-settled-read'),
name: 'job_output',
arguments: { job_id: jobId },
agent: parent,
})
expect((read.content[0] as { text: string }).text).toContain('"ok": true')
}, 15_000)
})
})
+6
View File
@@ -2150,6 +2150,9 @@ importers:
execa:
specifier: ^10.0.1
version: 10.0.1
hosted-git-info:
specifier: ^9.0.3
version: 9.0.3
semver:
specifier: ^7.8.5
version: 7.8.5
@@ -2220,6 +2223,9 @@ importers:
'@deepseek-ai/dsh-user-approval':
specifier: workspace:*
version: link:../../interaction/user-approval
'@types/hosted-git-info':
specifier: ^3.0.5
version: 3.0.5
'@types/semver':
specifier: ^7.8.0
version: 7.8.0
@@ -26,7 +26,7 @@
{"method":"session.event","params":{"sessionId":"{{session:1}}","event":{"type":"assistant/message","seq":27,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"STEER_HANDLED_PRODUCER_RUNNING"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:9}}"},"usage":{"inputTokens":10,"outputTokens":2},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["STEER_HANDLED_PRODUCER_RUNNING"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"STEER_HANDLED_PRODUCER_RUNNING"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}}}
{"method":"session.event","params":{"sessionId":"{{session:1}}","event":{"type":"step/end","seq":28,"time":0,"data":{"turn":1,"step":3}}}}
{"method":"session.event","params":{"sessionId":"{{session:1}}","event":{"type":"job/settled","seq":29,"time":0,"data":{"jobId":"{{id:1}}","owner":"{{session:1}}","kind":"bash","label":"echo 'producer ready'; sleep 600","startedAt":0,"toolCall":{"callId":"barrier-producer","rootCallId":"barrier-producer"},"finishedAt":0,"status":"killed","cause":"kill","producerReported":true,"detail":"signal: SIGTERM; barrier teardown"}}}}
{"method":"session.event","params":{"sessionId":"{{session:1}}","event":{"type":"agent/inbox/spliced","seq":30,"time":0,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"background job {{id:1}} (bash: echo 'producer ready'; sleep 600) finished [status: killed, signal: SIGTERM; barrier teardown]. Read its output with job_output."}],"source":{"kind":"tool-jobs","form":"notice","summary":"bash echo 'producer ready'; sleep 600 [status: killed, signal: SIGTERM; barrier teardown]"},"role":"user","id":"{{message:10}}"}]}}}}
{"method":"session.event","params":{"sessionId":"{{session:1}}","event":{"type":"agent/inbox/spliced","seq":30,"time":0,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"background job {{id:1}} (bash: echo 'producer ready'; sleep 600) finished [status: killed, signal: SIGTERM; barrier teardown]. No new output."}],"source":{"kind":"tool-jobs","form":"notice","summary":"bash echo 'producer ready'; sleep 600 [status: killed, signal: SIGTERM; barrier teardown]"},"role":"user","id":"{{message:10}}"}]}}}}
{"method":"session.event","params":{"sessionId":"{{session:1}}","event":{"type":"agent/inbox/spliced","seq":31,"time":0,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[],"outcome":"canceled"}}}}
{"method":"session.event","params":{"sessionId":"{{session:1}}","event":{"type":"turn/end","seq":32,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}}
{"method":"session.status","params":{"sessionId":"{{session:1}}","status":"idle"}}
@@ -29,6 +29,6 @@
{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"STEER_HANDLED_PRODUCER_RUNNING"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:9}}"},"usage":{"inputTokens":10,"outputTokens":2},"stream":[{"type":"chunk","time":1792486876135,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1792486876135,"index":0,"dt":[],"texts":["STEER_HANDLED_PRODUCER_RUNNING"]},{"type":"chunk","time":1792486876135,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"STEER_HANDLED_PRODUCER_RUNNING"}}},{"type":"chunk","time":1792486876135,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}},{"type":"chunk","time":1792486876135,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":3}}
{"type":"job/settled","data":{"jobId":"{{id:1}}","owner":"{{session:1}}","kind":"bash","label":"echo 'producer ready'; sleep 600","startedAt":1792486875966,"toolCall":{"callId":"barrier-producer","rootCallId":"barrier-producer"},"finishedAt":1792486876136,"status":"killed","cause":"kill","producerReported":true,"detail":"signal: SIGTERM; barrier teardown"}}
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"background job {{id:1}} (bash: echo 'producer ready'; sleep 600) finished [status: killed, signal: SIGTERM; barrier teardown]. Read its output with job_output."}],"source":{"kind":"tool-jobs","form":"notice","summary":"bash echo 'producer ready'; sleep 600 [status: killed, signal: SIGTERM; barrier teardown]"},"role":"user","id":"{{message:10}}"}]}}
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"background job {{id:1}} (bash: echo 'producer ready'; sleep 600) finished [status: killed, signal: SIGTERM; barrier teardown]. No new output."}],"source":{"kind":"tool-jobs","form":"notice","summary":"bash echo 'producer ready'; sleep 600 [status: killed, signal: SIGTERM; barrier teardown]"},"role":"user","id":"{{message:10}}"}]}}
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[],"outcome":"canceled"}}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
@@ -22,13 +22,13 @@
{"type":"step/end","data":{"turn":1,"step":2}}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Agent {{session:2}} sent a message: "},{"type":"text","text":"CHILD_MESSAGE_OK"}],"source":{"kind":"agent-message","form":"relay","senderSessionId":"{{session:2}}"},"role":"user","id":"{{message:7}}"}]}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{session:2}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"Message sent."}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{session:2}} finished and will do no further work unless you send it more.","senderSessionId":"{{session:2}}"},"role":"user","id":"{{message:8}}"}]}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{session:2}} finished and will do no further work unless you send it more."},{"type":"text","text":"It sent you a message just before finishing, so its closing message is omitted."}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{session:2}} finished and will do no further work unless you send it more.","senderSessionId":"{{session:2}}"},"role":"user","id":"{{message:8}}"}]}}
{"type":"turn/start","data":{"turn":2}}
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":2,"step":1}}
{"type":"user/message","data":{"content":[{"type":"text","text":"Agent {{session:2}} sent a message: "},{"type":"text","text":"CHILD_MESSAGE_OK"}],"source":{"kind":"agent-message","form":"relay","senderSessionId":"{{session:2}}"},"role":"user","id":"{{message:7}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Background subagent {{session:2}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"Message sent."}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{session:2}} finished and will do no further work unless you send it more.","senderSessionId":"{{session:2}}"},"role":"user","id":"{{message:8}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Background subagent {{session:2}} finished and will do no further work unless you send it more."},{"type":"text","text":"It sent you a message just before finishing, so its closing message is omitted."}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{session:2}} finished and will do no further work unless you send it more.","senderSessionId":"{{session:2}}"},"role":"user","id":"{{message:8}}"},"surfaceOp":"append"}
{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SUBAGENT_SETTLED_NOTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:9}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1788880390979,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1788880390979,"index":0,"dt":[],"texts":["SUBAGENT_SETTLED_NOTED"]},{"type":"chunk","time":1788880390979,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SUBAGENT_SETTLED_NOTED"}}},{"type":"chunk","time":1788880390979,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1788880390979,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":2,"step":1}}
{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}}
@@ -16,12 +16,12 @@
{"type":"tool/call","data":{"turn":1,"step":1,"callId":"async-confinement-start","name":"bash","arguments":"{\"command\":\"printf SHOULD_NOT_RUN > unexpected-command.txt\",\"description\":\"Start a background command under confinement\",\"run_in_background\":true}"}}
{"type":"job/registered","data":{"jobId":"{{id:1}}","owner":"{{session:1}}","kind":"bash","label":"printf SHOULD_NOT_RUN > unexpected-command.txt","startedAt":1791434082502,"toolCall":{"callId":"async-confinement-start","rootCallId":"async-confinement-start"}}}
{"type":"job/settled","data":{"jobId":"{{id:1}}","owner":"{{session:1}}","kind":"bash","label":"printf SHOULD_NOT_RUN > unexpected-command.txt","startedAt":1791434082502,"toolCall":{"callId":"async-confinement-start","rootCallId":"async-confinement-start"},"finishedAt":1791434082504,"status":"failed","cause":"producer","producerReported":true,"detail":"sandbox mode \"read-only\" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS), or ensure the ACL restricted-token runner can start (Windows) — otherwise switch the consumer to danger-full-access. Runner failure: fixture asynchronous confinement refused"}}
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"background job {{id:1}} (bash: printf SHOULD_NOT_RUN > unexpected-command.txt) finished [status: failed, sandbox mode \"read-only\" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS), or ensure the ACL restricted-token runner can start (Windows) — otherwise switch the consumer to danger-full-access. Runner failure: fixture asynchronous confinement refused]. Read its output with job_output."}],"source":{"kind":"tool-jobs","form":"notice","summary":"bash printf SHOULD_NOT_RUN > unexpected-command.txt [status: failed, sandbox mode \"read-only\" is requested but no sandb…"},"role":"user","id":"{{message:5}}"}]}}
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"background job {{id:1}} (bash: printf SHOULD_NOT_RUN > unexpected-command.txt) finished [status: failed, sandbox mode \"read-only\" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS), or ensure the ACL restricted-token runner can start (Windows) — otherwise switch the consumer to danger-full-access. Runner failure: fixture asynchronous confinement refused]. No new output."}],"source":{"kind":"tool-jobs","form":"notice","summary":"bash printf SHOULD_NOT_RUN > unexpected-command.txt [status: failed, sandbox mode \"read-only\" is requested but no sandb…"},"role":"user","id":"{{message:5}}"}]}}
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"async-confinement-start"},"toolCallId":"async-confinement-start","content":[{"type":"text","text":"started background job {{id:1}}"}],"isError":false,"id":"{{message:6}}"},"meta":{"cwd":"{{cwd}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":1,"step":2}}
{"type":"user/message","data":{"content":[{"type":"text","text":"background job {{id:1}} (bash: printf SHOULD_NOT_RUN > unexpected-command.txt) finished [status: failed, sandbox mode \"read-only\" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS), or ensure the ACL restricted-token runner can start (Windows) — otherwise switch the consumer to danger-full-access. Runner failure: fixture asynchronous confinement refused]. Read its output with job_output."}],"source":{"kind":"tool-jobs","form":"notice","summary":"bash printf SHOULD_NOT_RUN > unexpected-command.txt [status: failed, sandbox mode \"read-only\" is requested but no sandb…"},"role":"user","id":"{{message:5}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"background job {{id:1}} (bash: printf SHOULD_NOT_RUN > unexpected-command.txt) finished [status: failed, sandbox mode \"read-only\" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS), or ensure the ACL restricted-token runner can start (Windows) — otherwise switch the consumer to danger-full-access. Runner failure: fixture asynchronous confinement refused]. No new output."}],"source":{"kind":"tool-jobs","form":"notice","summary":"bash printf SHOULD_NOT_RUN > unexpected-command.txt [status: failed, sandbox mode \"read-only\" is requested but no sandb…"},"role":"user","id":"{{message:5}}"},"surfaceOp":"append"}
{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"async-confinement-result","name":"job_output","arguments":"{\"job_id\":\"{{id:1}}\",\"wait\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1791434082516,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1791434082516,"index":0,"dt":[],"id":"async-confinement-result","name":"job_output","args":["{\"job_id\":\"{{id:1}}\",\"wait\":true}"]},{"type":"chunk","time":1791434082516,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"async-confinement-result","name":"job_output","arguments":"{\"job_id\":\"{{id:1}}\",\"wait\":true}"}}},{"type":"chunk","time":1791434082516,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1791434082516,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
{"type":"tool/call","data":{"turn":1,"step":2,"callId":"async-confinement-result","name":"job_output","arguments":"{\"job_id\":\"{{id:1}}\",\"wait\":true}"}}
{"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"async-confinement-result"},"toolCallId":"async-confinement-result","content":[{"type":"text","text":"(no new output)\n[status: failed, sandbox mode \"read-only\" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS), or ensure the ACL restricted-token runner can start (Windows) — otherwise switch the consumer to danger-full-access. Runner failure: fixture asynchronous confinement refused]"}],"isError":false,"id":"{{message:8}}"}},"sourceEventSeqs":[24],"surfaceOp":"append"}
@@ -0,0 +1,118 @@
[
{
"kind": "chunks",
"chunks": [
{
"type": "block-start",
"index": 0,
"blockType": "tool-call"
},
{
"type": "tool-call-delta",
"index": 0,
"id": "notice-start",
"name": "bash",
"argumentsDelta": "{\"command\": \"while [ ! -e .notice-go ]; do sleep 0.05; done; printf 'NOTICE_OUTPUT\\\\n'\", \"description\": \"Start a short background command\", \"run_in_background\": true}"
},
{
"type": "block-end",
"index": 0,
"block": {
"type": "tool-call",
"id": "notice-start",
"name": "bash",
"arguments": "{\"command\": \"while [ ! -e .notice-go ]; do sleep 0.05; done; printf 'NOTICE_OUTPUT\\\\n'\", \"description\": \"Start a short background command\", \"run_in_background\": true}"
}
},
{
"type": "usage",
"usage": {
"inputTokens": 10,
"outputTokens": 5
}
},
{
"type": "finish",
"reason": {
"kind": "tool-calls"
}
}
]
},
{
"kind": "chunks",
"chunks": [
{
"type": "block-start",
"index": 0,
"blockType": "tool-call"
},
{
"type": "tool-call-delta",
"index": 0,
"id": "notice-wait",
"name": "bash",
"argumentsDelta": "{\"command\": \"touch .notice-go; sleep 1; rm -f .notice-go\", \"description\": \"Release the background job and keep working\"}"
},
{
"type": "block-end",
"index": 0,
"block": {
"type": "tool-call",
"id": "notice-wait",
"name": "bash",
"arguments": "{\"command\": \"touch .notice-go; sleep 1; rm -f .notice-go\", \"description\": \"Release the background job and keep working\"}"
}
},
{
"type": "usage",
"usage": {
"inputTokens": 10,
"outputTokens": 5
}
},
{
"type": "finish",
"reason": {
"kind": "tool-calls"
}
}
]
},
{
"kind": "chunks",
"chunks": [
{
"type": "block-start",
"index": 0,
"blockType": "text"
},
{
"type": "text-delta",
"index": 0,
"text": "NOTICE_OUTPUT"
},
{
"type": "block-end",
"index": 0,
"block": {
"type": "text",
"text": "NOTICE_OUTPUT"
}
},
{
"type": "usage",
"usage": {
"inputTokens": 10,
"outputTokens": 2
}
},
{
"type": "finish",
"reason": {
"kind": "stop"
}
}
]
}
]
@@ -0,0 +1,34 @@
{"type":"session","version":4,"id":"{{session:1}}","createdAt":1783352050748,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
{"type":"permission/preset","data":{"preset":"danger-full-access"}}
{"type":"sandbox/mode","data":{"mode":"danger-full-access"}}
{"type":"approval/policy","data":{"policy":"never"}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Start a background bash job that waits for .notice-go and then prints NOTICE_OUTPUT, then create .notice-go in the foreground and keep working briefly. When the job's completion notice arrives, reply with exactly the output it reported and stop."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}}
{"type":"turn/start","data":{"turn":1}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":1,"step":1}}
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Start a background bash job that waits for .notice-go and then prints NOTICE_OUTPUT, then create .notice-go in the foreground and keep working briefly. When the job's completion notice arrives, reply with exactly the output it reported and stop."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"session/title","data":{"title":"Start a background bash job","messageSeqs":[8],"source":{"kind":"fallback"}}}
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"notice-start","name":"bash","arguments":"{\"command\": \"while [ ! -e .notice-go ]; do sleep 0.05; done; printf 'NOTICE_OUTPUT\\\\n'\", \"description\": \"Start a short background command\", \"run_in_background\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1791807726901,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1791807726901,"index":0,"dt":[],"id":"notice-start","name":"bash","args":["{\"command\": \"while [ ! -e .notice-go ]; do sleep 0.05; done; printf 'NOTICE_OUTPUT\\\\n'\", \"description\": \"Start a short background command\", \"run_in_background\": true}"]},{"type":"chunk","time":1791807726901,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"notice-start","name":"bash","arguments":"{\"command\": \"while [ ! -e .notice-go ]; do sleep 0.05; done; printf 'NOTICE_OUTPUT\\\\n'\", \"description\": \"Start a short background command\", \"run_in_background\": true}"}}},{"type":"chunk","time":1791807726901,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1791807726901,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
{"type":"tool/call","data":{"turn":1,"step":1,"callId":"notice-start","name":"bash","arguments":"{\"command\": \"while [ ! -e .notice-go ]; do sleep 0.05; done; printf 'NOTICE_OUTPUT\\\\n'\", \"description\": \"Start a short background command\", \"run_in_background\": true}"}}
{"type":"job/registered","data":{"jobId":"{{id:1}}","owner":"{{session:1}}","kind":"bash","label":"while [ ! -e .notice-go ]; do sleep 0.05; done; printf 'NOTICE_OUTPUT\\n'","startedAt":1791807726914,"toolCall":{"callId":"notice-start","rootCallId":"notice-start"}}}
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"notice-start"},"toolCallId":"notice-start","content":[{"type":"text","text":"started background job {{id:1}}"}],"isError":false,"id":"{{message:5}}"},"meta":{"cwd":"{{cwd}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"step/start","data":{"turn":1,"step":2}}
{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"notice-wait","name":"bash","arguments":"{\"command\": \"touch .notice-go; sleep 1; rm -f .notice-go\", \"description\": \"Release the background job and keep working\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1791807726930,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1791807726930,"index":0,"dt":[],"id":"notice-wait","name":"bash","args":["{\"command\": \"touch .notice-go; sleep 1; rm -f .notice-go\", \"description\": \"Release the background job and keep working\"}"]},{"type":"chunk","time":1791807726930,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"notice-wait","name":"bash","arguments":"{\"command\": \"touch .notice-go; sleep 1; rm -f .notice-go\", \"description\": \"Release the background job and keep working\"}"}}},{"type":"chunk","time":1791807726930,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1791807726930,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
{"type":"tool/call","data":{"turn":1,"step":2,"callId":"notice-wait","name":"bash","arguments":"{\"command\": \"touch .notice-go; sleep 1; rm -f .notice-go\", \"description\": \"Release the background job and keep working\"}"}}
{"type":"job/registered","data":{"jobId":"{{id:2}}","owner":"{{session:1}}","kind":"bash","label":"touch .notice-go; sleep 1; rm -f .notice-go","startedAt":1791807726938,"toolCall":{"callId":"notice-wait","rootCallId":"notice-wait"}}}
{"type":"job/settled","data":{"jobId":"{{id:1}}","owner":"{{session:1}}","kind":"bash","label":"while [ ! -e .notice-go ]; do sleep 0.05; done; printf 'NOTICE_OUTPUT\\n'","startedAt":1791807726914,"toolCall":{"callId":"notice-start","rootCallId":"notice-start"},"finishedAt":1791807727923,"status":"completed","cause":"producer","producerReported":true,"detail":"exit code: 0"}}
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"background job {{id:1}} (bash: while [ ! -e .notice-go ]; do sleep 0.05; done; printf 'NOTICE_OUTPUT\\n') finished [status: completed, exit code: 0]. Output:\nNOTICE_OUTPUT"}],"source":{"kind":"tool-jobs","form":"notice","summary":"bash while [ ! -e .notice-go ]; do sleep 0.05; done; printf 'NOTICE_OUTPUT\\n' [status: completed, exit code: 0]"},"role":"user","id":"{{message:7}}"}]}}
{"type":"job/settled","data":{"jobId":"{{id:2}}","owner":"{{session:1}}","kind":"bash","label":"touch .notice-go; sleep 1; rm -f .notice-go","startedAt":1791807726938,"toolCall":{"callId":"notice-wait","rootCallId":"notice-wait"},"finishedAt":1791807729950,"status":"completed","cause":"producer","producerReported":true,"detail":"exit code: 0"}}
{"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"notice-wait"},"toolCallId":"notice-wait","content":[{"type":"text","text":"(no output)"}],"isError":false,"id":"{{message:8}}"},"meta":{"cwd":"{{cwd}}"}},"sourceEventSeqs":[20],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":2}}
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":1,"step":3}}
{"type":"user/message","data":{"content":[{"type":"text","text":"background job {{id:1}} (bash: while [ ! -e .notice-go ]; do sleep 0.05; done; printf 'NOTICE_OUTPUT\\n') finished [status: completed, exit code: 0]. Output:\nNOTICE_OUTPUT"}],"source":{"kind":"tool-jobs","form":"notice","summary":"bash while [ ! -e .notice-go ]; do sleep 0.05; done; printf 'NOTICE_OUTPUT\\n' [status: completed, exit code: 0]"},"role":"user","id":"{{message:7}}"},"surfaceOp":"append"}
{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"NOTICE_OUTPUT"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:9}}"},"usage":{"inputTokens":10,"outputTokens":2},"stream":[{"type":"chunk","time":1791807729968,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1791807729968,"index":0,"dt":[],"texts":["NOTICE_OUTPUT"]},{"type":"chunk","time":1791807729968,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"NOTICE_OUTPUT"}}},{"type":"chunk","time":1791807729968,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}},{"type":"chunk","time":1791807729968,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":3}}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
@@ -0,0 +1,10 @@
version: 1
scenario: background-job-notice
profile: headless
composition: default
recording: authored
header:
class: default
replay:
override: true
platform: posix
@@ -15,7 +15,7 @@
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bash-startup-timeout","name":"bash","arguments":"{\"command\":\"sleep 30\",\"timeoutMs\":1,\"description\":\"Cancel a command during startup\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":1,"outputTokens":1},"stream":[{"type":"chunk","time":1791526663346,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791526663346,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bash-startup-timeout","name":"bash","arguments":"{\"command\":\"sleep 30\",\"timeoutMs\":1,\"description\":\"Cancel a command during startup\"}"}}},{"type":"chunk","time":1791526663346,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}},{"type":"chunk","time":1791526663346,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
{"type":"tool/call","data":{"turn":1,"step":1,"callId":"bash-startup-timeout","name":"bash","arguments":"{\"command\":\"sleep 30\",\"timeoutMs\":1,\"description\":\"Cancel a command during startup\"}"}}
{"type":"job/registered","data":{"jobId":"{{id:1}}","owner":"{{session:1}}","kind":"bash","label":"sleep 30","startedAt":1791526663354,"toolCall":{"callId":"bash-startup-timeout","rootCallId":"bash-startup-timeout"}}}
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"bash-startup-timeout"},"toolCallId":"bash-startup-timeout","content":[{"type":"text","text":"[still running after 1ms; moved to background job {{id:1}}]\nThe command keeps running in the background. You will be notified when it finishes; read newer output with job_output, stop it with job_kill."}],"isError":false,"id":"{{message:5}}"},"meta":{"cwd":"{{cwd}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"bash-startup-timeout"},"toolCallId":"bash-startup-timeout","content":[{"type":"text","text":"[still running after 1ms; moved to background job {{id:1}}]"}],"isError":false,"id":"{{message:5}}"},"meta":{"cwd":"{{cwd}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"step/start","data":{"turn":1,"step":2}}
{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":1,"outputTokens":1},"stream":[{"type":"chunk","time":1791526663368,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1791526663368,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1791526663368,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}},{"type":"chunk","time":1791526663368,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
+1
View File
@@ -79,6 +79,7 @@
"apps/web/tests/plugin-config.e2e.ts",
"apps/web/tests/input-plugin-extensions.e2e.ts",
"apps/web/tests/plugin-manager.e2e.ts",
"apps/web/tests/plugin-sticky-motion.e2e.ts",
"apps/web/tests/entry-customization.e2e.ts",
"apps/web/tests/cua-optin.e2e.ts",
"apps/web/tests/cua-permissions.e2e.ts",