diff --git a/packages/client/ui-sidebar-documentpreview/tests/code-body.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/code-body.client.spec.tsx
index c2b3f0daae..ce8e6b548f 100644
--- a/packages/client/ui-sidebar-documentpreview/tests/code-body.client.spec.tsx
+++ b/packages/client/ui-sidebar-documentpreview/tests/code-body.client.spec.tsx
@@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
-import { cleanup, render, waitFor } from '@testing-library/react'
+import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { absoluteFileAddress, sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
import { CodeBody } from '../src/client/code/CodeBody.tsx'
@@ -84,7 +84,11 @@ describe('CodeBody', () => {
expect(view.getByText(language, { exact: true })).toBeTruthy()
expect(element(view.container, '.shiki').textContent).toBe('const answer = 42')
expect(view.container.querySelector('[data-line-numbers]')).not.toBeNull()
- expect(view.getByRole('button', { name: 'Copy' })).toBeTruthy()
+ const copy = view.getByRole('button', { name: 'Copy' })
+ expect(copy.textContent).toBe('')
+ expect(view.getAllByRole('button')).toHaveLength(1)
+ fireEvent.mouseEnter(copy)
+ expect(view.getByRole('tooltip').textContent).toBe('Copy')
})
it('carries multiline grammar state across pages and preserves completed lines through settlement', () => {
@@ -155,6 +159,7 @@ describe('CodeBody', () => {
const view = render(
)
const pre = element(view.container, '.shiki')
const block = element(view.container, '.md-code-block')
+ expect(block.getAttribute('data-code-wrap')).toBe('false')
const scrollport = element(view.container, '[data-code-block-content]')
expect(getComputedStyle(block).marginTop).toBe('0px')
expect(getComputedStyle(block).position).toBe('static')
@@ -165,6 +170,8 @@ describe('CodeBody', () => {
expect(getComputedStyle(pre).overflow).toBe('visible')
expect(getComputedStyle(pre).wordBreak).toBe('normal')
view.rerender(
)
+ expect(block.getAttribute('data-code-wrap')).toBe('true')
+ expect(view.getAllByRole('button')).toHaveLength(1)
expect(element(view.container, '.shiki')).toBe(pre)
expect(element(view.container, '[data-code-block-content]')).toBe(scrollport)
expect(getComputedStyle(pre).whiteSpace).toBe('pre-wrap')
diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-body.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/excel-body.client.spec.tsx
index 4bd79963d0..17367b3fa2 100644
--- a/packages/client/ui-sidebar-documentpreview/tests/excel-body.client.spec.tsx
+++ b/packages/client/ui-sidebar-documentpreview/tests/excel-body.client.spec.tsx
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
/** Excel preview lifecycle and read-only renderer settings. */
-import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { Config } from '../src/config.ts'
@@ -17,7 +17,31 @@ const props = { content: { kind: 'bytes', data: new Uint8Array([1]) }, limits: C
const loadedProps = { ...props, format: 'xlsx' } as LoadedExcelBodyProps
const value = { sheets: [{ name: 'Budget', celldata: [] }], missingResults: 0, unsupportedFeatures: [] }
const formulaValue = { sheets: [{ name: 'Budget', celldata: [{ r: 0, c: 0, v: { f: '=SUM(1,2)', m: '' } }] }], missingResults: 1, unsupportedFeatures: [] }
-afterEach(() => { cleanup(); vi.resetAllMocks() })
+afterEach(() => { cleanup(); vi.resetAllMocks(); vi.unstubAllGlobals(); vi.restoreAllMocks() })
+
+it('refreshes the existing workbook on pane resize and disconnects on file replacement and unmount', async () => {
+ const observers: { callback: ResizeObserverCallback; observe: ReturnType
; disconnect: ReturnType }[] = []
+ vi.stubGlobal('ResizeObserver', class {
+ observe = vi.fn()
+ disconnect = vi.fn()
+ constructor(callback: ResizeObserverCallback) { observers.push({ callback, observe: this.observe, disconnect: this.disconnect }) }
+ })
+ const dispatch = vi.spyOn(window, 'dispatchEvent')
+ mocked.parse.mockResolvedValue(value)
+ const view = render()
+ await waitFor(() => { expect(observers).toHaveLength(1) })
+ const first = observers[0]!
+ expect(first.observe).toHaveBeenCalledWith(view.container.querySelector('[data-excel-preview] > div'))
+ act(() => { first.callback([], {} as ResizeObserver) })
+ expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ type: 'resize' }))
+ expect(mocked.workbook).toHaveBeenCalledOnce()
+ expect(mocked.parse).toHaveBeenCalledOnce()
+ view.rerender()
+ await waitFor(() => { expect(observers).toHaveLength(2) })
+ expect(first.disconnect).toHaveBeenCalledOnce()
+ view.unmount()
+ expect(observers[1]!.disconnect).toHaveBeenCalledOnce()
+})
it('shows loading then a workbook with editing and recalculation disabled', async () => {
mocked.parse.mockResolvedValue(formulaValue)
diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-opc-fixture.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-opc-fixture.ts
new file mode 100644
index 0000000000..d54869680d
--- /dev/null
+++ b/packages/client/ui-sidebar-documentpreview/tests/excel-opc-fixture.ts
@@ -0,0 +1,14 @@
+/** Independent-writer XLSX files shared by parser and shipped-browser regressions. */
+import { readFile } from 'node:fs/promises'
+
+/** Five isolated failures and one workbook combining their features. */
+export const excelOpcCases = ['comments', 'table', 'prefix', 'part-name', 'relative', 'combined'] as const
+
+/**
+ * Read an Openpyxl or XlsxWriter fixture without regenerating it with the parser under test.
+ * @param name - Retained fixture name.
+ * @returns Complete XLSX bytes owned by the caller.
+ */
+export async function excelOpcFixture(name: typeof excelOpcCases[number]): Promise> {
+ return new Uint8Array(await readFile(new URL(`./fixtures/excel-opc/${name}.xlsx`, import.meta.url)))
+}
diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-opc.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-opc.client.spec.ts
new file mode 100644
index 0000000000..476bb4bac9
--- /dev/null
+++ b/packages/client/ui-sidebar-documentpreview/tests/excel-opc.client.spec.ts
@@ -0,0 +1,100 @@
+/** OPC part resolution and namespace aliases preserve independent-writer workbook contents. */
+import { createRequire } from 'node:module'
+import ExcelJS from 'exceljs'
+import { strFromU8, strToU8, unzipSync, zipSync } from 'fflate'
+import { describe, expect, it } from 'vitest'
+import { Config } from '../src/config.ts'
+import { convertExcel } from '../src/client/excel/convert.ts'
+import { XlsxPreviewArchive } from '../src/client/excel/xlsx-archive.ts'
+import { excelOpcCases, excelOpcFixture } from './excel-opc-fixture.ts'
+import { excelFixture, excelHtmlFixture } from './excel-fixture.ts'
+
+const require = createRequire(import.meta.url)
+const browserExcel = require('exceljs/dist/exceljs.js') as typeof ExcelJS
+const limits = Config({}).excel
+const relsPath = 'xl/worksheets/_rels/budget.xml.rels'
+
+describe.each([['Node', ExcelJS], ['browser bundle', browserExcel]] as const)('%s ExcelJS entry', (_name, parser) => {
+ it.each(excelOpcCases)('opens %s without changing source bytes', async (name) => {
+ const source = await excelOpcFixture(name)
+ const before = source.slice()
+ const archive = new XlsxPreviewArchive(source)
+ const input = archive.withoutDrawings()
+ const workbook = new parser.Workbook()
+ await workbook.xlsx.load(input.buffer, { ignoreNodes: ['drawing'] })
+ const sheet = workbook.getWorksheet('数据')!
+ expect(sheet.getCell('A1').value).toBe('Item')
+ expect(sheet.getCell('B2').value).toBe(42)
+ if (name === 'comments' || name === 'combined') {
+ expect(sheet.getCell('B2').note).toBe('生成的批注')
+ }
+ if (name === 'table' || name === 'combined') {
+ expect(sheet.getTable('DataTable')).toMatchObject({ table: { name: 'DataTable', tableRef: 'A1:B3' } })
+ }
+ expect(source).toEqual(before)
+ })
+
+ it('binds comment VML independently of relationship order and ZIP entry order', async () => {
+ const source = await excelOpcFixture('combined')
+ const files = unzipSync(new XlsxPreviewArchive(source).withoutDrawings())
+ const comments = 'xl/comments/comment1.xml'
+ files[comments] = strToU8(strFromU8(files[comments]!).replace('', '').replace('', ''))
+ const vml = 'xl/drawings/commentsDrawing1.vml'
+ files[vml] = strToU8(strFromU8(files[vml]!).replace('insetmode="auto"', 'insetmode="custom"'))
+ const xml = strFromU8(files[relsPath]!)
+ const relations = xml.match(/]*\/>/g)!
+ files[relsPath] = strToU8(xml.replace(relations.join(''), [...relations].reverse().join('')))
+ const workbook = new parser.Workbook()
+ await workbook.xlsx.load(new Uint8Array(zipSync(Object.fromEntries(Object.entries(files).reverse()))).buffer, { ignoreNodes: ['drawing'] })
+ expect(workbook.getWorksheet('数据')!.getCell('B2').note).toMatchObject({
+ texts: [{ text: '生成的批注', font: { bold: true } }], margins: { insetmode: 'custom' },
+ })
+ })
+})
+
+it('retains styles, formulas, hidden sheets and notices in the combined workbook', async () => {
+ const source = await excelOpcFixture('combined')
+ const result = await convertExcel(source, 'xlsx', limits)
+ const sheet = result.sheets[0]!
+ expect(result.sheets.map(sheet => [sheet.name, sheet.hide])).toEqual([['数据', 0], ['隐藏页', 1]])
+ expect(sheet.celldata!.find(cell => cell.r === 1 && cell.c === 1)!.v).toMatchObject({ v: 42, bl: 1, fc: '#112233', bg: '#FFFF00' })
+ expect(sheet.celldata!.find(cell => cell.r === 4 && cell.c === 0)!.v?.v).toBe(' & 中文😀\n tail ')
+ expect(sheet.celldata!.find(cell => cell.r === 5 && cell.c === 0)!.v?.f).toBe('=SUM(B2:B3)')
+ expect(sheet.config).toMatchObject({ merge: { '29_7': { r: 29, c: 7, rs: 1, cs: 3 } }, rowhidden: { 2: 0 }, colhidden: { 1: 0 } })
+ expect(sheet.frozen).toMatchObject({ type: 'rangeBoth', range: { row_focus: 0, column_focus: 0 } })
+ expect(result.unsupportedFeatures).toEqual(['charts', 'conditionalFormatting'])
+ const workbook = new ExcelJS.Workbook()
+ await workbook.xlsx.load(new XlsxPreviewArchive(source).withoutDrawings().buffer, { ignoreNodes: ['drawing'] })
+ expect(workbook.getWorksheet('数据')!.getCell('A2').hyperlink).toBe('https://example.com/?a=1&b=2')
+})
+
+it.each([excelFixture, excelHtmlFixture])('preserves cells and attributes under equivalent namespace aliases', async (fixture) => {
+ const source = await fixture()
+ const files = unzipSync(source)
+ for (const [path, bytes] of Object.entries(files)) {
+ if (!/^xl\/worksheets\/[^/]+\.xml$/.test(path)) continue
+ files[path] = strToU8(strFromU8(bytes)
+ .replace('xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"', 'xmlns:s="http://schemas.openxmlformats.org/spreadsheetml/2006/main"')
+ .replace(/<(\/?)([A-Za-z_][\w.-]*)(?=[\s/>])/g, '<$1s:$2')
+ .replaceAll('xmlns:r=', 'xmlns:link=').replaceAll(' r:id=', ' link:id='))
+ }
+ expect(await convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).toEqual(await convertExcel(source, 'xlsx', limits))
+})
+
+it.each(['part', 'relationship', 'escape', 'external'] as const)('rejects a missing or invalid worksheet %s instead of dropping the sheet', async (defect) => {
+ const files = unzipSync(await excelFixture())
+ if (defect === 'part') delete files['xl/worksheets/sheet2.xml']
+ else {
+ const path = 'xl/_rels/workbook.xml.rels'
+ const xml = strFromU8(files[path]!)
+ files[path] = strToU8(defect === 'relationship' ? xml.replace(/]*Target="worksheets\/sheet2.xml"[^>]*\/>/, '')
+ : xml.replace('Target="worksheets/sheet2.xml"', defect === 'escape' ? 'Target="../../sheet2.xml"' : 'Target="https://example.com/sheet2.xml" TargetMode="External"'))
+ }
+ await expect(convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).rejects.toMatchObject({ code: 'invalid' })
+})
+
+it.each(['xl/comments/comment1.xml', 'xl/tables/table1.xml', 'xl/drawings/commentsDrawing1.vml'])('rejects missing referenced metadata %s', async (path) => {
+ const files = unzipSync(await excelOpcFixture('combined'))
+ Reflect.deleteProperty(files, path)
+ await expect(convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).rejects.toMatchObject({ code: 'invalid' })
+})
diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-opening-fixture.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-opening-fixture.ts
new file mode 100644
index 0000000000..e9a4953ff3
--- /dev/null
+++ b/packages/client/ui-sidebar-documentpreview/tests/excel-opening-fixture.ts
@@ -0,0 +1,36 @@
+/** Opening regressions derived from the independent-writer combined workbook. */
+import { strFromU8, strToU8, unzipSync, zipSync } from 'fflate'
+import { excelOpcFixture } from './excel-opc-fixture.ts'
+
+/** Supported URI aliases, ASCII case-equivalent part names and orphan DrawingML. */
+export const excelOpeningCases = ['strict', 'case-root', 'case-parts', 'orphan-drawing'] as const
+
+/**
+ * Change only the package property under test in an independent-writer workbook.
+ * @param name - Opening regression to construct.
+ * @returns Complete XLSX bytes owned by the caller.
+ */
+export async function excelOpeningFixture(name: typeof excelOpeningCases[number]): Promise> {
+ const files = unzipSync(await excelOpcFixture('combined'))
+ const result: Record = {}
+ for (const [path, bytes] of Object.entries(files)) {
+ let xml = /\.(xml|rels|vml)$/.test(path) ? strFromU8(bytes) : undefined
+ let target = path
+ if (xml !== undefined) {
+ if (name === 'strict') {
+ xml = xml.replaceAll('http://schemas.openxmlformats.org/spreadsheetml/2006/main', 'http://purl.oclc.org/ooxml/spreadsheetml/main')
+ .replaceAll('http://schemas.openxmlformats.org/officeDocument/2006/relationships', 'http://purl.oclc.org/ooxml/officeDocument/relationships')
+ } else if (name === 'case-root' && path === '_rels/.rels') {
+ xml = xml.replace('Target="xl/workbook.xml"', 'Target="XL/WORKBOOK.XML"')
+ } else if (name === 'case-parts') {
+ if (!path.startsWith('docProps/') && !path.startsWith('xl/theme/')) target = path.toUpperCase()
+ if (path.endsWith('.rels')) xml = xml.replace(/Target="([^"]+)"/g, (attribute, value: string) => value.includes('://') ? attribute : `Target="${value.toLowerCase()}"`)
+ } else if (name === 'orphan-drawing') {
+ if (path.endsWith('.rels')) xml = xml.replace(/]*Type="[^"]*\/drawing"[^>]*\/>/g, '')
+ if (path.startsWith('xl/worksheets/')) xml = xml.replace(/<(?:\w+:)?drawing\b[^>]*\/>/g, '')
+ }
+ }
+ result[target] = xml === undefined ? bytes : strToU8(xml)
+ }
+ return new Uint8Array(zipSync(result))
+}
diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-opening.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-opening.client.spec.ts
new file mode 100644
index 0000000000..e25c12e936
--- /dev/null
+++ b/packages/client/ui-sidebar-documentpreview/tests/excel-opening.client.spec.ts
@@ -0,0 +1,85 @@
+/** URI and part-name aliases and orphan drawings preserve usable workbook previews. */
+import assert from 'node:assert/strict'
+import { createRequire } from 'node:module'
+import ExcelJS from 'exceljs'
+import { strFromU8, strToU8, unzipSync, zipSync } from 'fflate'
+import { describe, expect, it } from 'vitest'
+import { Config } from '../src/config.ts'
+import { convertExcel } from '../src/client/excel/convert.ts'
+import { XlsxPreviewArchive } from '../src/client/excel/xlsx-archive.ts'
+import { excelOpeningCases, excelOpeningFixture } from './excel-opening-fixture.ts'
+import { excelOpcFixture } from './excel-opc-fixture.ts'
+
+const require = createRequire(import.meta.url)
+const browserExcel = require('exceljs/dist/exceljs.js') as typeof ExcelJS
+const limits = Config({}).excel
+
+describe.each([['Node', ExcelJS], ['browser bundle', browserExcel]] as const)('%s opening regressions', (_name, parser) => {
+ it.each(excelOpeningCases)('opens %s with cells, styles, comments, Tables and hyperlinks', async (name) => {
+ const source = await excelOpeningFixture(name)
+ const original = source.slice()
+ const retained = new XlsxPreviewArchive(source).withoutDrawings()
+ const workbook = new parser.Workbook()
+ await workbook.xlsx.load(retained.buffer, { ignoreNodes: ['drawing'] })
+ const sheet = workbook.getWorksheet('数据')
+ assert.ok(sheet)
+ expect(sheet.getCell('A1').value).toBe('Item')
+ expect(sheet.getCell('B2').value).toBe(42)
+ expect(sheet.getCell('B2').font).toMatchObject({ bold: true, color: { argb: 'FF112233' } })
+ expect(sheet.getCell('B2').note).toBe('生成的批注')
+ expect(sheet.getTable('DataTable')).toMatchObject({ table: { tableRef: 'A1:B3' } })
+ expect(sheet.getCell('A2').hyperlink).toBe('https://example.com/?a=1&b=2')
+ expect(workbook.getWorksheet('隐藏页')?.state).toBe('hidden')
+ const originalParts = unzipSync(source)
+ for (const [path, bytes] of Object.entries(unzipSync(retained))) expect(bytes, path).toEqual(originalParts[path])
+ expect(source).toEqual(original)
+ })
+
+ it.each(['xl/workbook.xml', '_rels/.rels'])('rejects ambiguous ASCII case-equivalent entries for %s', async (path) => {
+ const files = unzipSync(await excelOpcFixture('combined'))
+ const bytes = files[path]
+ assert.ok(bytes)
+ files[path.toUpperCase()] = bytes
+ const source = new Uint8Array(zipSync(files))
+ await expect(new parser.Workbook().xlsx.load(source.buffer)).rejects.toThrow(/Ambiguous.*part/i)
+ })
+})
+
+it.each(excelOpeningCases)('preserves the complete preview and referenced-content notices for %s', async (name) => {
+ const expected = await convertExcel(await excelOpcFixture('combined'), 'xlsx', limits)
+ if (name === 'orphan-drawing') expected.unsupportedFeatures = ['conditionalFormatting']
+ expect(await convertExcel(await excelOpeningFixture(name), 'xlsx', limits)).toEqual(expected)
+})
+
+it('omits orphan DrawingML and its relationships while retaining comment VML', async () => {
+ const source = await excelOpeningFixture('orphan-drawing')
+ const original = unzipSync(source)
+ expect(original['xl/drawings/drawing1.xml']).toBeDefined()
+ expect(original['xl/drawings/_rels/drawing1.xml.rels']).toBeDefined()
+ const archive = new XlsxPreviewArchive(source)
+ const retained = unzipSync(archive.withoutDrawings())
+ expect(retained['xl/drawings/drawing1.xml']).toBeUndefined()
+ expect(retained['xl/drawings/_rels/drawing1.xml.rels']).toBeUndefined()
+ expect(retained['xl/drawings/commentsDrawing1.vml']).toEqual(original['xl/drawings/commentsDrawing1.vml'])
+ expect([...archive.unsupportedFeatures]).toEqual(['conditionalFormatting'])
+})
+
+it('rejects ambiguous part names before filtering can hide either entry', async () => {
+ const files = unzipSync(await excelOpcFixture('combined'))
+ const drawing = files['xl/drawings/drawing1.xml']
+ assert.ok(drawing)
+ files['XL/DRAWINGS/DRAWING1.XML'] = drawing
+ await expect(convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).rejects.toMatchObject({ code: 'invalid' })
+})
+
+it('does not fold non-ASCII characters when resolving part names', async () => {
+ const files = unzipSync(await excelOpcFixture('combined'))
+ const path = 'xl/_rels/workbook.xml.rels'
+ const rels = files[path]
+ const sheet = files['xl/worksheets/budget.xml']
+ assert.ok(rels)
+ assert.ok(sheet)
+ files[path] = strToU8(strFromU8(rels).replace('worksheets/budget.xml', 'worksheets/Ä.xml'))
+ files['xl/worksheets/ä.xml'] = sheet
+ await expect(convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).rejects.toMatchObject({ code: 'invalid' })
+})
diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-xml-fixture.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-xml-fixture.ts
new file mode 100644
index 0000000000..f1f743d677
--- /dev/null
+++ b/packages/client/ui-sidebar-documentpreview/tests/excel-xml-fixture.ts
@@ -0,0 +1,14 @@
+/** Equivalent XML encodings and package layouts produced with XlsxWriter. */
+import { readFile } from 'node:fs/promises'
+
+/** Baseline and equivalent workbooks covering XML text and relocated core parts. */
+export const excelXmlCases = ['rich', 'utf16-sheet', 'utf16-metadata', 'workbook-path', 'styles-path', 'strings-path', 'cdata', 'parts-path'] as const
+
+/**
+ * Read committed independent-writer bytes for parser and browser tests.
+ * @param name - Retained fixture name.
+ * @returns Complete XLSX bytes owned by the caller.
+ */
+export async function excelXmlFixture(name: typeof excelXmlCases[number]): Promise> {
+ return new Uint8Array(await readFile(new URL(`./fixtures/excel-xml/${name}.xlsx`, import.meta.url)))
+}
diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-xml.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-xml.client.spec.ts
new file mode 100644
index 0000000000..fb3c5e3c54
--- /dev/null
+++ b/packages/client/ui-sidebar-documentpreview/tests/excel-xml.client.spec.ts
@@ -0,0 +1,112 @@
+/** XML decoding and OPC discovery preserve cells, metadata and preview notices. */
+import { createRequire } from 'node:module'
+import ExcelJS from 'exceljs'
+import { strFromU8, strToU8, unzipSync, zipSync } from 'fflate'
+import { describe, expect, it } from 'vitest'
+import { Config } from '../src/config.ts'
+import { convertExcel } from '../src/client/excel/convert.ts'
+import { XlsxPreviewArchive } from '../src/client/excel/xlsx-archive.ts'
+import { excelXmlCases, excelXmlFixture } from './excel-xml-fixture.ts'
+
+const require = createRequire(import.meta.url)
+const browserExcel = require('exceljs/dist/exceljs.js') as typeof ExcelJS
+const limits = Config({}).excel
+
+describe.each([['Node', ExcelJS], ['browser bundle', browserExcel]] as const)('%s XML reader', (_name, parser) => {
+ it.each(excelXmlCases)('preserves comments and Tables in %s', async (name) => {
+ const source = await excelXmlFixture(name)
+ const original = source.slice()
+ const archive = new XlsxPreviewArchive(source)
+ const retained = archive.withoutDrawings()
+ const workbook = new parser.Workbook()
+ await workbook.xlsx.load(retained.buffer, { ignoreNodes: ['drawing'] })
+ const sheet = workbook.getWorksheet('数据')!
+ expect(sheet.getCell('A5').value).toBe(' & 中文😀\n tail ')
+ expect(sheet.getCell('A6').value).toEqual({ formula: 'SUM(B2:B3)', result: 57 })
+ expect(sheet.getCell('B2').note).toMatchObject({ texts: [{ text: '生成的批注' }] })
+ expect(sheet.getTable('Table1')).toMatchObject({ table: { name: 'Table1', tableRef: 'A1:B3' } })
+ expect(sheet.getCell('B2').font).toMatchObject({ bold: true, color: { argb: 'FF112233' } })
+ expect(workbook.getWorksheet('隐藏页')!.state).toBe('hidden')
+ const originalParts = unzipSync(source)
+ for (const [path, bytes] of Object.entries(unzipSync(retained))) expect(bytes, path).toEqual(originalParts[path])
+ expect(source).toEqual(original)
+ })
+})
+
+it.each(excelXmlCases)('preserves the complete preview for %s', async (name) => {
+ const expected = await convertExcel(await excelXmlFixture('rich'), 'xlsx', limits)
+ expect(await convertExcel(await excelXmlFixture(name), 'xlsx', limits)).toEqual(expected)
+ expect(expected.unsupportedFeatures).toEqual(['charts', 'images', 'shapes', 'conditionalFormatting'])
+ const sheet = expected.sheets[0]!
+ expect(sheet.celldata!.find(cell => cell.r === 1 && cell.c === 1)!.v).toMatchObject({ v: 42, bl: 1, fc: '#112233', bg: '#FFFF00' })
+ expect(sheet.config).toMatchObject({ merge: { '29_7': { r: 29, c: 7, rs: 1, cs: 3 } }, rowhidden: { 2: 0 }, colhidden: { 1: 0 } })
+ expect(sheet.frozen).toMatchObject({ type: 'rangeBoth', range: { row_focus: 0, column_focus: 0 } })
+})
+
+it.each(['utf-16le', 'utf-16be'] as const)('decodes %s parts and relationships with and without a BOM', async (encoding) => {
+ const source = await excelXmlFixture('rich')
+ const expected = await convertExcel(source, 'xlsx', limits)
+ for (const bom of [false, true]) {
+ const files = unzipSync(source)
+ for (const [path, bytes] of Object.entries(files)) {
+ if (!/\.(xml|rels|vml)$/.test(path)) continue
+ const xml = strFromU8(bytes).replace(/encoding="UTF-8"/i, 'encoding="UTF-16"')
+ const encoded = Buffer.from(`${bom ? '\uFEFF' : ''}${xml}`, 'utf16le')
+ if (encoding === 'utf-16be') encoded.swap16()
+ files[path] = encoded
+ }
+ expect(await convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).toEqual(expected)
+ }
+})
+
+it.each(['xl/workbook.xml', 'xl/styles.xml', 'xl/sharedStrings.xml', '_rels/.rels', 'xl/_rels/workbook.xml.rels'])('rejects missing core part %s', async (path) => {
+ const files = unzipSync(await excelXmlFixture('rich'))
+ Reflect.deleteProperty(files, path)
+ await expect(convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).rejects.toMatchObject({ code: 'invalid' })
+})
+
+it('ignores unrelated binary parts without trying to decode them as XML', async () => {
+ const source = await excelXmlFixture('rich')
+ const files = unzipSync(source)
+ files['xl/vbaProject.bin'] = Uint8Array.of(0xff, 0x80, 0x81)
+ expect(await convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).toEqual(await convertExcel(source, 'xlsx', limits))
+})
+
+it('preserves text split across escaped text and CDATA segments', async () => {
+ const source = await excelXmlFixture('rich')
+ const files = unzipSync(source)
+ const path = 'xl/sharedStrings.xml'
+ files[path] = strToU8(strFromU8(files[path]!).replace('Item', 'Iem'))
+ expect(await convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).toEqual(await convertExcel(source, 'xlsx', limits))
+})
+
+it('finds a drawing owned by a part at the package root', () => {
+ const archive = new XlsxPreviewArchive(new Uint8Array(zipSync({
+ '_rels/root.xml.rels': strToU8(''),
+ 'drawing.xml': strToU8(''),
+ '_rels/drawing.xml.rels': strToU8(''),
+ })))
+ expect(Object.keys(unzipSync(archive.withoutDrawings()))).toEqual(['_rels/root.xml.rels'])
+ expect([...archive.unsupportedFeatures]).toEqual(['charts'])
+})
+
+it('rejects a worksheet relationship with no target', async () => {
+ const files = unzipSync(await excelXmlFixture('rich'))
+ const path = 'xl/_rels/workbook.xml.rels'
+ files[path] = strToU8(strFromU8(files[path]!).replace('Target="worksheets/sheet1.xml"', ''))
+ await expect(convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).rejects.toMatchObject({ code: 'invalid' })
+})
+
+it.each(['missing', 'external', 'duplicate'] as const)('rejects a %s officeDocument relationship', async (defect) => {
+ const files = unzipSync(await excelXmlFixture('rich'))
+ const xml = strFromU8(files['_rels/.rels']!)
+ const relation = xml.match(/]*officeDocument"[^>]*\/>/)![0]
+ files['_rels/.rels'] = strToU8(xml.replace(relation, defect === 'missing' ? '' : defect === 'duplicate' ? relation + relation : relation.replace('/>', ' TargetMode="External"/>')))
+ await expect(convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).rejects.toMatchObject({ code: 'invalid' })
+})
+
+it('rejects malformed UTF-16 without replacing invalid code units', async () => {
+ const files = unzipSync(await excelXmlFixture('rich'))
+ files['xl/worksheets/sheet1.xml'] = Uint8Array.of(0xff, 0xfe, 0x3c)
+ await expect(convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).rejects.toMatchObject({ code: 'invalid' })
+})
diff --git a/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-opc/combined.xlsx b/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-opc/combined.xlsx
new file mode 100644
index 0000000000..e3d02cd127
Binary files /dev/null and b/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-opc/combined.xlsx differ
diff --git a/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-opc/comments.xlsx b/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-opc/comments.xlsx
new file mode 100644
index 0000000000..53b90c36e5
Binary files /dev/null and b/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-opc/comments.xlsx differ
diff --git a/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-opc/part-name.xlsx b/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-opc/part-name.xlsx
new file mode 100644
index 0000000000..b8a3bc7a97
Binary files /dev/null and b/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-opc/part-name.xlsx differ
diff --git a/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-opc/prefix.xlsx b/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-opc/prefix.xlsx
new file mode 100644
index 0000000000..f3ae57754c
Binary files /dev/null and b/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-opc/prefix.xlsx differ
diff --git a/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-opc/relative.xlsx b/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-opc/relative.xlsx
new file mode 100644
index 0000000000..4a3ada5581
Binary files /dev/null and b/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-opc/relative.xlsx differ
diff --git a/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-opc/table.xlsx b/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-opc/table.xlsx
new file mode 100644
index 0000000000..4b37ab4051
Binary files /dev/null and b/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-opc/table.xlsx differ
diff --git a/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-xml/cdata.xlsx b/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-xml/cdata.xlsx
new file mode 100644
index 0000000000..4a15bb0319
Binary files /dev/null and b/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-xml/cdata.xlsx differ
diff --git a/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-xml/parts-path.xlsx b/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-xml/parts-path.xlsx
new file mode 100644
index 0000000000..f3cdcf0787
Binary files /dev/null and b/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-xml/parts-path.xlsx differ
diff --git a/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-xml/rich.xlsx b/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-xml/rich.xlsx
new file mode 100644
index 0000000000..9e88142e59
Binary files /dev/null and b/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-xml/rich.xlsx differ
diff --git a/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-xml/strings-path.xlsx b/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-xml/strings-path.xlsx
new file mode 100644
index 0000000000..1eb7d265f4
Binary files /dev/null and b/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-xml/strings-path.xlsx differ
diff --git a/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-xml/styles-path.xlsx b/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-xml/styles-path.xlsx
new file mode 100644
index 0000000000..a9b4ebb24e
Binary files /dev/null and b/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-xml/styles-path.xlsx differ
diff --git a/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-xml/utf16-metadata.xlsx b/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-xml/utf16-metadata.xlsx
new file mode 100644
index 0000000000..92d76dd873
Binary files /dev/null and b/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-xml/utf16-metadata.xlsx differ
diff --git a/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-xml/utf16-sheet.xlsx b/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-xml/utf16-sheet.xlsx
new file mode 100644
index 0000000000..342d632e02
Binary files /dev/null and b/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-xml/utf16-sheet.xlsx differ
diff --git a/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-xml/workbook-path.xlsx b/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-xml/workbook-path.xlsx
new file mode 100644
index 0000000000..d617d6d966
Binary files /dev/null and b/packages/client/ui-sidebar-documentpreview/tests/fixtures/excel-xml/workbook-path.xlsx differ
diff --git a/packages/client/ui-sidebar-documentpreview/tests/fuzz/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/tests/fuzz/README.i18n.yaml
new file mode 100644
index 0000000000..4b73053e6e
--- /dev/null
+++ b/packages/client/ui-sidebar-documentpreview/tests/fuzz/README.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write packages/client/ui-sidebar-documentpreview/tests/fuzz/README.md
+README.md: 7a95b5cedb7dbcbfbc1f0773513ff6488440fd98
+README.zh.md: 608acf3d1eb83028e3a916dff3e87cc0e88b2ee6
diff --git a/packages/client/ui-sidebar-documentpreview/tests/fuzz/README.md b/packages/client/ui-sidebar-documentpreview/tests/fuzz/README.md
new file mode 100644
index 0000000000..7a95b5cedb
--- /dev/null
+++ b/packages/client/ui-sidebar-documentpreview/tests/fuzz/README.md
@@ -0,0 +1,46 @@
+# XLSX fuzz diagnostics
+
+English | [中文](README.zh.md)
+
+Generate spreadsheet files with Openpyxl and XlsxWriter, then compare the source XLSX adapter against independently recorded cell values, formulas, and unsupported-content categories. This explicit diagnostic campaign retains failures and exits nonzero; it is not part of the default unit suite.
+
+## Run a campaign
+
+Install workspace dependencies and use a Python interpreter with Openpyxl, XlsxWriter, and Pillow. In PowerShell, set `$excelFuzzPython` to that interpreter's executable path. Run from the repository root with a new output directory:
+
+```powershell
+& $excelFuzzPython packages/client/ui-sidebar-documentpreview/tests/fuzz/generate.py .artifacts/excel-fuzz-new --seed 4863 --random-cases 400
+pnpm exec tsx packages/client/ui-sidebar-documentpreview/tests/fuzz/run.client.ts .artifacts/excel-fuzz-new
+```
+
+The seed controls feature combinations, values, and byte mutations. ZIP metadata is fixed; reproducibility also requires the writer versions recorded in `manifest.json`. The generator validates internal relationship targets and reloads oracle cells with Openpyxl before classifying a workbook as valid. Rejected candidates remain in `generatorErrors`; random byte mutations are classified separately.
+
+Replay selected cases from an existing corpus:
+
+```sh
+pnpm exec tsx packages/client/ui-sidebar-documentpreview/tests/fuzz/run.client.ts .artifacts/excel-fuzz-new --only openpyxl-table --report table.json
+```
+
+The runner gives each input a disposable-worker deadline of 10 seconds and checks input immutability, cell values, formula caches, and notice ordering/uniqueness. `--timeout-ms` changes that diagnostic deadline. Reports retain every outcome; `repros/` contains representative failures with fewer feature combinations, not a claim of globally minimal inputs. Accepted damaged ZIPs are informational and need separate inspection.
+
+## Compare an older adapter
+
+`--baseline-ref --report baseline.json` reads that revision's `xlsx.ts` from Git and disables notice comparisons. It still uses the current dependency versions and shared conversion helpers; it cannot reproduce an unpatched ExcelJS baseline after installing the patch. Retain reports from the original dependency state or run that revision in a separate checkout. The temporary baseline module is removed after its worker exits. This compares source parsing, not FortuneSheet browser rendering or the shipped Web profile.
+
+## Retained regressions
+
+The [OPC fixtures](../fixtures/excel-opc/) contain five isolated failures copied from the seed 4863 corpus and a combined Openpyxl workbook. Openpyxl 3.1.5 produces comments, Tables, namespace-prefix and part-name cases; XlsxWriter 3.2.9 produces the relative-target case. The combined workbook adds chart, conditional formatting, frozen headings, hidden rows/columns/sheet, a merge, styles, a hyperlink, literal XML-looking text, and a formula, then applies `sheet-prefix` and `sheet-filename` mutations. Openpyxl independently validates the saved inputs. Unit and browser tests read these committed bytes without Python.
+
+The [XML fixtures](../fixtures/excel-xml/) retain one XlsxWriter 3.2.9 workbook and seven equivalent encodings: UTF-16 worksheet or metadata XML, relocated workbook/styles/shared strings, CDATA text, and relocated worksheet/drawing directories. Each retains a formula cache, Unicode and XML-looking text, comment, Table, styles, merge, frozen headings, hidden content, and all four notice categories. When upgrading ExcelJS, compare the complete preview and comment/Table metadata through both Node and browser entries, then run `apps/web/tests/excel-opc.e2e.ts` against the built Worker. Keep original corpus reports; save replay results under a new `--report` name and inspect ZIP directory integrity separately from independent-reader acceptance.
+
+The [opening fixtures](../excel-opening-fixture.ts) derive Strict URI aliases, root-target and part-name case variants, and orphan DrawingML from the combined Openpyxl file. These transformations retain cell data and run through both parser entries, full preview comparison, and the shipped browser Worker. Ambiguous ASCII case-equivalent entries are rejected; non-ASCII names remain distinct.
+
+Run the Node and browser-bundle parser regressions:
+
+```sh
+pnpm exec vitest run packages/client/ui-sidebar-documentpreview/tests/excel-opening.client.spec.ts packages/client/ui-sidebar-documentpreview/tests/excel-opc.client.spec.ts packages/client/ui-sidebar-documentpreview/tests/excel-xml.client.spec.ts
+```
+
+Fuzz reports record each input's SHA-256, whether it opened, and the worktree status. `validOpened` and `validRejected` separate opening failures from value differences; strict exit status still rejects either defect. Known 1904 time/duration offsets and empty-string formula-cache differences remain diagnostic failures. Corrupt inputs accepted by the parser remain informational.
+
+Maintain the ExcelJS patch with `pnpm patch` and `pnpm patch-commit` against the published package. Installed dependency trees contain generated launchers that must not enter the patch. Validate patch application in a fresh `pnpm patch --edit-dir ` extraction before relying on tests against a cached installation.
diff --git a/packages/client/ui-sidebar-documentpreview/tests/fuzz/README.zh.md b/packages/client/ui-sidebar-documentpreview/tests/fuzz/README.zh.md
new file mode 100644
index 0000000000..608acf3d1e
--- /dev/null
+++ b/packages/client/ui-sidebar-documentpreview/tests/fuzz/README.zh.md
@@ -0,0 +1,46 @@
+# XLSX 模糊测试诊断
+
+[English](README.md) | 中文
+
+使用 Openpyxl 和 XlsxWriter 生成表格文件,再将源码 XLSX 适配器的结果与独立记录的单元格值、公式和不支持内容类别比较。此诊断任务需要显式运行,会保留失败结果并以非零状态退出;它不属于默认单元测试集。
+
+## 运行测试
+
+安装工作区依赖,并准备装有 Openpyxl、XlsxWriter 和 Pillow 的 Python 解释器。在 PowerShell 中,将 `$excelFuzzPython` 设置为该解释器的可执行文件路径。从仓库根目录运行,并使用新的输出目录:
+
+```powershell
+& $excelFuzzPython packages/client/ui-sidebar-documentpreview/tests/fuzz/generate.py .artifacts/excel-fuzz-new --seed 4863 --random-cases 400
+pnpm exec tsx packages/client/ui-sidebar-documentpreview/tests/fuzz/run.client.ts .artifacts/excel-fuzz-new
+```
+
+种子控制功能组合、数值和字节变异。ZIP 元数据保持固定;复现还依赖 `manifest.json` 记录的写入器版本。生成器先验证内部关系目标,并用 Openpyxl 重新读取预期单元格,再将工作簿归类为合法文件。被排除的候选保留在 `generatorErrors` 中;随机字节变异单独分类。
+
+从已有样本集重跑选定用例:
+
+```sh
+pnpm exec tsx packages/client/ui-sidebar-documentpreview/tests/fuzz/run.client.ts .artifacts/excel-fuzz-new --only openpyxl-table --report table.json
+```
+
+执行器为每个输入设置 10 秒的可终止 Worker 截止时间,并检查输入不变性、单元格值、公式缓存,以及提示顺序和唯一性。`--timeout-ms` 可修改该诊断时限。报告保留全部结果;`repros/` 包含功能组合较少的代表性失败文件,不声称输入已达到全局最小。被接受的损坏 ZIP 仅作为观察结果,需要单独检查。
+
+## 对比旧适配器
+
+`--baseline-ref --report baseline.json` 从 Git 读取该版本的 `xlsx.ts`,并禁用提示比较。它仍使用当前依赖版本和共用转换辅助函数,安装补丁后无法复现未修补 ExcelJS 的基线。须保留原依赖状态下的报告,或在独立检出目录中运行旧版本。Worker 退出后会删除临时基线模块。此对比检查源码解析,不覆盖 FortuneSheet 浏览器渲染或发布的 Web 配置。
+
+## 保留的回归用例
+
+[OPC fixture](../fixtures/excel-opc/) 包含从 seed 4863 语料复制的五类独立失败文件,以及一个组合 Openpyxl 工作簿。Openpyxl 3.1.5 生成批注、Table、命名空间前缀和部件名用例,XlsxWriter 3.2.9 生成相对目标用例。组合工作簿加入图表、条件格式、冻结表头、隐藏行/列/工作表、合并、样式、超链接、类似 XML 的字面文本和公式,再应用 `sheet-prefix` 与 `sheet-filename` 变异。Openpyxl 独立校验保存的输入。单元测试和浏览器测试读取已提交的字节,无需 Python。
+
+[XML fixture](../fixtures/excel-xml/) 保留一个 XlsxWriter 3.2.9 工作簿及七种等价编码:UTF-16 工作表或元数据 XML、移动工作簿/样式/共享字符串部件、CDATA 文本,以及移动工作表/绘图目录。每个文件都保留公式缓存、Unicode 和类似 XML 的文本、批注、Table、样式、合并、冻结标题、隐藏内容和全部四类提示。升级 ExcelJS 时,应通过 Node 与浏览器入口比较完整预览及批注/Table 元数据,再对构建后的 Worker 运行 `apps/web/tests/excel-opc.e2e.ts`。保留原始语料报告;重放时使用新的 `--report` 文件名,并在独立读取器验证之外单独检查 ZIP 目录完整性。
+
+[打开回归样例](../excel-opening-fixture.ts)基于 Openpyxl 组合文件派生 Strict URI 别名、根目标与部件名大小写变体,以及孤立 DrawingML。变换保留单元格数据,并通过两个解析入口、完整预览比较和实际发布的浏览器 Worker 验证。ASCII 大小写等价的歧义条目会被拒绝;非 ASCII 名称保持区分。
+
+运行 Node 和浏览器 bundle 解析器回归测试:
+
+```sh
+pnpm exec vitest run packages/client/ui-sidebar-documentpreview/tests/excel-opening.client.spec.ts packages/client/ui-sidebar-documentpreview/tests/excel-opc.client.spec.ts packages/client/ui-sidebar-documentpreview/tests/excel-xml.client.spec.ts
+```
+
+fuzz 报告记录每个输入的 SHA-256、能否打开以及工作区状态。`validOpened` 和 `validRejected` 将打开失败与值差异分开,严格退出状态仍会拒绝这两类缺陷。已知的 1904 时间/累计时长偏移和空字符串公式缓存差异仍作为诊断失败。解析器接受的损坏输入继续作为观察结果单列。
+
+通过 `pnpm patch` 和 `pnpm patch-commit` 对发布包维护 ExcelJS 补丁。已安装的依赖树包含生成的启动脚本,不得混入补丁。在依赖缓存安装上的测试结果之前,先用 `pnpm patch --edit-dir ` 全新解包,验证补丁能够应用。
diff --git a/packages/client/ui-sidebar-documentpreview/tests/fuzz/excel-worker.client.ts b/packages/client/ui-sidebar-documentpreview/tests/fuzz/excel-worker.client.ts
new file mode 100644
index 0000000000..b921e05e66
--- /dev/null
+++ b/packages/client/ui-sidebar-documentpreview/tests/fuzz/excel-worker.client.ts
@@ -0,0 +1,29 @@
+/** Test-only Client parser isolation; the parent owns deadlines and termination. */
+import { parentPort, workerData } from 'node:worker_threads'
+import { convertExcel } from '../../src/client/excel/convert.ts'
+import type { ExcelLimits, ExcelPreview } from '../../src/client/excel/model.ts'
+
+if (parentPort === null) throw new Error('The Excel fuzz worker requires a parent port')
+const port = parentPort
+const options = workerData as { source?: string }
+const baseline = options.source === undefined ? undefined : await import(options.source) as {
+ convertXlsx: (bytes: Uint8Array, limits: ExcelLimits) => Promise
+}
+port.on('message', (bytes: Uint8Array) => { void parseWorkbook(bytes) })
+
+async function parseWorkbook(bytes: Uint8Array): Promise {
+ const before = bytes.slice()
+ try {
+ const limits = { maxBytes: 20_000_000, maxCells: 1_000_000, timeoutMs: 10_000 }
+ const preview = baseline === undefined ? await convertExcel(bytes, 'xlsx', limits) : await baseline.convertXlsx(bytes, limits)
+ port.postMessage({ ok: true, preview, inputUnchanged: before.every((value, index) => value === bytes[index]) })
+ } catch (error) {
+ const causes: string[] = []
+ let current: unknown = error
+ while (current instanceof Error && causes.length < 8) {
+ causes.push(`${current.name}: ${current.message}`)
+ current = current.cause
+ }
+ port.postMessage({ ok: false, causes, inputUnchanged: before.every((value, index) => value === bytes[index]) })
+ }
+}
diff --git a/packages/client/ui-sidebar-documentpreview/tests/fuzz/generate.py b/packages/client/ui-sidebar-documentpreview/tests/fuzz/generate.py
new file mode 100644
index 0000000000..a9141ac552
--- /dev/null
+++ b/packages/client/ui-sidebar-documentpreview/tests/fuzz/generate.py
@@ -0,0 +1,351 @@
+"""Generate replayable XLSX cases using independent writers and equivalent ZIP/XML encodings."""
+
+import argparse
+import base64
+import copy
+from datetime import datetime, time, timedelta
+import io
+import json
+import math
+from pathlib import Path
+import posixpath
+import random
+import re
+import warnings
+import zipfile
+from xml.etree import ElementTree as ET
+
+import openpyxl
+from openpyxl.chart import BarChart, Reference
+from openpyxl.comments import Comment
+from openpyxl.drawing.image import Image
+from openpyxl.formatting.rule import ColorScaleRule, DataBarRule, IconSetRule, FormulaRule
+from openpyxl.styles import Font, PatternFill
+from openpyxl.utils.datetime import CALENDAR_MAC_1904, to_excel
+from openpyxl.worksheet.datavalidation import DataValidation
+from openpyxl.worksheet.table import Table, TableStyleInfo
+import xlsxwriter
+
+FEATURES = ['chart', 'image', 'comment', 'conditional', 'conditional-databar', 'conditional-iconset', 'conditional-expression', 'table', 'validation', 'hyperlink',
+ 'merge', 'freeze', 'hidden', 'header', 'epoch1904', 'styled']
+ERRORS = ['#NULL!', '#DIV/0!', '#VALUE!', '#REF!', '#NAME?', '#NUM!', '#N/A',
+ '#SPILL!', '#CALC!', '#GETTING_DATA']
+PNG = base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=')
+MAIN_NS = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'
+
+
+def fixed_zip(files, compression=zipfile.ZIP_DEFLATED, reverse=False, comment=b''):
+ """Fix ZIP metadata so the same seed yields byte-identical files."""
+ output = io.BytesIO()
+ with zipfile.ZipFile(output, 'w', compression=compression) as archive:
+ for name in sorted(files, reverse=reverse):
+ entry = zipfile.ZipInfo(name, date_time=(2020, 1, 1, 0, 0, 0))
+ entry.compress_type = compression
+ content = files[name]
+ if name == 'docProps/core.xml':
+ # Openpyxl overwrites the modified time during save_workbook.
+ content = re.sub(rb'(]*>)[^<]*()', rb'\g<1>2020-01-01T00:00:00Z\g<2>', content)
+ archive.writestr(entry, content)
+ archive.comment = comment
+ return output.getvalue()
+
+
+def unpack(data):
+ with zipfile.ZipFile(io.BytesIO(data)) as archive:
+ return {name: archive.read(name) for name in archive.namelist()}
+
+
+def validate(data, cells):
+ """Check internal relationship targets and independently reload all oracle cells."""
+ files = unpack(data)
+ for name, content in files.items():
+ if name.endswith('.rels'):
+ folder = '' if name == '_rels/.rels' else name.split('/_rels/')[0]
+ for rel in ET.fromstring(content):
+ if rel.get('TargetMode') == 'External':
+ continue
+ target = rel.attrib['Target']
+ target = posixpath.normpath(target.lstrip('/') if target.startswith('/') else posixpath.join(folder, target))
+ if target not in files:
+ raise ValueError(f'Missing relationship target {name}: {target}')
+ with warnings.catch_warnings():
+ warnings.simplefilter('ignore', UserWarning)
+ workbook = openpyxl.load_workbook(io.BytesIO(data), data_only=False)
+ for cell in cells:
+ actual = workbook[cell['sheet']].cell(cell['r'] + 1, cell['c'] + 1).value
+ expected = cell.get('formula', cell.get('source', cell.get('value')))
+ if isinstance(expected, dict):
+ continue
+ equivalent_number = type(actual) in (int, float) and type(expected) in (int, float) and math.isclose(actual, expected, rel_tol=1e-12, abs_tol=1e-12)
+ if actual != expected and not equivalent_number:
+ raise ValueError(f'Writer changed {cell}: {actual!r}')
+ workbook.close()
+
+
+def workbook_bytes(writer, features, values, case_seed):
+ """Keep expected values independent of the preview implementation."""
+ output = io.BytesIO()
+ cells = []
+ active = set(features)
+ if writer == 'openpyxl':
+ book = openpyxl.Workbook()
+ book.properties.created = book.properties.modified = datetime(2020, 1, 1)
+ if 'epoch1904' in active:
+ book.epoch = CALENDAR_MAC_1904
+ sheet = book.active
+ sheet.title = '数据'
+ else:
+ book = xlsxwriter.Workbook(output, {'in_memory': True, 'date_1904': 'epoch1904' in active})
+ book.set_properties({'created': datetime(2020, 1, 1)})
+ sheet = book.add_worksheet('数据')
+
+ for r, c, value, kind in values:
+ oracle = {'sheet': '数据', 'r': r, 'c': c}
+ if kind == 'formula':
+ formula, cached = value
+ oracle['formula'] = formula
+ oracle['value'] = None if writer == 'openpyxl' else cached
+ if writer == 'openpyxl':
+ sheet.cell(r + 1, c + 1, formula)
+ else:
+ sheet.write_formula(r, c, formula, None, cached)
+ elif kind == 'date':
+ oracle['source'] = {'iso': value.isoformat()}
+ oracle['value'] = to_excel(value)
+ if writer == 'openpyxl':
+ sheet.cell(r + 1, c + 1, value).number_format = 'yyyy-mm-dd hh:mm:ss'
+ else:
+ sheet.write_datetime(r, c, value, book.add_format({'num_format': 'yyyy-mm-dd hh:mm:ss'}))
+ elif kind == 'time':
+ oracle['source'] = {'iso': str(value)}
+ oracle['value'] = to_excel(value)
+ if writer == 'openpyxl':
+ sheet.cell(r + 1, c + 1, value).number_format = '[h]:mm:ss' if isinstance(value, timedelta) else 'hh:mm:ss'
+ else:
+ sheet.write_datetime(r, c, value, book.add_format({'num_format': '[h]:mm:ss'}))
+ elif kind == 'error':
+ oracle['value'] = value
+ if writer == 'openpyxl':
+ cell = sheet.cell(r + 1, c + 1, value)
+ cell.data_type = 'e'
+ else:
+ # XlsxWriter exposes Excel error caches through its formula writer.
+ sheet.write_formula(r, c, '=1/0', None, value)
+ oracle['formula'] = '=1/0'
+ else:
+ oracle['value'] = value
+ if writer == 'openpyxl':
+ sheet.cell(r + 1, c + 1, value)
+ elif isinstance(value, str):
+ sheet.write_string(r, c, value)
+ else:
+ sheet.write(r, c, value)
+ cells.append(oracle)
+
+ if writer == 'openpyxl':
+ if 'chart' in active:
+ chart = BarChart()
+ chart.add_data(Reference(sheet, min_col=2, min_row=1, max_row=3), titles_from_data=True)
+ sheet.add_chart(chart, 'H2')
+ if 'image' in active:
+ sheet.add_image(Image(io.BytesIO(PNG)), 'H18')
+ if 'comment' in active:
+ sheet['B2'].comment = Comment('生成的批注', 'fuzz')
+ if 'conditional' in active:
+ sheet.conditional_formatting.add('B2:B3', ColorScaleRule(start_type='min', start_color='FF0000', end_type='max', end_color='00FF00'))
+ if 'conditional-databar' in active:
+ sheet.conditional_formatting.add('B2:B3', DataBarRule(start_type='min', end_type='max', color='FF336699'))
+ if 'conditional-iconset' in active:
+ sheet.conditional_formatting.add('B2:B3', IconSetRule('3TrafficLights1', 'percent', [0, 33, 67]))
+ if 'conditional-expression' in active:
+ sheet.conditional_formatting.add('B2:B3', FormulaRule(formula=['B2>20'], font=Font(bold=True)))
+ if 'table' in active:
+ table = Table(displayName='DataTable', ref='A1:B3')
+ table.tableStyleInfo = TableStyleInfo(name='TableStyleMedium2', showRowStripes=True)
+ sheet.add_table(table)
+ if 'validation' in active:
+ validation = DataValidation(type='list', formula1='"A,B,C"')
+ sheet.add_data_validation(validation)
+ validation.add('F1:F3')
+ if 'hyperlink' in active:
+ sheet['A2'].hyperlink = 'https://example.com/?a=1&b=2'
+ if 'merge' in active:
+ sheet.merge_cells('H30:J30')
+ if 'freeze' in active:
+ sheet.freeze_panes = 'B2'
+ if 'hidden' in active:
+ sheet.row_dimensions[3].hidden = True
+ sheet.column_dimensions['B'].hidden = True
+ book.create_sheet('隐藏页').sheet_state = 'hidden'
+ if 'header' in active:
+ sheet.oddHeader.center.text = 'Fuzz &P'
+ if 'styled' in active:
+ sheet['B2'].font = Font(bold=True, color='FF112233')
+ sheet['B2'].fill = PatternFill('solid', fgColor='FFFFFF00')
+ book.save(output)
+ else:
+ if 'chart' in active:
+ chart = book.add_chart({'type': 'column'})
+ chart.add_series({'values': ['数据', 1, 1, 2, 1]})
+ sheet.insert_chart('H2', chart)
+ if 'image' in active:
+ sheet.insert_image('H18', 'pixel.png', {'image_data': io.BytesIO(PNG)})
+ if 'comment' in active:
+ sheet.write_comment('B2', '生成的批注')
+ if 'conditional' in active:
+ sheet.conditional_format('B2:B3', {'type': '2_color_scale'})
+ if 'conditional-databar' in active:
+ sheet.conditional_format('B2:B3', {'type': 'data_bar', 'data_bar_2010': True})
+ if 'conditional-iconset' in active:
+ sheet.conditional_format('B2:B3', {'type': 'icon_set', 'icon_style': '3_traffic_lights'})
+ if 'conditional-expression' in active:
+ sheet.conditional_format('B2:B3', {'type': 'formula', 'criteria': '=B2>20', 'format': book.add_format({'bold': True})})
+ if 'textbox' in active:
+ sheet.insert_textbox('H32', 'Text box ')
+ if 'table' in active:
+ sheet.add_table('A1:B3', {'columns': [{'header': 'Item'}, {'header': 'Amount'}]})
+ if 'validation' in active:
+ sheet.data_validation('F1:F3', {'validate': 'list', 'source': ['A', 'B', 'C']})
+ if 'hyperlink' in active:
+ sheet.write_url('A2', 'https://example.com/?a=1&b=2', string='A')
+ if 'merge' in active:
+ sheet.merge_range('H30:J30', '', book.add_format())
+ if 'freeze' in active:
+ sheet.freeze_panes(1, 1)
+ if 'hidden' in active:
+ sheet.set_row(2, None, None, {'hidden': True})
+ sheet.set_column('B:B', None, None, {'hidden': True})
+ book.add_worksheet('隐藏页').hide()
+ if 'header' in active:
+ sheet.set_header('&CFuzz &P')
+ if 'styled' in active:
+ sheet.write('B2', 42, book.add_format({'bold': True, 'font_color': '#112233', 'bg_color': '#FFFF00'}))
+ book.close()
+ features = [key for feature, key in [('chart', 'charts'), ('image', 'images'), ('textbox', 'shapes')] if feature in active]
+ if any(feature.startswith('conditional') for feature in active):
+ features.append('conditionalFormatting')
+ return fixed_zip(unpack(output.getvalue())), cells, features
+
+
+def mutate(data, mutation):
+ """Change encodings or OPC names without changing worksheet cell semantics."""
+ files = unpack(data)
+ if mutation == 'zip-store':
+ return fixed_zip(files, zipfile.ZIP_STORED)
+ if mutation == 'zip-reverse-comment':
+ return fixed_zip(files, reverse=True, comment=b'Excel fuzz archive comment')
+ if mutation == 'sheet-prefix':
+ path = 'xl/worksheets/sheet1.xml'
+ xml = files[path].decode()
+ xml = xml.replace(f'xmlns="{MAIN_NS}"', f'xmlns:s="{MAIN_NS}"')
+ files[path] = re.sub(r'<(/?)([A-Za-z_][\w.-]*)(?=[\s/>])', r'<\1s:\2', xml).encode()
+ if mutation == 'sheet-filename':
+ old, new = 'xl/worksheets/sheet1.xml', 'xl/worksheets/budget.xml'
+ files[new] = files.pop(old)
+ rel = 'xl/worksheets/_rels/sheet1.xml.rels'
+ if rel in files:
+ files['xl/worksheets/_rels/budget.xml.rels'] = files.pop(rel)
+ for path in ['xl/_rels/workbook.xml.rels', '[Content_Types].xml']:
+ files[path] = files[path].replace(b'worksheets/sheet1.xml', b'worksheets/budget.xml')
+ if mutation == 'relationship-dot':
+ path = 'xl/_rels/workbook.xml.rels'
+ files[path] = files[path].replace(b'Target="worksheets/', b'Target="./worksheets/').replace(b'Target="/xl/worksheets/', b'Target="/xl/./worksheets/')
+ if mutation == 'relationship-absolute':
+ path = 'xl/_rels/workbook.xml.rels'
+ files[path] = files[path].replace(b'Target="worksheets/', b'Target="/xl/worksheets/')
+ if mutation in ['drawing-default', 'drawing-alternate']:
+ for path in list(files):
+ if re.fullmatch(r'xl/drawings/[^/]+\.xml', path):
+ xml = files[path].decode()
+ match = re.search(r'xmlns(?::([\w]+))?="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing"', xml)
+ if match:
+ old = match.group(1)
+ new = '' if mutation == 'drawing-default' else 'draw'
+ xml = xml.replace(match.group(0), f'xmlns{":" + new if new else ""}="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing"')
+ if old:
+ xml = re.sub(r'<(/?)' + re.escape(old) + ':', r'<\1' + (new + ':' if new else ''), xml)
+ elif new:
+ xml = re.sub(r'<(/?)([A-Za-z_][\w.-]*)(?=[\s/>])', r'<\1' + new + r':\2', xml)
+ files[path] = xml.encode()
+ return fixed_zip(files)
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('output', type=Path)
+ parser.add_argument('--seed', type=int, default=4863)
+ parser.add_argument('--random-cases', type=int, default=300, help='Cases per independent writer')
+ args = parser.parse_args()
+ args.output.mkdir(parents=True, exist_ok=False)
+ rng = random.Random(args.seed)
+ manifest = {'seed': args.seed, 'writers': {'openpyxl': openpyxl.__version__, 'xlsxwriter': xlsxwriter.__version__}, 'cases': [], 'generatorErrors': []}
+ base_values = [(0, 0, 'Item', 'value'), (0, 1, 'Amount', 'value'), (1, 0, 'A', 'value'),
+ (1, 1, 42, 'value'), (2, 0, 'B', 'value'), (2, 1, 15, 'value')]
+ directed = [([], base_values, 'plain')]
+ directed += [([feature], base_values, feature) for feature in FEATURES]
+ directed += [(['chart', feature], base_values, 'chart-' + feature) for feature in ['comment', 'image', 'conditional']]
+ directed += [([], base_values + [(4, 0, error, 'error')], 'error-' + str(index)) for index, error in enumerate(ERRORS)]
+ directed += [([], base_values + [(4, 0, ('=1+2', value), 'formula')], 'cache-' + str(index)) for index, value in enumerate([3, 0, False, True, '', '文本', '#DIV/0!'])]
+ directed += [([epoch] if epoch else [], base_values + [(4, 0, value, kind)], f'temporal-{epoch}-{index}') for epoch in ['', 'epoch1904'] for index, (value, kind) in enumerate([
+ (datetime(2024, 2, 29, 12, 34, 56), 'date'), (time(12, 34, 56), 'time'), (timedelta(days=2, hours=3), 'time')])]
+ directed += [([], base_values + [(4, 0, value, 'value')], 'text-' + str(index)) for index, value in enumerate([' & 字符', '😀\n多行\t数据', '_x0041_', '_x000D_', '00123'])]
+
+ def emit(data, cells, expected_features, writer, features, variant, valid=True):
+ index = len(manifest['cases'])
+ path = f'{index:05d}-{writer}-{variant}.xlsx'
+ if valid:
+ validate(data, cells)
+ (args.output / path).write_bytes(data)
+ manifest['cases'].append({'file': path, 'writer': writer, 'features': sorted(set(features)), 'variant': variant,
+ 'valid': valid, 'cells': cells, 'unsupportedFeatures': expected_features})
+
+ for writer in ['openpyxl', 'xlsxwriter']:
+ writer_directed = directed + ([(['textbox'], base_values, 'textbox')] if writer == 'xlsxwriter' else [])
+ for features, values, label in writer_directed:
+ try:
+ data, cells, expected = workbook_bytes(writer, features, values, args.seed)
+ emit(data, cells, expected, writer, features, label)
+ if label in ['plain', 'chart', 'image', 'comment', 'conditional', 'chart-comment']:
+ for mutation in ['zip-store', 'zip-reverse-comment', 'sheet-prefix', 'sheet-filename', 'relationship-dot', 'relationship-absolute', 'drawing-default', 'drawing-alternate']:
+ try:
+ emit(mutate(data, mutation), cells, expected, writer, features, label + '--' + mutation)
+ except Exception as error:
+ manifest['generatorErrors'].append({'writer': writer, 'case': label, 'mutation': mutation, 'error': str(error)})
+ except Exception as error:
+ manifest['generatorErrors'].append({'writer': writer, 'case': label, 'error': str(error)})
+ for index in range(args.random_cases):
+ features = [feature for feature in FEATURES + (['textbox'] if writer == 'xlsxwriter' else []) if rng.random() < 0.3]
+ values = copy.deepcopy(base_values)
+ for row in range(5, rng.randint(6, 14)):
+ value = rng.choice([rng.randint(-100000, 100000), rng.random() * 100, True, False, '中文😀', 'literal', '00123'])
+ values.append((row, rng.randrange(4), value, 'value'))
+ values += [(3, 3, rng.choice(ERRORS), 'error')]
+ try:
+ data, cells, expected = workbook_bytes(writer, features, values, args.seed + index)
+ mutation = rng.choice(['none', 'zip-store', 'zip-reverse-comment', 'drawing-default', 'drawing-alternate'])
+ emit(mutate(data, mutation), cells, expected, writer, features, f'random-{index}-{mutation}')
+ except Exception as error:
+ manifest['generatorErrors'].append({'writer': writer, 'case': index, 'error': str(error)})
+
+ plain = next(case for case in manifest['cases'] if case['variant'] == 'plain')
+ source = (args.output / plain['file']).read_bytes()
+ for index in range(128):
+ data = bytearray(source)
+ if index < 32:
+ data = data[:rng.randrange(1, len(data))]
+ else:
+ for _ in range(rng.randint(1, 5)):
+ data[rng.randrange(len(data))] ^= rng.randint(1, 255)
+ try:
+ validate(bytes(data), plain['cells'])
+ valid = True
+ except Exception:
+ valid = False
+ emit(bytes(data), plain['cells'], [], 'mutated', [], f'bytes-{index}', valid=valid)
+
+ (args.output / 'manifest.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
+ print(json.dumps({'output': str(args.output), 'cases': len(manifest['cases']), 'valid': sum(case['valid'] for case in manifest['cases']), 'generatorErrors': len(manifest['generatorErrors'])}))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/packages/client/ui-sidebar-documentpreview/tests/fuzz/run.client.ts b/packages/client/ui-sidebar-documentpreview/tests/fuzz/run.client.ts
new file mode 100644
index 0000000000..7cf04e4274
--- /dev/null
+++ b/packages/client/ui-sidebar-documentpreview/tests/fuzz/run.client.ts
@@ -0,0 +1,177 @@
+/** Explicit XLSX fuzz campaign with per-input worker deadlines and retained failure evidence. */
+import { readFile, writeFile, mkdir, copyFile, mkdtemp, rm } from 'node:fs/promises'
+import { execFileSync } from 'node:child_process'
+import { resolve, join, relative, isAbsolute } from 'node:path'
+import { parseArgs } from 'node:util'
+import { Worker } from 'node:worker_threads'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+import { createHash } from 'node:crypto'
+import type { ExcelPreview } from '../../src/client/excel/model.ts'
+
+interface OracleCell { sheet: string; r: number; c: number; value: string | number | boolean | null; formula?: string }
+interface Case {
+ file: string
+ writer: string
+ features: string[]
+ variant: string
+ valid: boolean
+ cells: OracleCell[]
+ unsupportedFeatures: string[]
+}
+interface Manifest { seed: number; writers: Record; cases: Case[]; generatorErrors: unknown[] }
+type Reply = ({ ok: true; preview: ExcelPreview } | { ok: false; causes: string[] }) & { inputUnchanged?: boolean }
+interface Outcome {
+ file: string
+ writer: string
+ features: string[]
+ variant: string
+ valid: boolean
+ status: string
+ detail: string
+ group: string
+ elapsedMs: number
+ opened: boolean
+ sha256: string
+}
+
+const { values, positionals } = parseArgs({
+ allowPositionals: true,
+ options: { only: { type: 'string' }, 'timeout-ms': { type: 'string', default: '10000' }, report: { type: 'string', default: 'results.json' }, 'baseline-ref': { type: 'string' } },
+})
+if (positionals.length !== 1) throw new Error('Usage: pnpm exec tsx packages/client/ui-sidebar-documentpreview/tests/fuzz/run.client.ts [--only substring]')
+const directory = resolve(positionals[0]!)
+const deadlineMs = Number(values['timeout-ms'])
+if (!Number.isSafeInteger(deadlineMs) || deadlineMs <= 0) throw new Error('timeout-ms must be a positive integer')
+const manifest = JSON.parse(await readFile(join(directory, 'manifest.json'), 'utf8')) as Manifest
+const cases = manifest.cases.filter(test => values.only === undefined || test.file.includes(values.only))
+if (cases.length === 0) throw new Error('No cases selected')
+const head = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim()
+const artifactRoot = fileURLToPath(new URL('../../.artifacts/', import.meta.url))
+let worker: Worker | undefined
+let baselineDirectory: string | undefined
+let source: string | undefined
+let parserRef = head
+if (values['baseline-ref'] !== undefined) {
+ parserRef = execFileSync('git', ['rev-parse', '--verify', `${values['baseline-ref']}^{commit}`], { encoding: 'utf8' }).trim()
+ const sourceRoot = new URL('../../src/client/excel/', import.meta.url).href
+ const baseline = execFileSync('git', ['show', `${parserRef}:packages/client/ui-sidebar-documentpreview/src/client/excel/xlsx.ts`], { encoding: 'utf8' }).replaceAll("from './", `from '${sourceRoot}`)
+ await mkdir(artifactRoot, { recursive: true })
+ baselineDirectory = await mkdtemp(join(artifactRoot, 'excel-baseline-'))
+ const path = join(baselineDirectory, 'xlsx.ts')
+ await writeFile(path, baseline)
+ source = pathToFileURL(path).href
+}
+
+async function parse(bytes: Uint8Array): Promise {
+ // Source conversion is the subject; a dedicated worker can be terminated even during synchronous ZIP/XML work.
+ worker ??= new Worker(new URL('./excel-worker.client.ts', import.meta.url), { execArgv: ['--import', 'tsx/esm'], workerData: { source }, resourceLimits: { maxOldGenerationSizeMb: 256 } })
+ const current = worker
+ return new Promise((resolveReply) => {
+ const cleanup = (): void => {
+ clearTimeout(timer)
+ current.off('message', message)
+ current.off('error', error)
+ current.off('exit', exit)
+ }
+ const message = (reply: Reply): void => { cleanup(); resolveReply(reply) }
+ const error = (cause: Error): void => {
+ cleanup()
+ worker = undefined
+ void current.terminate().then(() => { resolveReply({ ok: false, causes: [`WorkerError: ${cause.message}`] }) })
+ }
+ const exit = (code: number): void => {
+ cleanup()
+ worker = undefined
+ resolveReply({ ok: false, causes: [`WorkerExit: ${code}`] })
+ }
+ const timer = setTimeout(() => {
+ cleanup()
+ worker = undefined
+ void current.terminate().then(() => { resolveReply({ ok: false, causes: [`Timeout: ${deadlineMs}ms`] }) })
+ }, deadlineMs)
+ current.once('message', message)
+ current.once('error', error)
+ current.once('exit', exit)
+ current.postMessage(bytes, [bytes.buffer])
+ })
+}
+
+function inspect(test: Case, reply: Reply): Pick {
+ if (reply.inputUnchanged === false) return { status: 'mutated-source', detail: 'Conversion mutated the borrowed input bytes', group: 'mutated-source' }
+ if (!reply.ok) {
+ const detail = reply.causes.join(' <- ')
+ const controlled = /^ExcelPreviewError: (invalid|tooLarge|timeout|encoding)$/.test(reply.causes[0] ?? '')
+ const status = test.valid ? 'rejected-valid' : controlled ? 'pass' : 'unclassified-error'
+ const variant = test.variant.includes('sheet-prefix') ? 'sheet-prefix' : test.variant.includes('sheet-filename') ? 'sheet-filename' : test.variant.includes('relationship-dot') ? 'relationship-dot' : 'content'
+ return { status, detail, group: `${status}:${variant}:${reply.causes.at(-1)}` }
+ }
+ if (!test.valid) return { status: 'accepted-corrupt', detail: 'Independent reader or ZIP relationship validation rejected this input; permissive acceptance requires inspection.', group: 'accepted-corrupt' }
+ for (const expected of test.cells) {
+ const sheet = reply.preview.sheets.find(sheet => sheet.name === expected.sheet)
+ const cell = sheet?.celldata?.find(cell => cell.r === expected.r && cell.c === expected.c)?.v
+ const actual = cell?.v ?? null
+ const same = typeof expected.value === 'number' && typeof actual === 'number'
+ ? Math.abs(actual - expected.value) <= Math.max(1, Math.abs(expected.value)) * 1e-12
+ : actual === expected.value
+ if (!same || (expected.formula !== undefined && expected.formula !== cell?.f)) {
+ const detail = JSON.stringify({ expected, actual: cell ?? null })
+ const category = expected.formula !== undefined ? 'formula-cache' : test.variant.startsWith('temporal') ? 'temporal' : 'value'
+ return { status: 'value-mismatch', detail, group: `value-mismatch:${category}:${JSON.stringify(expected.value)}:${JSON.stringify(actual)}` }
+ }
+ }
+ if (values['baseline-ref'] !== undefined) return { status: 'pass', detail: '', group: 'pass' }
+ const actual = reply.preview.unsupportedFeatures
+ if (new Set(actual).size !== actual.length || JSON.stringify(actual) !== JSON.stringify(test.unsupportedFeatures)) {
+ const detail = JSON.stringify({ expected: test.unsupportedFeatures, actual })
+ return { status: 'notice-mismatch', detail, group: `notice-mismatch:${detail}` }
+ }
+ return { status: 'pass', detail: '', group: 'pass' }
+}
+
+const outcomes: Outcome[] = []
+try {
+ for (const test of cases) {
+ const bytes = new Uint8Array(await readFile(join(directory, test.file)))
+ const sha256 = createHash('sha256').update(bytes).digest('hex')
+ const start = performance.now()
+ const reply = await parse(bytes)
+ outcomes.push({
+ file: test.file, writer: test.writer, features: test.features, variant: test.variant, valid: test.valid,
+ ...inspect(test, reply), elapsedMs: Math.round(performance.now() - start), opened: reply.ok, sha256,
+ })
+ if (outcomes.length % 100 === 0) console.log(JSON.stringify({ completed: outcomes.length, total: cases.length, findings: outcomes.filter(item => item.status !== 'pass').length }))
+ }
+} finally {
+ await worker?.terminate()
+ if (baselineDirectory !== undefined) {
+ const child = relative(artifactRoot, baselineDirectory)
+ if (child === '' || child.startsWith('..') || isAbsolute(child)) throw new Error('Baseline cleanup escaped its artifact directory')
+ await rm(baselineDirectory, { recursive: true })
+ }
+}
+
+const counts: Record = {}
+const groups = new Map()
+for (const outcome of outcomes) {
+ counts[outcome.status] = (counts[outcome.status] ?? 0) + 1
+ if (outcome.status !== 'pass') groups.set(outcome.group, [...(groups.get(outcome.group) ?? []), outcome])
+}
+const representatives: { group: string; count: number; example: string; detail: string }[] = []
+await mkdir(join(directory, 'repros'), { recursive: true })
+for (const [group, members] of groups) {
+ const sorted = [...members].sort((a, b) => a.features.length - b.features.length
+ || Number(a.variant.includes('random')) - Number(b.variant.includes('random')) || a.file.localeCompare(b.file))
+ const first = sorted[0]!
+ await copyFile(join(directory, first.file), join(directory, 'repros', first.file))
+ representatives.push({ group, count: members.length, example: `repros/${first.file}`, detail: first.detail })
+}
+const report = {
+ head, parserRef, seed: manifest.seed, writers: manifest.writers, selected: cases.length, counts,
+ worktreeStatus: execFileSync('git', ['status', '--short'], { encoding: 'utf8' }).trim(),
+ validOpened: outcomes.filter(outcome => outcome.valid && outcome.opened).length,
+ validRejected: outcomes.filter(outcome => outcome.valid && !outcome.opened).length,
+ generatorErrors: manifest.generatorErrors, representatives, outcomes,
+}
+await writeFile(join(directory, values.report), `${JSON.stringify(report, null, 2)}\n`)
+console.log(JSON.stringify({ report: join(directory, values.report), head, selected: cases.length, counts, representatives }, null, 2))
+if (outcomes.some(outcome => outcome.status !== 'pass' && outcome.status !== 'accepted-corrupt')) process.exitCode = 1
diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml
index d6ed363040..397f847dab 100644
--- a/packages/client/ui-theme/README.i18n.yaml
+++ b/packages/client/ui-theme/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-theme/README.md
-README.md: 3961c16f862ed83d8b64b1a899f24f254e99ee63
-README.zh.md: 22fc05f7a82fe549cb8e77a2772b62bfd8b15baa
+README.md: f8db4c3c353aa9b6854c05288316babd198475d7
+README.zh.md: 135878213908ea11093d267c361cf2f80b4d768a
diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md
index 3961c16f86..f8db4c3c35 100644
--- a/packages/client/ui-theme/README.md
+++ b/packages/client/ui-theme/README.md
@@ -51,7 +51,7 @@ The service owns theme and font-size state and publishes snapshots. The ui-layou
### Stylesheets
-`src/styles/` holds six sheets imported in order by ui-theme's dynamic client entry: `base.css`, `corner-shape.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. The client bundle compiles and injects them as plugin-owned global styles, so unload and HMR remove them with ui-theme. `scrollbar.css` consumes the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them. Status marks use their own semantic state tokens.
+`src/styles/` holds six sheets imported in order by ui-theme's dynamic client entry: `base.css`, `corner-shape.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. The client bundle compiles and injects them as plugin-owned global styles, so unload and HMR remove them with ui-theme. `scrollbar.css` consumes the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them. Status marks use their own semantic state tokens. `design-platform.css` also owns the code-diff fill aliases and their static alpha palette entries; `shiki.css` owns syntax colors.
`brand-font.css` exports the local Montserrat Light, Regular and Medium faces (normal style, weights 300, 400 and 500), with `montserrat-light.woff2`, `montserrat-regular.woff2`, `montserrat-medium.woff2` and its SIL Open Font License in `lib/styles/`. Desktop bundles the same stylesheet, font and license for offline welcome brand text; ordinary UI keeps its system font stack.
diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md
index 22fc05f7a8..1358782139 100644
--- a/packages/client/ui-theme/README.zh.md
+++ b/packages/client/ui-theme/README.zh.md
@@ -51,7 +51,7 @@ kind: "package-reference"
### 样式表
-`src/styles/` 下有六张样式表,由 ui-theme 的动态客户端 entry 依次导入:`base.css`、`corner-shape.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。客户端 bundle 将其编译并注入为插件持有的全局样式,因此卸载与 HMR(热模块替换)会随 ui-theme 一同移除。`scrollbar.css` 消费 `--dsw-alias-scrollbar-*` token,必须排在声明这些 token 的 `design-platform.css` 之后。状态标记使用各自的语义状态 token。
+`src/styles/` 下有六张样式表,由 ui-theme 的动态客户端 entry 依次导入:`base.css`、`corner-shape.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。客户端 bundle 将其编译并注入为插件持有的全局样式,因此卸载与 HMR(热模块替换)会随 ui-theme 一同移除。`scrollbar.css` 消费 `--dsw-alias-scrollbar-*` token,必须排在声明这些 token 的 `design-platform.css` 之后。状态标记使用各自的语义状态 token。`design-platform.css` 还负责代码差异底色的别名及其静态透明度色阶;`shiki.css` 负责语法颜色。
`brand-font.css` 导出本地 Montserrat Light、Regular 和 Medium 字体(正体、字重 300、400 和 500),`lib/styles/` 同时提供 `montserrat-light.woff2`、`montserrat-regular.woff2`、`montserrat-medium.woff2` 及其 SIL Open Font License。Desktop 将同一份样式表、字体和许可证打包,用于欢迎页品牌文字的离线显示;普通界面保留系统字体栈。
diff --git a/packages/client/ui-theme/src/styles/design-platform.css b/packages/client/ui-theme/src/styles/design-platform.css
index 4cc18f3be5..929b24be6c 100644
--- a/packages/client/ui-theme/src/styles/design-platform.css
+++ b/packages/client/ui-theme/src/styles/design-platform.css
@@ -34,6 +34,8 @@ body {
--dsw-static-green-100: rgb(230, 250, 237);
--dsw-static-green-400: rgb(78, 209, 126);
--dsw-static-green-500: rgb(34, 197, 94);
+ --dsw-static-green-500-a08: rgb(34 197 94 / 8%);
+ --dsw-static-green-500-a12: rgb(34 197 94 / 12%);
--dsw-static-green-900: rgb(35, 60, 44);
--dsw-static-neutral-00: rgb(255, 255, 255);
--dsw-static-neutral-1000: rgb(0, 0, 0);
@@ -72,9 +74,11 @@ body {
--dsw-static-neutral-bluish-950: rgb(21, 21, 23);
--dsw-static-red-100: rgb(254, 226, 226);
--dsw-static-red-400: rgb(242, 90, 90);
+ --dsw-static-red-400-a12: rgb(242 90 90 / 12%);
--dsw-static-red-500: rgb(239, 68, 68);
--dsw-static-red-50: rgb(254, 242, 242);
--dsw-static-red-600: rgb(236, 19, 19);
+ --dsw-static-red-600-a08: rgb(236 19 19 / 8%);
--dsw-static-red-900: rgb(87, 12, 12);
}
@@ -111,6 +115,8 @@ body[data-ds-dark-theme] {
--dsw-static-green-100: rgb(230, 250, 237);
--dsw-static-green-400: rgb(78, 209, 126);
--dsw-static-green-500: rgb(34, 197, 94);
+ --dsw-static-green-500-a08: rgb(34 197 94 / 8%);
+ --dsw-static-green-500-a12: rgb(34 197 94 / 12%);
--dsw-static-green-900: rgb(35, 60, 44);
--dsw-static-neutral-00: rgb(255, 255, 255);
--dsw-static-neutral-1000: rgb(0, 0, 0);
@@ -149,9 +155,11 @@ body[data-ds-dark-theme] {
--dsw-static-neutral-bluish-950: rgb(21, 21, 23);
--dsw-static-red-100: rgb(254, 226, 226);
--dsw-static-red-400: rgb(242, 90, 90);
+ --dsw-static-red-400-a12: rgb(242 90 90 / 12%);
--dsw-static-red-500: rgb(239, 68, 68);
--dsw-static-red-50: rgb(254, 242, 242);
--dsw-static-red-600: rgb(236, 19, 19);
+ --dsw-static-red-600-a08: rgb(236 19 19 / 8%);
--dsw-static-red-900: rgb(87, 12, 12);
}
@@ -226,6 +234,8 @@ body {
--dsw-alias-scrollbar-hover-l2: var(--dsw-static-neutral-300);
--dsw-alias-state-business-primary: var(--dsw-static-deepseek-500);
--dsw-alias-state-business-tertiary: var(--dsw-static-deepseek-100);
+ --dsw-alias-code-diff-added: var(--dsw-static-green-500-a08);
+ --dsw-alias-code-diff-deleted: var(--dsw-static-red-600-a08);
--dsw-alias-state-error-primary: var(--dsw-static-red-600);
--dsw-alias-state-error-secondary: var(--dsw-static-red-400);
--dsw-alias-state-idle-primary: var(--dsw-static-neutral-300);
@@ -324,6 +334,8 @@ body[data-ds-dark-theme] {
--dsw-alias-scrollbar-hover-l2: var(--dsw-static-neutral-550);
--dsw-alias-state-business-primary: var(--dsw-static-deepseek-400);
--dsw-alias-state-business-tertiary: var(--dsw-static-deepseek-800);
+ --dsw-alias-code-diff-added: var(--dsw-static-green-500-a12);
+ --dsw-alias-code-diff-deleted: var(--dsw-static-red-400-a12);
--dsw-alias-state-error-primary: var(--dsw-static-red-400);
--dsw-alias-state-error-secondary: var(--dsw-static-red-400);
--dsw-alias-state-idle-primary: var(--dsw-static-neutral-600);
diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml
index 45be7c0b8f..b5dd281e0e 100644
--- a/packages/client/ui-tool/README.i18n.yaml
+++ b/packages/client/ui-tool/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-tool/README.md
-README.md: e26a277028d41420c4796b4678a2d6897d592872
-README.zh.md: 63d34c640571b0b3e363f6968221d15eea70ad49
+README.md: 622dbd89c93f8a21bece72f2267613a4fa81b4d6
+README.zh.md: c3fec14cb7a5d2e2448743204b59dcac73380849
diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md
index e26a277028..622dbd89c9 100644
--- a/packages/client/ui-tool/README.md
+++ b/packages/client/ui-tool/README.md
@@ -68,7 +68,7 @@ Tool owner props forward Chat's stable `useDisclosure` Hook through root and nes
Every card is read in place in the call tree; there is no second, full-height presentation of a selected call. Row renderers share one pure card model for each terminal, read, diff, search, and web card, and the image card's gallery renders through the tool-owned `tool.call.images` slot. These models validate raw call arguments, result content, failure state, persisted metadata, PTC dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. A file-path summary opens the file through the owner's `openFile`, which the chat view routes to the right Sidebar's text preview; `inspect` opens the trajectory view and is absent when that View is unavailable; cards then omit Inspect. Card-specific limits and fallback rules for the terminal, diff, read, search, and web cards remain in [the ui-primitives README](../ui-primitives/README.md); the image card's model in this package carries its own fallback rules.
-Chat diff cards keep nine rows before folding, enough for a file header, one removed/added pair, and three context lines on either side. The collapsed row and expanded footer share the primitive's exact or coarse-replacement counts.
+Chat diff cards keep nine rows before folding, enough for a file header, one removed/added pair, and three context lines on either side. The tool row shows the primitive's exact or coarse-replacement counts; the expanded card contains the diff body without a totals footer.
An Auto denial takes precedence over keyed specialized views. Its generic row preserves the call identity, omits raw arguments, and normalizes the stored reason only for display: trim surrounding whitespace and collapse line separators to spaces, with localized fallback for an empty result. Session and SDK error details keep the original reason.
diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md
index 63d34c6405..c3fec14cb7 100644
--- a/packages/client/ui-tool/README.zh.md
+++ b/packages/client/ui-tool/README.zh.md
@@ -68,7 +68,7 @@ Tool 所有者属性将 Chat 注入的稳定 `useDisclosure` 钩子传给根调
每张卡片都直接在调用树中查看;选中调用后不会再显示第二个全高视图。行 renderer 为 terminal、read、diff、search 和 web 卡片各复用同一个纯 card model,image 卡片的图库经由工具自有 `tool.call.images` slot 渲染。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、PTC dispatch 的 `parentCallId` 与会话路径信息。不受支持或格式错误的输入使用压平的工具结果文本。文件路径摘要经属主的 `openFile` 打开文件,chat 视图把它路由到右侧 Sidebar 的文本预览;`inspect` 打开轨迹视图;该视图不可用时不提供此回调,卡片随之隐藏 Inspect。terminal、diff、read、search 与 web 卡片的上限与 fallback 规则仍由 [ui-primitives README](../ui-primitives/README.zh.md) 负责;image 卡片的 fallback 规则由本包内的 card model 自行承载。
-Chat diff 卡片在折叠前保留九行,足以容纳文件标题、一对删除与新增行及两侧各三行上下文。折叠工具行与展开卡片底部采用原语一致的精确或粗粒度替换统计。
+Chat diff 卡片在折叠前保留九行,足以容纳文件标题、一对删除与新增行及两侧各三行上下文。工具行显示原语提供的精确或粗粒度替换统计;展开卡片包含差异正文,不显示底部统计。
Auto 拒绝优先于按工具名选择的专门视图。其通用行保留调用身份、省略原始参数,并且只在显示时归一化存储的理由:去除首尾空白,把行分隔符折叠为空格,结果为空时使用本地化通用理由。Session 与 SDK 错误详情保留原始理由。
diff --git a/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css b/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css
index 824bd18fde..c813c772cc 100644
--- a/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css
+++ b/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css
@@ -232,15 +232,13 @@
overflow: auto;
}
+.root .item:has(> .code:only-child) { padding: 0; }
+.root .item > .code:only-child { margin: 0; }
+
.root .group .item:has(> .code:only-child) {
padding: 4px 0 0 20px;
}
-.root .group .item > .code:only-child { margin: 0; }
-
-.root .code [data-code-block-banner] { padding: 4px 10px; }
-.root .code pre { padding: 6px 10px 8px; }
-
.path {
flex: 1;
min-width: 0;
diff --git a/packages/client/ui-tool/src/client/tool/components/ToolDetails.tsx b/packages/client/ui-tool/src/client/tool/components/ToolDetails.tsx
index 801be3c7a0..618d998ce3 100644
--- a/packages/client/ui-tool/src/client/tool/components/ToolDetails.tsx
+++ b/packages/client/ui-tool/src/client/tool/components/ToolDetails.tsx
@@ -4,7 +4,7 @@ import {
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import type { OpenFileOptions } from '@deepseek-ai/dsh-client-ui-chat/client'
-import { markdownLabels } from '../models/primitive-labels.ts'
+import { codeToolbarLabels, markdownLabels } from '../models/primitive-labels.ts'
import css from './ToolDetails.module.css'
/** One recorded entity, receipt, or collapsible group of related values. */
@@ -89,6 +89,7 @@ function DetailItem({ item, t, onOpenFile }: { item: ToolDetailItem } & DetailCo
{item.markdown !== undefined &&
}
{item.code !== undefined && (
diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css
index 1478b06961..5e54eeb7e5 100644
--- a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css
+++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css
@@ -324,12 +324,6 @@
color: var(--dsw-alias-label-tertiary);
}
-/* In-row code renders at the smaller code size (12/18) via each primitive's
- rebindable content-font seam; standalone markdown code blocks keep 13/22. */
-.codeBody {
- --dsl-code-block-content-font: var(--dsw-font-markdown-code-block-small);
-}
-
/* The terminal card scrolls its OUTPUT inside its own surface (same l1
hairline as the IN/OUT card): the banner stays pinned and the scrollbar
never rides over it. 224px = the 260px card cap minus the ~36px banner. */
diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx
index 88d7475e10..228b6e9a94 100644
--- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx
+++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx
@@ -16,7 +16,7 @@ import {
localizeTerminalCardModel, terminalBlockLabels, type TerminalCardModel,
} from '../models/terminal-card-model.ts'
import {
- diffBlockLabels, readBlockLabels, searchBlockLabels, webBlockLabels,
+ codeToolbarLabels, diffBlockLabels, readBlockLabels, searchBlockLabels, webBlockLabels,
} from '../models/primitive-labels.ts'
import type { AskQuestionCardModel } from '../models/ask-question-card-model.ts'
import {
@@ -171,9 +171,8 @@ export const ToolRow = memo(function ToolRow({
// amber while retaining the business icon and hidden state announcement.
const failureLine = state === 'error' ? errorSummary ?? normalSummary : null
const summaryText = failureLine ?? normalSummary
- // A diff row's collapsed line carries the card's +/- totals (the same
- // numbers the expanded footer prints) so the change size reads without
- // expanding; an explicit summarySuffix (none today on diff rows) wins.
+ // The tool row keeps the diff's +/- totals visible while its body is collapsed.
+ // An explicit summarySuffix overrides the diff totals.
const diffStat = useMemo(() => {
if (diffBody === null) return null
const { added, removed } = diffTotals(diffBody.card.diffs)
@@ -289,7 +288,8 @@ export const ToolRow = memo(function ToolRow({
<>
{variant === 'code' && bodyText !== null && (
-
+
)}
{(cardBody !== null || outputText !== null) && (
diff --git a/packages/client/ui-tool/src/client/tool/models/primitive-labels.ts b/packages/client/ui-tool/src/client/tool/models/primitive-labels.ts
index 4b48cf4f8e..4aa7477f4c 100644
--- a/packages/client/ui-tool/src/client/tool/models/primitive-labels.ts
+++ b/packages/client/ui-tool/src/client/tool/models/primitive-labels.ts
@@ -1,6 +1,7 @@
/** Localized copy adapters for Cordis-free UI primitives used by Tool cards. */
import type {
+ CodeToolbarLabels,
DiffBlockLabels,
MarkdownLabels,
ReadBlockLabels,
@@ -11,6 +12,15 @@ import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
type T = TranslateNS<'conversation'>
+/**
+ * Localize the shared code-card toolbar.
+ * @param t - Conversation locale seat.
+ * @returns Language fallback and wrapping actions.
+ */
+export function codeToolbarLabels(t: T): CodeToolbarLabels {
+ return { codeLabel: t('codeBlock.title'), wrapLabel: t('codeBlock.wrap'), unwrapLabel: t('codeBlock.unwrap') }
+}
+
/**
* Build localized Markdown chrome labels.
* @param t - Conversation locale seat.
@@ -18,7 +28,7 @@ type T = TranslateNS<'conversation'>
*/
export function markdownLabels(t: T): MarkdownLabels {
return {
- code: { copyLabel: t('copy'), copiedLabel: t('copied') },
+ code: { copyLabel: t('copy'), copiedLabel: t('copied'), toolbarLabels: codeToolbarLabels(t) },
footnotes: t('markdown.footnotes'),
}
}
@@ -30,13 +40,13 @@ export function markdownLabels(t: T): MarkdownLabels {
*/
export function diffBlockLabels(t: T): DiffBlockLabels {
return {
+ ...codeToolbarLabels(t),
copy: t('copy'),
copied: t('copied'),
collapseAria: t('diff.collapseAria'),
expandAria: count => t('diff.expandAria', { count }),
collapse: t('collapse'),
expand: count => t('diff.expandRest', { count }),
- files: count => t(count === 1 ? 'diff.files.one' : 'diff.files.other', { count }),
}
}
@@ -47,6 +57,7 @@ export function diffBlockLabels(t: T): DiffBlockLabels {
*/
export function readBlockLabels(t: T): ReadBlockLabels {
return {
+ ...codeToolbarLabels(t),
window: (shown, total) => t('read.window', { shown, total }),
copy: t('copy'),
copied: t('copied'),
diff --git a/packages/client/ui-tool/tests/diff-card.client.spec.tsx b/packages/client/ui-tool/tests/diff-card.client.spec.tsx
index ca0dff809c..7be88bb9d6 100644
--- a/packages/client/ui-tool/tests/diff-card.client.spec.tsx
+++ b/packages/client/ui-tool/tests/diff-card.client.spec.tsx
@@ -225,7 +225,7 @@ describe('FileMutationRow diff card', () => {
toggleRow(view)
expect(view.container.querySelector('[data-diff]')).not.toBeNull()
expect(view.getByText('hello fixture')).toBeTruthy()
- expect(view.getByText('复制')).toBeTruthy()
+ expect(view.getByRole('button', { name: '复制' })).toBeTruthy()
})
it('the summary is a path link that opens the tool path through the host', () => {
@@ -246,9 +246,9 @@ describe('FileMutationRow diff card', () => {
}), 'write')} />)
// The collapsed row already carries the card's +/- totals beside the path.
expect(view.getByText('+1 -0')).toBeTruthy()
- // The footer counts live inside the collapsed diff card.
toggleRow(view)
- expect(view.getByText('└ +1 -0 · 1 个文件')).toBeTruthy()
+ expect(view.getAllByText('+1 -0')).toHaveLength(1)
+ expect(view.getByText('hello fixture')).toBeTruthy()
})
it('reflects the run state on its leading slot', () => {
diff --git a/packages/client/ui-tool/tests/tool-row.client.spec.tsx b/packages/client/ui-tool/tests/tool-row.client.spec.tsx
index 050f8cc42b..723f2eb359 100644
--- a/packages/client/ui-tool/tests/tool-row.client.spec.tsx
+++ b/packages/client/ui-tool/tests/tool-row.client.spec.tsx
@@ -301,7 +301,7 @@ describe('ToolRow', () => {
expect(view.getByText('List files')).toBeTruthy()
})
- it('excludes shared context from collapsed edit totals and the expanded card', () => {
+ it('shows totals once and shared context once when an edit expands', () => {
const view = render( {
expect(view.getByText('+1 -1')).toBeTruthy()
expect(view.container.querySelector('[data-diff]')).toBeNull()
fireEvent.click(view.getByRole('button'))
- expect(view.getByText(/└ \+1 -1/)).toBeTruthy()
+ expect(view.getAllByText('+1 -1')).toHaveLength(1)
expect(view.getAllByText('start')).toHaveLength(1)
expect(view.getAllByText('end')).toHaveLength(1)
expect(view.getByText('old', { exact: true })).toBeTruthy()
diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx
index 4b99d20991..4068deb520 100644
--- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx
+++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx
@@ -131,7 +131,7 @@ type QuestionFlowProps =
function QuestionFlow({ pending, t, useStore, actions }: QuestionFlowProps) {
const questions = pending.questions
const markdownLabels = useMemo(() => ({
- code: { copyLabel: t('copy'), copiedLabel: t('copied') },
+ code: { copyLabel: t('copy'), copiedLabel: t('copied'), toolbarLabels: { codeLabel: t('codeBlock.title'), wrapLabel: t('codeBlock.wrap'), unwrapLabel: t('codeBlock.unwrap') } },
footnotes: t('markdown.footnotes'),
}), [t])
const initialProgress = useMemo(() => ({
diff --git a/packages/credentials/deepseek-account-platform/tests/account.spec.ts b/packages/credentials/deepseek-account-platform/tests/account.spec.ts
index 291c163820..aa5761b9c5 100644
--- a/packages/credentials/deepseek-account-platform/tests/account.spec.ts
+++ b/packages/credentials/deepseek-account-platform/tests/account.spec.ts
@@ -484,13 +484,16 @@ it('adds private deployment cookies to every Platform request without exposing t
expect(JSON.stringify(await f.account.getState())).not.toContain('test_gate')
await f.account.signOut()
await expect.poll(f.logoutCount).toBe(1)
- const paths = f.receivedHeaders.map(item => item.path)
+ const headers = f.receivedHeaders
+ const paths = headers.map(item => item.path)
+ // The profile and wallet queries are independent requests, so their arrival order is scheduler-dependent.
expect(paths.slice(0, 2)).toEqual(['/auth-api/v0/dsh/auth_init', '/auth-api/v0/dsh/auth_exchange'])
- // Profile and balance requests run concurrently.
expect(paths.slice(2, -1).sort()).toEqual(['/api/v0/users/get_user_summary', '/auth-api/v0/users/current'])
- expect(paths.slice(-1)).toEqual(['/auth-api/v0/users/logout'])
- expect(f.receivedHeaders.every(item => item.cookie === 'test_gate=synthetic')).toBe(true)
- expect(f.receivedHeaders.slice(2).every(item => item.authorization === 'dsh_mock_test')).toBe(true)
+ expect(paths.at(-1)).toBe('/auth-api/v0/users/logout')
+ expect(headers.every(item => item.cookie === 'test_gate=synthetic')).toBe(true)
+ const grantedPaths = new Set(['/auth-api/v0/users/current', '/api/v0/users/get_user_summary', '/auth-api/v0/users/logout'])
+ expect(headers.filter(item => grantedPaths.has(item.path ?? '')).every(item => item.authorization === 'dsh_mock_test')).toBe(true)
+ expect(headers.filter(item => !grantedPaths.has(item.path ?? '')).every(item => item.authorization === undefined)).toBe(true)
})
it('rejects reserved, duplicate and malformed deployment headers without disclosing values', () => {
diff --git a/packages/credentials/deepseek-account/src/types.ts b/packages/credentials/deepseek-account/src/types.ts
index f59c959567..7245ba1418 100644
--- a/packages/credentials/deepseek-account/src/types.ts
+++ b/packages/credentials/deepseek-account/src/types.ts
@@ -27,7 +27,7 @@ export interface AccountView {
/** Platform account identifier supplied by the current-user endpoint. */
export type AccountUserId = Branded<'AccountUserId'>
-/** Display identity and support UID; contacts retain Platform masking. */
+/** Display identity; contacts retain Platform masking. */
export interface AccountProfile {
readonly id: AccountUserId | null
readonly name: string | null
diff --git a/packages/experimental/speech-to-text/README.i18n.yaml b/packages/experimental/speech-to-text/README.i18n.yaml
index 2aa9341cd0..855300b8f1 100644
--- a/packages/experimental/speech-to-text/README.i18n.yaml
+++ b/packages/experimental/speech-to-text/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/experimental/speech-to-text/README.md
-README.md: 64202aba4ae7e0ffc47c86243d818caa8f85a9e4
-README.zh.md: 38466f18a0670a252958c6b51c17457fcf80f14c
+README.md: 3dafb5c7cfb9c3946be329e6e67b876ddebfb322
+README.zh.md: d665e5c05f7c220ecd838108b97ae886af7d952a
diff --git a/packages/experimental/speech-to-text/README.md b/packages/experimental/speech-to-text/README.md
index 64202aba4a..3dafb5c7cf 100644
--- a/packages/experimental/speech-to-text/README.md
+++ b/packages/experimental/speech-to-text/README.md
@@ -37,7 +37,7 @@ Load through the [voice input bundle](../voice-input-bundle/README.md), or compo
`resolve()` captures the provider instance, recording and language. `transcribe()` rejects a withdrawn or replaced registration. A registration disposer closes admission, aborts accepted requests and joins provider settlement; providers must honor cancellation. No fallback selects a different recognizer or uploads audio. No runtime invariant companion is published because the registry is the sole source of provider and preparation observations.
-`defaultProvider` and `language` are volatile Config fields: `configure()` writes the supplied fields into this plugin's profile entry through the `settings` service, and the running instance reads the updated values without remounting; composition defaults apply until an override is saved. `configure()` fails without `settings` or a profile entry, and rejects a language unsupported by the selected provider before saving. Providers advertise accepted language hints through `languages`; `resolve()` validates the selected hint before transcription. Provider-owned preparation is observed through complete `SpeechSnapshot` values; closing an observer never cancels preparation.
+`defaultProvider` and `language` are volatile Config fields: `configure()` writes the supplied fields into this plugin's profile entry through the `settings` service, and the running instance reads the updated values without remounting; composition defaults apply until an override is saved. Settings addresses the entry by its configured id (`entry.options.id`), without the Loader's Include path. `configure()` fails without `settings` or a profile entry, and rejects a language unsupported by the selected provider before saving. Providers advertise accepted language hints through `languages`; `resolve()` validates the selected hint before transcription. Provider-owned preparation is observed through complete `SpeechSnapshot` values; closing an observer never cancels preparation.
The `./wave` helper validates canonical 16 kHz mono PCM16 WAV for the Remote consumer and the native recognition process. Both reject inconsistent headers and lengths before decoding samples.
diff --git a/packages/experimental/speech-to-text/README.zh.md b/packages/experimental/speech-to-text/README.zh.md
index 38466f18a0..d665e5c05f 100644
--- a/packages/experimental/speech-to-text/README.zh.md
+++ b/packages/experimental/speech-to-text/README.zh.md
@@ -37,7 +37,7 @@ kind: "package-reference"
`resolve()` 捕获 Provider 实例、录音和语言。`transcribe()` 拒绝已撤销或替换的注册。注册的清理函数先拒绝新请求,再取消已接收的请求并等待 Provider 完成;Provider 必须响应取消。服务不会回退到其他识别器或上传音频。不发布运行时不变量伴随模块,因为注册表是 Provider 与准备状态观测的唯一来源。
-`defaultProvider` 与 `language` 是 volatile Config 字段:`configure()` 通过 `settings` 服务把传入的字段写入本插件的 profile 条目,运行中的实例无需重挂载即可读到更新值;保存覆盖值之前使用组合配置默认值。没有 `settings` 或 profile 条目时 `configure()` 失败,并在保存前拒绝所选 Provider 不支持的语言。Provider 通过 `languages` 公布支持的语言提示;`resolve()` 在转写前校验所选提示。观察者通过完整 `SpeechSnapshot` 接收 Provider 拥有的准备状态;关闭观察者不会取消准备。
+`defaultProvider` 与 `language` 是 volatile Config 字段:`configure()` 通过 `settings` 服务把传入的字段写入本插件的 profile 条目,运行中的实例无需重挂载即可读到更新值;保存覆盖值之前使用组合配置默认值。Settings 使用条目配置中的 id(`entry.options.id`)定位条目,不包含 Loader 的 Include 路径。没有 `settings` 或 profile 条目时 `configure()` 失败,并在保存前拒绝所选 Provider 不支持的语言。Provider 通过 `languages` 公布支持的语言提示;`resolve()` 在转写前校验所选提示。观察者通过完整 `SpeechSnapshot` 接收 Provider 拥有的准备状态;关闭观察者不会取消准备。
`./wave` 辅助函数为 Remote 消费者与原生识别进程校验规范的 16 kHz 单声道 PCM16 WAV。两者均在解码采样前拒绝不一致的头部和长度。
diff --git a/packages/experimental/speech-to-text/package.json b/packages/experimental/speech-to-text/package.json
index e517471e13..b32066e0d7 100644
--- a/packages/experimental/speech-to-text/package.json
+++ b/packages/experimental/speech-to-text/package.json
@@ -46,6 +46,8 @@
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-app-boot": "workspace:^",
+ "@deepseek-ai/dsh-config-editor": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/cordis-plugin-loader": "workspace:^"
},
diff --git a/packages/experimental/speech-to-text/src/index.ts b/packages/experimental/speech-to-text/src/index.ts
index b0db14b83a..ba4bb4fc9a 100644
--- a/packages/experimental/speech-to-text/src/index.ts
+++ b/packages/experimental/speech-to-text/src/index.ts
@@ -41,12 +41,12 @@ export default class SpeechToText extends Service {
private readonly providers = new Map()
private readonly listeners = new Set<() => void>()
private readonly lifetime = new AbortController()
- /** Profile entry `configure()` writes to; absent when the plugin was mounted without Loader. */
+ /** Profile-local entry id used by Settings; absent when the plugin was mounted without Loader. */
private readonly entryId: string | undefined
constructor(ctx: Context, private readonly config: Config) {
super(ctx, 'speechToText')
- this.entryId = ctx.fiber.entry?.id
+ this.entryId = ctx.fiber.entry?.options.id
ctx.on('loader/volatile-update', () => { this.changed() })
ctx.effect(() => async () => {
this.lifetime.abort(new Error('Speech service disposed'))
diff --git a/packages/experimental/speech-to-text/tests/configuration.spec.ts b/packages/experimental/speech-to-text/tests/configuration.spec.ts
new file mode 100644
index 0000000000..05a2b97f35
--- /dev/null
+++ b/packages/experimental/speech-to-text/tests/configuration.spec.ts
@@ -0,0 +1,59 @@
+/** Speech preferences persisted through the profile's Loader and Settings services. */
+import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { expect, it } from 'vitest'
+import { boot, initProfile, readProfilePatches, type ProfileContext } from '@deepseek-ai/dsh-app-boot'
+import ConfigEditor from '@deepseek-ai/dsh-config-editor'
+import Settings from '@deepseek-ai/dsh-settings'
+import SpeechToText from '../src/index.ts'
+import type { SpeechProviderId } from '../src/types.ts'
+
+it('saves language and provider preferences without remounting and restores them at restart', async () => {
+ const home = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-speech-configuration-')))
+ let ctx: Awaited> | undefined
+ try {
+ const dir = join(home, 'profiles', 'test')
+ initProfile(dir, ['test-bundle'])
+ const bundle = join(dir, 'node_modules', 'test-bundle')
+ mkdirSync(bundle, { recursive: true })
+ writeFileSync(join(home, 'package.json'), '{"name":"test-installation"}\n')
+ writeFileSync(join(bundle, 'package.json'), JSON.stringify({ name: 'test-bundle', version: '1.0.0', dsh: { bundle: { patch: 'cordis.patch.yml' } } }))
+ writeFileSync(join(bundle, 'cordis.patch.yml'), readFileSync(new URL('./fixtures/selection.patch.yml', import.meta.url)))
+ writeFileSync(join(dir, 'cordis.yml'), '[]\n')
+ const profile: ProfileContext = {
+ name: 'test', startedBundles: ['test-bundle'], dir, patchPath: join(dir, 'cordis.patch.yml'),
+ installAnchor: join(home, 'package.json'), cwd: home, home, overlays: [], telemetryDisabledEnv: undefined,
+ }
+ const start = () => boot('test', join(dir, 'cordis.yml'), readProfilePatches('test', profile), (root) => {
+ root.provide('profileContext', profile)
+ Object.assign(root.loader.builtins, { editor: ConfigEditor, settings: Settings, speech: SpeechToText })
+ })
+ ctx = await start()
+ const speech = ctx.get('speechToText')!
+ const languages = ['auto', 'zh', 'en', 'yue', 'ja', 'ko']
+ for (const id of ['sensevoice-local', 'another-provider']) speech.register({
+ info: { id: id as SpeechProviderId, name: id, location: 'host-local', languages },
+ transcribe: async () => { throw new Error('Changing preferences must not transcribe audio') },
+ })
+ const entry = ctx.configEditor.entries().find(row => row.options.id === 'speech-to-text')!
+ expect(entry.id).toBe('include:speech-to-text')
+ const fiber = entry.fiber
+ for (const language of [...languages, 'zh']) {
+ await speech.configure({ language })
+ expect(speech.snapshot().selection).toEqual({ providerId: 'sensevoice-local', language })
+ }
+ await speech.configure({ providerId: 'another-provider' as SpeechProviderId })
+ expect(speech.snapshot().selection).toEqual({ providerId: 'another-provider', language: 'zh' })
+ expect(entry.fiber === fiber).toBe(true)
+ const saved = readFileSync(profile.patchPath, 'utf8')
+ expect(saved).toContain('id: speech-to-text')
+ expect(saved).toContain('defaultProvider: another-provider')
+ expect(saved).toContain('language: zh')
+ await ctx.fiber.dispose()
+ ctx = await start()
+ expect(ctx.get('speechToText')!.snapshot().selection).toEqual({ providerId: 'another-provider', language: 'zh' })
+ } finally {
+ try { await ctx?.fiber.dispose() } finally { rmSync(home, { recursive: true, force: true }) }
+ }
+})
diff --git a/packages/experimental/speech-to-text/tests/fixtures/selection.patch.yml b/packages/experimental/speech-to-text/tests/fixtures/selection.patch.yml
new file mode 100644
index 0000000000..693db59237
--- /dev/null
+++ b/packages/experimental/speech-to-text/tests/fixtures/selection.patch.yml
@@ -0,0 +1,9 @@
+- insert:
+ - id: config-editor
+ name: cordis:editor
+ - id: settings
+ name: cordis:settings
+ - id: speech-to-text
+ name: cordis:speech
+ config:
+ defaultProvider: sensevoice-local
diff --git a/packages/experimental/tool-agent-team/README.i18n.yaml b/packages/experimental/tool-agent-team/README.i18n.yaml
index 2b30aa79fc..4e2ee828f7 100644
--- a/packages/experimental/tool-agent-team/README.i18n.yaml
+++ b/packages/experimental/tool-agent-team/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/experimental/tool-agent-team/README.md
-README.md: bfa3fc7f466d4b7f405231de3de4cf648fc690de
-README.zh.md: 990e786fd69877af17cef339a696bb5e93d5925c
+README.md: 0918686d28f7212558aafa988a4b968c18aa8664
+README.zh.md: 8243f8471a67fe55d5a5d368d98e20c5519f42f3
diff --git a/packages/experimental/tool-agent-team/README.md b/packages/experimental/tool-agent-team/README.md
index bfa3fc7f46..0918686d28 100644
--- a/packages/experimental/tool-agent-team/README.md
+++ b/packages/experimental/tool-agent-team/README.md
@@ -125,7 +125,7 @@ Read these pages when the package-level contract is not enough. They move from t
#### What the model sees
-One shared system policy states the explicit-delegation requirement, shared-cwd behavior, filesystem stale-version recovery, Bash/formatter/codegen risk, task and write-scope coordination, Steer delivery, the no-retry mailbox rule, and the Lead's duty to wait before answering. All nine Team schemas are identical for Leads and teammates; execution enforces Lead-only operations. `spawn_teammate` prefixes its initial user message with `\nYou are teammate "".\n`, followed by a blank line and the task. The prefix contains no Team id and works when runtime context is disabled. Forks inherit history without an additional Lead identity message.
+One shared system policy states the explicit-delegation requirement, shared-cwd behavior, filesystem stale-version recovery, Bash/formatter/codegen risk, task and write-scope coordination, Steer delivery, the no-retry mailbox rule, and the Lead's duty to wait before answering. All nine Team schemas are identical for Leads and teammates; execution enforces Lead-only operations. `spawn_teammate` prefixes its initial user message with `\nYou are teammate "".\nYour Team Lead is named "lead".\nUse list_agents({}) to find your teammates and their names.\nTo message your Team Lead, use send_message({ target: "lead", message: "..." }).\nTo message another teammate, use send_message({ target: "", message: "..." }).\n`, followed by a blank line and the task. The prefix contains no Team id and works when runtime context is disabled. Forks inherit history without an additional Lead identity message.
#### Token effect
diff --git a/packages/experimental/tool-agent-team/README.zh.md b/packages/experimental/tool-agent-team/README.zh.md
index 990e786fd6..8243f8471a 100644
--- a/packages/experimental/tool-agent-team/README.zh.md
+++ b/packages/experimental/tool-agent-team/README.zh.md
@@ -125,7 +125,7 @@ member scope 上的一个 `team:policy` 段落说明共享的协作规则;固
#### 模型看到什么
-一段共享 system 策略会说明显式 delegation 要求、共享 cwd 行为、文件陈旧版本恢复、Bash/formatter/codegen 风险、task/write-scope 协调、Steer 投递、mailbox 不重试规则,以及 Lead 必须在回答前等待。Lead 与 teammate 的全部九个 Team schema 相同;执行时检查仅限 Lead 的操作权限。`spawn_teammate` 在初始 user 消息前加上 `\nYou are teammate "".\n`,接着是一个空行和任务。该前缀不含 Team id,禁用运行时上下文时也能生效。fork 继承历史,不额外添加 Lead 身份消息。
+一段共享 system 策略会说明显式 delegation 要求、共享 cwd 行为、文件陈旧版本恢复、Bash/formatter/codegen 风险、task/write-scope 协调、Steer 投递、mailbox 不重试规则,以及 Lead 必须在回答前等待。Lead 与 teammate 的全部九个 Team schema 相同;执行时检查仅限 Lead 的操作权限。`spawn_teammate` 在初始 user 消息前加上 `\nYou are teammate "".\nYour Team Lead is named "lead".\nUse list_agents({}) to find your teammates and their names.\nTo message your Team Lead, use send_message({ target: "lead", message: "..." }).\nTo message another teammate, use send_message({ target: "", message: "..." }).\n`,接着是一个空行和任务。该前缀不含 Team id,禁用运行时上下文时也能生效。fork 继承历史,不额外添加 Lead 身份消息。
#### Token 影响
diff --git a/packages/experimental/tool-agent-team/src/index.ts b/packages/experimental/tool-agent-team/src/index.ts
index 3ab4b77235..75dee88c70 100644
--- a/packages/experimental/tool-agent-team/src/index.ts
+++ b/packages/experimental/tool-agent-team/src/index.ts
@@ -193,7 +193,15 @@ function install(agent: Agent, ctx: Context, config: Required): () => vo
name: args.name,
description: args.description,
prompt: [
- { type: 'text', text: `\nYou are teammate "${args.name.trim()}".\n\n\n` },
+ { type: 'text', text: `
+You are teammate "${args.name.trim()}".
+Your Team Lead is named "lead".
+Use list_agents({}) to find your teammates and their names.
+To message your Team Lead, use send_message({ target: "lead", message: "..." }).
+To message another teammate, use send_message({ target: "", message: "..." }).
+
+
+` },
{ type: 'text', text: args.prompt },
],
context,
diff --git a/packages/experimental/tool-agent-team/tests/tool-team.spec.ts b/packages/experimental/tool-agent-team/tests/tool-team.spec.ts
index 198e39f35f..c1c98c19e2 100644
--- a/packages/experimental/tool-agent-team/tests/tool-team.spec.ts
+++ b/packages/experimental/tool-agent-team/tests/tool-team.spec.ts
@@ -238,12 +238,12 @@ describe('dsh-tool-team', () => {
expect(renderPrompt(childAssembly)).toBe(leadPrompt)
expect(renderContextSnapshot(childAssembly)).not.toContain('team:identity')
expect(child.session.deriveMessages().some(message => message.content.some(block =>
- block.type === 'text' && block.text === '\nYou are teammate "tool-worker".\n\n\n'))).toBe(true)
+ block.type === 'text' && block.text === '\nYou are teammate "tool-worker".\nYour Team Lead is named "lead".\nUse list_agents({}) to find your teammates and their names.\nTo message your Team Lead, use send_message({ target: "lead", message: "..." }).\nTo message another teammate, use send_message({ target: "", message: "..." }).\n\n\n'))).toBe(true)
const initialPrompt = child.session.snapshotEvents().find(event => event.type === 'user/message'
&& event.data.source.kind === 'user')
expect(initialPrompt?.type === 'user/message'
? initialPrompt.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
- : []).toEqual(['\nYou are teammate "tool-worker".\n\n\n', 'stay available'])
+ : []).toEqual(['\nYou are teammate "tool-worker".\nYour Team Lead is named "lead".\nUse list_agents({}) to find your teammates and their names.\nTo message your Team Lead, use send_message({ target: "lead", message: "..." }).\nTo message another teammate, use send_message({ target: "", message: "..." }).\n\n\n', 'stay available'])
const denied = await execute(ctx, child, 'spawn_teammate', {
name: 'nested', description: 'not allowed', prompt: 'no',
@@ -279,14 +279,14 @@ describe('dsh-tool-team', () => {
expect(JSON.stringify(childRequest.messages)).not.toContain('Parent task')
}
expect(childRequest.messages.at(-1)?.content.slice(0, 2)).toEqual([
- { type: 'text', text: '\nYou are teammate "reviewer".\n\n\n' },
+ { type: 'text', text: '\nYou are teammate "reviewer".\nYour Team Lead is named "lead".\nUse list_agents({}) to find your teammates and their names.\nTo message your Team Lead, use send_message({ target: "lead", message: "..." }).\nTo message another teammate, use send_message({ target: "", message: "..." }).\n\n\n' },
{ type: 'text', text: 'Review the work' },
])
await using persisted = await ctx.sessionPersistence.open(childId, 'read')
const { events } = await persisted.read()
const initial = events.findLast(event => event.type === 'user/message' && event.data.source.kind === 'user')
expect(initial?.type === 'user/message' ? initial.data.content : []).toEqual([
- { type: 'text', text: '\nYou are teammate "reviewer".\n\n\n' },
+ { type: 'text', text: '\nYou are teammate "reviewer".\nYour Team Lead is named "lead".\nUse list_agents({}) to find your teammates and their names.\nTo message your Team Lead, use send_message({ target: "lead", message: "..." }).\nTo message another teammate, use send_message({ target: "", message: "..." }).\n\n\n' },
{ type: 'text', text: 'Review the work' },
])
})
@@ -377,7 +377,7 @@ describe('dsh-tool-team', () => {
})
const childId = spawnedChildId(ctx, lead, spawned)
await waitNoAgent(ctx, childId)
- const reminder = '\nYou are teammate "reviewer".\n\n\n'
+ const reminder = '\nYou are teammate "reviewer".\nYour Team Lead is named "lead".\nUse list_agents({}) to find your teammates and their names.\nTo message your Team Lead, use send_message({ target: "lead", message: "..." }).\nTo message another teammate, use send_message({ target: "", message: "..." }).\n\n\n'
const first = serializeRequest(adapter.requests[0]!).messages
expect(first.at(-1)?.content.slice(0, 2)).toEqual([{ type: 'text', text: reminder }, { type: 'text', text: 'Review the work' }])
expect(adapter.requests[1]!.messages.filter(message => message.content.some(block =>
@@ -419,7 +419,7 @@ describe('dsh-tool-team', () => {
expect(spawned.isError, text(spawned)).toBe(false)
await waitNoAgent(ctx, spawnedChildId(ctx, lead, spawned))
expect(serializeRequest(adapter.requests[0]!).messages.at(-1)?.content).toEqual([
- { type: 'text', text: '\nYou are teammate "reviewer".\n\n\n' },
+ { type: 'text', text: '\nYou are teammate "reviewer".\nYour Team Lead is named "lead".\nUse list_agents({}) to find your teammates and their names.\nTo message your Team Lead, use send_message({ target: "lead", message: "..." }).\nTo message another teammate, use send_message({ target: "", message: "..." }).\n\n\n' },
{ type: 'text', text: 'Review the work' },
])
})
diff --git a/packages/extensions/ui-cordis/src/client/CordisDefineRow.tsx b/packages/extensions/ui-cordis/src/client/CordisDefineRow.tsx
index 0c0806a1ef..b36b62b599 100644
--- a/packages/extensions/ui-cordis/src/client/CordisDefineRow.tsx
+++ b/packages/extensions/ui-cordis/src/client/CordisDefineRow.tsx
@@ -134,6 +134,7 @@ export function CordisDefineRow({
aria-labelledby={`${sourcePanelId}-${activeSource}`}
>
(: