From b96d670ecc751a0f74134b81d190e8b3664852f1 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:25:11 -0700 Subject: [PATCH 01/25] fix(workspace-context): commit only represented changes --- .../context/workspace-context/src/files.ts | 6 +-- .../context/workspace-context/src/render.ts | 51 ++++++++++++++----- .../tests/workspace-context.spec.ts | 39 +++++++++++++- 3 files changed, 76 insertions(+), 20 deletions(-) diff --git a/packages/context/workspace-context/src/files.ts b/packages/context/workspace-context/src/files.ts index 3e6a3d5de8..a70fdc5485 100644 --- a/packages/context/workspace-context/src/files.ts +++ b/packages/context/workspace-context/src/files.ts @@ -12,7 +12,7 @@ import { assertNever } from '@deepseek-ai/dsh-llm' import { dshHomeDisplay } from '@deepseek-ai/dsh-paths' import { resolveConfig, resolveDiscoveryConfig, type ResolvedConfig } from './config.ts' import { trimmedInstructionDigest } from './digest.ts' -import { decodeScopeKey, renderWorkspaceContext, USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE, type RenderedWorkspaceContext } from './render.ts' +import { decodeScopeKey, renderWorkspaceInstructionSet, USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE, type RenderedWorkspaceContext } from './render.ts' /** An instruction candidate identified by absolute and model-facing paths. */ export interface InstructionFile { @@ -413,9 +413,7 @@ export async function loadBaselineInstructionSet( } const deduped = dedupInstructionFilesByDirectory(loaded) if (deduped.length === 0) return undefined - const rendered = renderWorkspaceContext(deduped, { maxBytes: config.maxBytes }) - const omitted = new Set(rendered.omitted.map(file => file.absolutePath)) - return { rendered, included: deduped.filter(file => !omitted.has(file.absolutePath)) } + return renderWorkspaceInstructionSet(deduped, { maxBytes: config.maxBytes }) } /** diff --git a/packages/context/workspace-context/src/render.ts b/packages/context/workspace-context/src/render.ts index 9ab311e942..05dba00736 100644 --- a/packages/context/workspace-context/src/render.ts +++ b/packages/context/workspace-context/src/render.ts @@ -28,6 +28,11 @@ export interface RenderedWorkspaceContext { truncated: TruncatedInstruction[] } +interface RenderedInstructionContext extends RenderedWorkspaceContext { + /** Original files whose file-specific semantic section survived rendering. */ + included: LoadedInstructionFile[] +} + /** Structured dynamic state persisted outside model-visible prompt prose. */ export interface WorkspaceInstructionChange { action: 'set' | 'replace' | 'remove' @@ -174,13 +179,10 @@ export function renderInstructionChanges( }, } const rendered = renderInstructionContext(items.map(item => item.file), maxBytes, style) - const omitted = new Set(rendered.omitted.map(file => file.absolutePath)) + const included = new Set(rendered.included.map(file => file.absolutePath)) return { text: rendered.text, - // TODO(rendered-change-proof): retain a transition only when its semantic - // notice survived rendering; a tiny compact budget can currently return - // unrelated notice text while still committing the full state transition. - changes: items.filter(item => !omitted.has(item.file.absolutePath)).map(item => item.change), + changes: items.filter(item => included.has(item.file.absolutePath)).map(item => item.change), } } @@ -248,22 +250,26 @@ function renderInstructionContext( files: LoadedInstructionFile[], maxBytes: number, style: RenderStyle, -): RenderedWorkspaceContext { - if (maxBytes <= 0 || !Number.isFinite(maxBytes)) return { text: '', omitted: files, truncated: [] } +): RenderedInstructionContext { + if (maxBytes <= 0 || !Number.isFinite(maxBytes)) { + return { text: '', omitted: files, truncated: [], included: [] } + } const fullText = buildInstructionText(files, maxBytes, [], [], style) - if (byteLength(fullText) <= maxBytes) return { text: fullText, omitted: [], truncated: [] } + if (byteLength(fullText) <= maxBytes) { + return { text: fullText, omitted: [], truncated: [], included: files } + } for (let start = 1; start < files.length; start += 1) { const included = files.slice(start) const omitted = files.slice(0, start).map(file => ({ absolutePath: file.absolutePath, displayPath: file.displayPath })) const suffixText = buildInstructionText(included, maxBytes, omitted, [], style) - if (byteLength(suffixText) <= maxBytes) return { text: suffixText, omitted, truncated: [] } + if (byteLength(suffixText) <= maxBytes) return { text: suffixText, omitted, truncated: [], included } } const mostSpecific = files.at(-1) /* v8 ignore next -- callers only reach this after a non-empty fullText was built. */ - if (mostSpecific === undefined) return { text: '', omitted: [], truncated: [] } + if (mostSpecific === undefined) return { text: '', omitted: [], truncated: [], included: [] } const omitted = files.slice(0, -1).map(file => ({ absolutePath: file.absolutePath, displayPath: file.displayPath })) for (const candidateStyle of [style, { ...style, intro: COMPACT_WORKSPACE_CONTEXT_INTRO }]) { @@ -274,7 +280,7 @@ function renderInstructionContext( includedBytes: byteLength(truncatedFile.content), }] const text = buildInstructionText([truncatedFile], maxBytes, omitted, truncated, candidateStyle) - if (byteLength(text) <= maxBytes) return { text, omitted, truncated } + if (byteLength(text) <= maxBytes) return { text, omitted, truncated, included: [mostSpecific] } } const truncated = [{ @@ -286,9 +292,26 @@ function renderInstructionContext( const compactWithHeading = escapeInstructionFrameBody( [compactNotice, style.section(withTruncatedContent(mostSpecific, 0))].join('\n\n'), ) - if (byteLength(compactWithHeading) <= maxBytes) return { text: compactWithHeading, omitted, truncated } + if (byteLength(compactWithHeading) <= maxBytes) { + return { text: compactWithHeading, omitted, truncated, included: [mostSpecific] } + } const text = byteLength(compactNotice) <= maxBytes ? compactNotice : truncateUtf8(compactNotice, maxBytes) - return { text, omitted, truncated } + return { text, omitted, truncated, included: [] } +} + +/** + * Render a baseline together with the exact source files semantically represented in it. + * @param files - loaded files ordered from broadest to most specific. + * @param options - required rendering byte budget. + * @returns bounded public rendering plus the original files whose semantic sections survived. + * @internal + */ +export function renderWorkspaceInstructionSet( + files: LoadedInstructionFile[], + options: { maxBytes: number }, +): { rendered: RenderedWorkspaceContext; included: LoadedInstructionFile[] } { + const { included, ...rendered } = renderInstructionContext(files, options.maxBytes, BASELINE_RENDER_STYLE) + return { rendered, included } } /** @@ -301,5 +324,5 @@ export function renderWorkspaceContext( files: LoadedInstructionFile[], options: { maxBytes: number }, ): RenderedWorkspaceContext { - return renderInstructionContext(files, options.maxBytes, BASELINE_RENDER_STYLE) + return renderWorkspaceInstructionSet(files, options).rendered } diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index edbb4b3145..c6fcb80650 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -828,6 +828,38 @@ describe('workspace context rendering', () => { expect(Buffer.byteLength(rendered.text, 'utf8')).toBe(20) }) + it('does not commit a change when only the generic compact notice survives', () => { + const change = { + action: 'set' as const, + scope: sk('pkg', 'AGENTS.md'), + path: 'pkg/AGENTS.md', + digest: 'digest', + } + const rendered = renderInstructionChanges([{ + change, + file: { absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'x'.repeat(1000) }, + }], 20) + + expect(rendered.text).toBe('Workspace instructio') + expect(rendered.changes).toEqual([]) + }) + + it('commits a change when its file-specific semantic section survives truncation', () => { + const change = { + action: 'replace' as const, + scope: sk('pkg', 'AGENTS.md'), + path: 'pkg/AGENTS.md', + digest: 'digest', + } + const rendered = renderInstructionChanges([{ + change, + file: { absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'x'.repeat(1000) }, + }], 400) + + expect(rendered.text).toContain('Updated instructions from: pkg/AGENTS.md') + expect(rendered.changes).toEqual([change]) + }) + it('keeps compact truncation notices within budget when a multibyte display path is cut', () => { const rendered = renderWorkspaceContext([ { absolutePath: '/repo/路径/AGENTS.md', displayPath: '路径/AGENTS.md', content: 'x'.repeat(1000) }, @@ -1298,9 +1330,12 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) - expect(agent.session.events.filter(event => + const contexts = agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user', - )).toHaveLength(1) + ) + expect(contexts).toHaveLength(1) + const source = contexts[0]?.type === 'user/message' ? contexts[0].data.source : undefined + expect(source?.kind === 'workspace-instructions' ? source.changes : undefined).toEqual([]) expect(derivedText(agent)).not.toContain('workspace-context:') } finally { await rm(root, { recursive: true, force: true }) From 36b8efd2c6afc5d42e00b16559e242767b6caead Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:07:38 -0700 Subject: [PATCH 02/25] fix(workspace-context): require rendered instruction content --- .../context/workspace-context/src/files.ts | 15 +++-- .../context/workspace-context/src/render.ts | 47 ++++++++++---- .../tests/workspace-context.spec.ts | 64 ++++++++++++++++++- 3 files changed, 103 insertions(+), 23 deletions(-) diff --git a/packages/context/workspace-context/src/files.ts b/packages/context/workspace-context/src/files.ts index a70fdc5485..ef6d61f327 100644 --- a/packages/context/workspace-context/src/files.ts +++ b/packages/context/workspace-context/src/files.ts @@ -12,7 +12,14 @@ import { assertNever } from '@deepseek-ai/dsh-llm' import { dshHomeDisplay } from '@deepseek-ai/dsh-paths' import { resolveConfig, resolveDiscoveryConfig, type ResolvedConfig } from './config.ts' import { trimmedInstructionDigest } from './digest.ts' -import { decodeScopeKey, renderWorkspaceInstructionSet, USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE, type RenderedWorkspaceContext } from './render.ts' +import { + decodeScopeKey, + renderWorkspaceInstructionSet, + USER_GLOBAL_DIRECTORY, + USER_GLOBAL_FILE, + type RenderedInstructionSet, + type RenderedWorkspaceContext, +} from './render.ts' /** An instruction candidate identified by absolute and model-facing paths. */ export interface InstructionFile { @@ -54,12 +61,6 @@ interface LoadOptions extends DiscoverOptions { maxSourceBytes?: number } -/** Rendered baseline plus the files that survived byte budgeting. */ -export interface RenderedInstructionSet { - rendered: RenderedWorkspaceContext - included: LoadedInstructionFile[] -} - /** Tri-state scope probe that distinguishes confirmed absence from provider failure. */ export type ScopeInstructionProbe = | { kind: 'present'; file: ProbedInstructionFile } diff --git a/packages/context/workspace-context/src/render.ts b/packages/context/workspace-context/src/render.ts index 05dba00736..62cf5cbbdf 100644 --- a/packages/context/workspace-context/src/render.ts +++ b/packages/context/workspace-context/src/render.ts @@ -29,7 +29,17 @@ export interface RenderedWorkspaceContext { } interface RenderedInstructionContext extends RenderedWorkspaceContext { - /** Original files whose file-specific semantic section survived rendering. */ + /** + * Original files whose file-specific section text survived rendering. This + * is not the complement of `omitted`: a truncated file may be represented + * here and in `truncated`, while a notice-only file appears in neither. + */ + represented: LoadedInstructionFile[] +} + +/** Rendered baseline plus the files whose current content survived budgeting. */ +export interface RenderedInstructionSet { + rendered: RenderedWorkspaceContext included: LoadedInstructionFile[] } @@ -56,6 +66,12 @@ function byteLength(value: string): number { return Buffer.byteLength(value, 'utf8') } +function zeroContentTruncatedPaths(truncated: TruncatedInstruction[]): Set { + return new Set(truncated + .filter(item => item.originalBytes > 0 && item.includedBytes === 0) + .map(item => item.displayPath)) +} + function truncateUtf8(value: string, maxBytes: number): string { let truncated = Buffer.from(value, 'utf8').subarray(0, Math.max(0, maxBytes)).toString('utf8') while (byteLength(truncated) > maxBytes) { @@ -179,10 +195,14 @@ export function renderInstructionChanges( }, } const rendered = renderInstructionContext(items.map(item => item.file), maxBytes, style) - const included = new Set(rendered.included.map(file => file.absolutePath)) + const represented = new Set(rendered.represented.map(file => file.absolutePath)) + const contentOmitted = zeroContentTruncatedPaths(rendered.truncated) return { text: rendered.text, - changes: items.filter(item => included.has(item.file.absolutePath)).map(item => item.change), + changes: items + .filter(item => represented.has(item.file.absolutePath) + && (item.change.action === 'remove' || !contentOmitted.has(item.file.displayPath))) + .map(item => item.change), } } @@ -252,24 +272,24 @@ function renderInstructionContext( style: RenderStyle, ): RenderedInstructionContext { if (maxBytes <= 0 || !Number.isFinite(maxBytes)) { - return { text: '', omitted: files, truncated: [], included: [] } + return { text: '', omitted: files, truncated: [], represented: [] } } const fullText = buildInstructionText(files, maxBytes, [], [], style) if (byteLength(fullText) <= maxBytes) { - return { text: fullText, omitted: [], truncated: [], included: files } + return { text: fullText, omitted: [], truncated: [], represented: files } } for (let start = 1; start < files.length; start += 1) { const included = files.slice(start) const omitted = files.slice(0, start).map(file => ({ absolutePath: file.absolutePath, displayPath: file.displayPath })) const suffixText = buildInstructionText(included, maxBytes, omitted, [], style) - if (byteLength(suffixText) <= maxBytes) return { text: suffixText, omitted, truncated: [], included } + if (byteLength(suffixText) <= maxBytes) return { text: suffixText, omitted, truncated: [], represented: included } } const mostSpecific = files.at(-1) /* v8 ignore next -- callers only reach this after a non-empty fullText was built. */ - if (mostSpecific === undefined) return { text: '', omitted: [], truncated: [], included: [] } + if (mostSpecific === undefined) return { text: '', omitted: [], truncated: [], represented: [] } const omitted = files.slice(0, -1).map(file => ({ absolutePath: file.absolutePath, displayPath: file.displayPath })) for (const candidateStyle of [style, { ...style, intro: COMPACT_WORKSPACE_CONTEXT_INTRO }]) { @@ -280,7 +300,7 @@ function renderInstructionContext( includedBytes: byteLength(truncatedFile.content), }] const text = buildInstructionText([truncatedFile], maxBytes, omitted, truncated, candidateStyle) - if (byteLength(text) <= maxBytes) return { text, omitted, truncated, included: [mostSpecific] } + if (byteLength(text) <= maxBytes) return { text, omitted, truncated, represented: [mostSpecific] } } const truncated = [{ @@ -293,10 +313,10 @@ function renderInstructionContext( [compactNotice, style.section(withTruncatedContent(mostSpecific, 0))].join('\n\n'), ) if (byteLength(compactWithHeading) <= maxBytes) { - return { text: compactWithHeading, omitted, truncated, included: [mostSpecific] } + return { text: compactWithHeading, omitted, truncated, represented: [mostSpecific] } } const text = byteLength(compactNotice) <= maxBytes ? compactNotice : truncateUtf8(compactNotice, maxBytes) - return { text, omitted, truncated, included: [] } + return { text, omitted, truncated, represented: [] } } /** @@ -309,9 +329,10 @@ function renderInstructionContext( export function renderWorkspaceInstructionSet( files: LoadedInstructionFile[], options: { maxBytes: number }, -): { rendered: RenderedWorkspaceContext; included: LoadedInstructionFile[] } { - const { included, ...rendered } = renderInstructionContext(files, options.maxBytes, BASELINE_RENDER_STYLE) - return { rendered, included } +): RenderedInstructionSet { + const { represented, ...rendered } = renderInstructionContext(files, options.maxBytes, BASELINE_RENDER_STYLE) + const contentOmitted = zeroContentTruncatedPaths(rendered.truncated) + return { rendered, included: represented.filter(file => !contentOmitted.has(file.displayPath)) } } /** diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index c6fcb80650..0331fcea8e 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -860,6 +860,26 @@ describe('workspace context rendering', () => { expect(rendered.changes).toEqual([change]) }) + it.each([ + { action: 'set' as const, maxBytes: 327, heading: 'Additional instructions from:' }, + { action: 'replace' as const, maxBytes: 256, heading: 'Updated instructions from:' }, + ])('does not commit a $action change when its heading survives with zero content bytes', ({ action, maxBytes, heading }) => { + const change = { + action, + scope: sk('pkg', 'AGENTS.md'), + path: 'pkg/AGENTS.md', + digest: 'digest', + } + const rendered = renderInstructionChanges([{ + change, + file: { absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'x'.repeat(1000) }, + }], maxBytes) + + expect(rendered.text).toContain(heading) + expect(rendered.text).toContain('from 1000 to 0 bytes') + expect(rendered.changes).toEqual([]) + }) + it('keeps compact truncation notices within budget when a multibyte display path is cut', () => { const rendered = renderWorkspaceContext([ { absolutePath: '/repo/路径/AGENTS.md', displayPath: '路径/AGENTS.md', content: 'x'.repeat(1000) }, @@ -1318,14 +1338,14 @@ describe('workspace context request injection', () => { } }) - it('does not expose state markers when a tiny budget reduces the baseline contribution', async () => { + it('does not expose state markers when a baseline heading survives with zero content bytes', async () => { const root = await tempRepo() const home = await tempRepo() try { await mkdir(join(root, '.git'), { recursive: true }) - await write(join(root, 'AGENTS.md'), 'repo rule') + await write(join(root, 'AGENTS.md'), 'x'.repeat(1000)) const ctx = new Context() - await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 10 }) + await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 120 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -1336,6 +1356,8 @@ describe('workspace context request injection', () => { expect(contexts).toHaveLength(1) const source = contexts[0]?.type === 'user/message' ? contexts[0].data.source : undefined expect(source?.kind === 'workspace-instructions' ? source.changes : undefined).toEqual([]) + expect(derivedText(agent)).toContain('Instructions from: AGENTS.md') + expect(derivedText(agent)).toContain('from 1000 to 0 bytes') expect(derivedText(agent)).not.toContain('workspace-context:') } finally { await rm(root, { recursive: true, force: true }) @@ -3385,6 +3407,42 @@ describe('dynamic nested workspace context injection', () => { } }) + it('retries a nested instruction touch when only a truncated budget notice was rendered', async () => { + const root = join(await tempRepo(), 'virtual-repo') + const home = join(await tempRepo(), 'virtual-home') + const ctx = new Context() + try { + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(RecordingFileSystem) + const fs = ctx.fs as RecordingFileSystem + const instructionPath = join(root, 'pkg/AGENTS.md') + fs.entries.set(join(root, '.git'), { type: 'directory' }) + fs.entries.set(instructionPath, { type: 'file', content: 'x'.repeat(1000) }) + fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) + await ctx.plugin(ToolFs) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 20 }) + const agent = stubAgent(root) + + const first = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('read-tiny-budget-1'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, + }) + const second = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('read-tiny-budget-2'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, + }) + + expect(first.additionalContexts).toBeUndefined() + expect(second.additionalContexts).toBeUndefined() + expect(fs.readTargets.filter(path => path === instructionPath)).toHaveLength(2) + } finally { + await ctx.fiber.dispose() + await rm(dirname(root), { recursive: true, force: true }) + await rm(dirname(home), { recursive: true, force: true }) + } + }) + it('does not attach nested instructions after a failed file read', async () => { const root = await tempRepo() const home = await tempRepo() From 10c13391774d48f9b219678a377aba7984505f0b Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:33:33 -0700 Subject: [PATCH 03/25] docs(workspace-context): define partial render commits --- .../feature/2026-06-24-workspace-context.i18n.yaml | 4 ++-- .../feature/2026-06-24-workspace-context.md | 2 +- .../feature/2026-06-24-workspace-context.zh.md | 2 +- packages/context/workspace-context/README.i18n.yaml | 4 ++-- packages/context/workspace-context/README.md | 2 +- packages/context/workspace-context/README.zh.md | 2 +- packages/context/workspace-context/src/render.ts | 9 ++++++--- .../tests/workspace-context.spec.ts | 13 +++++++++---- 8 files changed, 23 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml index 073aa9fa4b..71a11a4666 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.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 .agents/notes/implemented/feature/2026-06-24-workspace-context.md -2026-06-24-workspace-context.md: 8baced0143abb38ff34d16a072761ec016a53d6e -2026-06-24-workspace-context.zh.md: 392d57f344b97c1816f691fef75440f815bccb50 +2026-06-24-workspace-context.md: 004792bbbcf6a99e2c5cdcd4c4d640d9c7d39877 +2026-06-24-workspace-context.zh.md: a7d0ea1884c8cb69c96e5192c0fbc1d2ae5ea4dd diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md index 8baced0143..004792bbbc 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md @@ -52,7 +52,7 @@ Every workspace context event stores versioned metadata with `{ action, scope, p At reconciliation time the plugin scans workspace-sourced `user/message` events and derives the latest state for each visible scope. A short per-session pending map begins only after the immutable top-level `tools/result` proves an `additionalContexts` entry survived every post-execute listener, then covers the interval before the loop appends that context to the log. Each entry records the open `{ turn, step }`: an equal durable `user/message` at or after its sequence boundary confirms and removes it, while a matching `step/end` arriving first means the loop discarded its context buffer, so the plugin removes both the pending entry and its version-cache fast path. A nested Code Mode result stages its changes under the parent's opaque execution token so repeated sub-dispatches in one run do not duplicate them; the parent result rolls that provisional state back and commits only contexts retained by outer policy. -An unchanged path and digest is suppressed. A logged removal is a tombstone, so a reappearing candidate becomes a new `set`. Resume works from persisted metadata. If compaction removes an instruction event from the visible surface, that state no longer suppresses a later load, matching the fact that the model can no longer see it. Only changes actually included under the byte budget enter metadata or pending state, so an omitted file remains eligible on a later touch. +An unchanged path and digest is suppressed. A logged removal is a tombstone, so a reappearing candidate becomes a new `set`. Resume works from persisted metadata. If compaction removes an instruction event from the visible surface, that state no longer suppresses a later load, matching the fact that the model can no longer see it. A change enters metadata or pending state only when its file-specific section retains at least one content byte, or when the original content is genuinely empty. Partial truncation commits the full-content digest once any byte survives; zero-content truncation remains eligible on a later touch. The initial baseline's typed changes are comparison state only while its event remains in the visible session surface. A later successful filesystem touch re-adds an unchanged baseline scope after compaction, or appends baseline edits or removals as dynamic messages; it never rewrites the original event. The in-memory scope marker and provider-version cache only select and accelerate probes, so neither can suppress context the model no longer sees. During resumed baseline preparation the plugin also reconciles visible dynamic scopes, so nested changes made while the agent was offline can append an update before the first resumed request. diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md index 392d57f344..a7d0ea1884 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md @@ -52,7 +52,7 @@ shell 命令不会触发发现。本地 bash 调用会启动全新的 shell, 协调时,插件扫描带工作区来源的 `user/message` 事件,并派生每个可见作用域的最新状态。一个简短的逐会话待处理映射只会在不可变的顶层 `tools/result` 证明某个 `additionalContexts` 条目经过所有 post-execute 监听器后仍然保留时开始记录;随后,它覆盖循环将该上下文追加到日志之前的间隔。每个条目记录开启状态的 `{ turn, step }`:如果相同的持久 `user/message` 出现在其序列边界或之后,该条目得到确认并被移除;如果匹配的 `step/end` 先到达,则说明循环丢弃了上下文缓冲区,插件会同时移除待处理条目及其版本缓存快速路径。嵌套的 Code Mode 结果会把变更暂存在父级的不透明执行 token 下,确保一次运行中的重复子分发不会产生重复项;父级结果会回滚这份临时状态,并且只提交外层策略保留的上下文。 -路径和 digest 均未变化时会被抑制。日志中的移除操作是一条墓碑记录,因此重新出现的候选项会成为新的 `set`。恢复操作从持久化元数据继续工作。如果压缩从可见表面移除某条指令事件,该状态不再抑制后续加载,这与模型已经无法看见它的事实一致。只有真正纳入字节预算的变更才会进入元数据或待处理状态,因此被省略的文件在之后的触碰中仍有资格加载。 +路径和 digest 均未变化时会被抑制。日志中的移除操作是一条墓碑记录,因此重新出现的候选项会成为新的 `set`。恢复操作从持久化元数据继续工作。如果压缩从可见表面移除某条指令事件,该状态不再抑制后续加载,这与模型已经无法看见它的事实一致。变更只有在对应文件专属段落保留至少一个内容字节,或原始内容确实为空时,才会进入元数据或待处理状态。只要任一字节保留下来,部分截断就会提交完整内容 digest;零内容截断仍可在后续触碰中处理。 只有当初始基线事件仍在可见会话表层中时,其类型化变更才用作比较状态。后续成功的文件系统触碰会在压缩后重新添加未变化的基线 scope,或把基线编辑或移除操作追加为动态消息;它绝不重写原始事件。内存中的 scope 标记和提供方版本 cache 只用于选择探测对象并加速探测,因此二者都不能抑制模型已无法看见的上下文。恢复时准备基线的过程中,插件还会协调可见的动态作用域,因此 agent 离线期间发生的嵌套变更可以在第一次恢复请求前追加更新。 diff --git a/packages/context/workspace-context/README.i18n.yaml b/packages/context/workspace-context/README.i18n.yaml index 102c991391..85980bfcf0 100644 --- a/packages/context/workspace-context/README.i18n.yaml +++ b/packages/context/workspace-context/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/context/workspace-context/README.md -README.md: 2669422ec1fa7a74ba329cd96ee6b7e5e6da7e9d -README.zh.md: e9fab4c6998f1193068389b41bdd7fa7d8c98dca +README.md: dc8889cf99b1af691c72af621e78dfb93228ad92 +README.zh.md: ef1dd31ecd5b368aa3e92176edda5aa53268f7d8 diff --git a/packages/context/workspace-context/README.md b/packages/context/workspace-context/README.md index 2669422ec1..dc8889cf99 100644 --- a/packages/context/workspace-context/README.md +++ b/packages/context/workspace-context/README.md @@ -50,7 +50,7 @@ The plugin owns the complete `` framing, and every injected `us Model-visible text contains no hidden state markers. Each baseline or dynamic context event instead carries a typed `workspace-instructions` source with a list of `{ action, scope, path, digest? }` changes; the complete startup or resume baseline also carries `baseline: true`. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `user/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy. -An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope provider cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the typed source, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter the source, pending state, and version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates only the provider cache. +An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope provider cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the typed source, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. A model-visible change enters the source, pending state, and version cache only when its file-specific section retains at least one content byte, or when its original content is genuinely empty. Partial truncation records the complete-content digest once any content byte survives; truncation to zero remains eligible for a later touch, while a same-digest version refresh updates only the provider cache. The initial baseline event itself is not rewritten. Its typed changes remain authoritative only while that event is in the visible session surface; the next successful filesystem touch re-adds an unchanged baseline scope after compaction, or appends its replacement or removal. The in-memory scope marker and provider-version cache only select and accelerate probes. A hot plugin remount retains a baseline only when its typed event remains visible, while rebuilding current scope and version tracking; otherwise it injects a current baseline. A resumed loop always recomposes the current baseline and also reconciles still-visible dynamic scopes before its first request. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, or when a resumed loop prepares its baseline. diff --git a/packages/context/workspace-context/README.zh.md b/packages/context/workspace-context/README.zh.md index e9fab4c699..ef1dd31ecd 100644 --- a/packages/context/workspace-context/README.zh.md +++ b/packages/context/workspace-context/README.zh.md @@ -50,7 +50,7 @@ These instructions apply to work under `packages/app`. Use them as guidance when 模型可见文本不含隐藏状态标记。每个基线或动态上下文事件改为携带带类型的 `workspace-instructions` 来源,其中包含 `{ action, scope, path, digest? }` 变更列表;完整的启动或恢复基线还会携带 `baseline: true`。每次相关工具 touch 时,插件会从可见会话事件重建已加载状态,并叠加一个短暂内存 pending 窗口,用于不可变顶层 `tools/result` 上存在但 loop 尚未追加的上下文。匹配的持久 `user/message` 会确认 pending 转换。如果所属 `step/end` 在匹配上下文进入日志之前到达,插件会清除 pending 转换及其版本快速路径,使下一次成功 touch 可以重新加载。嵌套 Code Mode 结果会在外层执行 token 下暂存 pending 变更,用于抑制同次运行中的重复项;外层结果会回滚该状态,再只重新提交经过外层策略的上下文。 -路径与 SHA-1 内容 digest 都未变时,不会重复注入。每会话、每 scope 提供方 cache 只存储 `{ path, version, digest, trimmedDigest }`:当提供方的不透明 `FsVersion` 与有效可见状态都匹配时,对账会跳过内容读取;版本改变会在任何模型可见更新之前触发有界读取与 SHA-1 确认。`trimmedDigest` 是针对去除空白后内容的 SHA-1,也是每目录重复 key,因此较早候选文件与某个未更改文件的内容收敛后,后者仍可被移除。恢复可行,因为 SHA-1 状态持久化在带类型的来源中,而空的内存版本 cache 只会导致一次确认读取。压缩(compaction)会在 scope 的上下文事件离开可见表层后重新启用它,即使缓存版本未变。移除是 tombstone,因此候选文件之后重新出现时会重新加载。只有在字节预算内实际渲染的模型可见变更才会进入来源、pending 状态和版本 cache;已省略变更仍可在后续 touch 处理,而相同 digest 的版本刷新只更新提供方 cache。 +路径与 SHA-1 内容 digest 都未变时,不会重复注入。每会话、每 scope 提供方 cache 只存储 `{ path, version, digest, trimmedDigest }`:当提供方的不透明 `FsVersion` 与有效可见状态都匹配时,对账会跳过内容读取;版本改变会在任何模型可见更新之前触发有界读取与 SHA-1 确认。`trimmedDigest` 是针对去除空白后内容的 SHA-1,也是每目录重复 key,因此较早候选文件与某个未更改文件的内容收敛后,后者仍可被移除。恢复可行,因为 SHA-1 状态持久化在带类型的来源中,而空的内存版本 cache 只会导致一次确认读取。压缩(compaction)会在 scope 的上下文事件离开可见表层后重新启用它,即使缓存版本未变。移除是 tombstone,因此候选文件之后重新出现时会重新加载。模型可见变更只有在对应文件专属段落保留至少一个内容字节,或原始内容确实为空时,才会进入来源、pending 状态和版本 cache。只要任一内容字节保留下来,部分截断就会记录完整内容的 digest;截断到零字节则仍可在后续 touch 处理,而相同 digest 的版本刷新只更新提供方 cache。 初始基线事件自身不会被改写。其带类型的变更仅在该事件仍位于可见会话表层时才是权威状态;下一次成功的文件系统 touch 会在压缩后重新添加未变的基线 scope,或追加其替换或移除。内存中的 scope 标记和提供方版本 cache 只负责选择探测对象并加速探测。插件热重挂只有在其带类型的事件仍然可见时才保留基线,同时会重建当前 scope 与版本跟踪状态;否则会注入当前基线。恢复的 loop 始终重新组合当前基线,并在第一个请求前对账仍可见的动态 scope。没有文件 watcher,因此磁盘变更会在下一次成功 `read`、`write` 或 `edit` touch 时可见,也会在恢复 loop 准备基线时可见。 diff --git a/packages/context/workspace-context/src/render.ts b/packages/context/workspace-context/src/render.ts index 62cf5cbbdf..bd59decdb4 100644 --- a/packages/context/workspace-context/src/render.ts +++ b/packages/context/workspace-context/src/render.ts @@ -37,7 +37,10 @@ interface RenderedInstructionContext extends RenderedWorkspaceContext { represented: LoadedInstructionFile[] } -/** Rendered baseline plus the files whose current content survived budgeting. */ +/** + * Rendered baseline plus files whose section retained content, or whose original content was empty. + * A partially rendered file keeps the digest of its complete original content. + */ export interface RenderedInstructionSet { rendered: RenderedWorkspaceContext included: LoadedInstructionFile[] @@ -201,7 +204,7 @@ export function renderInstructionChanges( text: rendered.text, changes: items .filter(item => represented.has(item.file.absolutePath) - && (item.change.action === 'remove' || !contentOmitted.has(item.file.displayPath))) + && !contentOmitted.has(item.file.displayPath)) .map(item => item.change), } } @@ -323,7 +326,7 @@ function renderInstructionContext( * Render a baseline together with the exact source files semantically represented in it. * @param files - loaded files ordered from broadest to most specific. * @param options - required rendering byte budget. - * @returns bounded public rendering plus the original files whose semantic sections survived. + * @returns bounded public rendering plus files with surviving content, including genuinely empty files. * @internal */ export function renderWorkspaceInstructionSet( diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 0331fcea8e..f8a375aa87 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -860,6 +860,7 @@ describe('workspace context rendering', () => { expect(rendered.changes).toEqual([change]) }) + // Each prose-derived budget is the smallest current value that retains the named heading plus a zero-byte marker. it.each([ { action: 'set' as const, maxBytes: 327, heading: 'Additional instructions from:' }, { action: 'replace' as const, maxBytes: 256, heading: 'Updated instructions from:' }, @@ -1338,14 +1339,14 @@ describe('workspace context request injection', () => { } }) - it('does not expose state markers when a baseline heading survives with zero content bytes', async () => { + it.each([10, 120])('does not expose state markers when baseline content is omitted at %i bytes', async (maxBytes) => { const root = await tempRepo() const home = await tempRepo() try { await mkdir(join(root, '.git'), { recursive: true }) await write(join(root, 'AGENTS.md'), 'x'.repeat(1000)) const ctx = new Context() - await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 120 }) + await mountWorkspaceContext(ctx, { dshHome: home, maxBytes }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -1356,8 +1357,12 @@ describe('workspace context request injection', () => { expect(contexts).toHaveLength(1) const source = contexts[0]?.type === 'user/message' ? contexts[0].data.source : undefined expect(source?.kind === 'workspace-instructions' ? source.changes : undefined).toEqual([]) - expect(derivedText(agent)).toContain('Instructions from: AGENTS.md') - expect(derivedText(agent)).toContain('from 1000 to 0 bytes') + if (maxBytes === 120) { + expect(derivedText(agent)).toContain('Instructions from: AGENTS.md') + expect(derivedText(agent)).toContain('from 1000 to 0 bytes') + } else { + expect(derivedText(agent)).not.toContain('Instructions from: AGENTS.md') + } expect(derivedText(agent)).not.toContain('workspace-context:') } finally { await rm(root, { recursive: true, force: true }) From eb9d2a7ea0f449b26fb3a9b8e5bd6d7db8844db9 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:57:08 -0700 Subject: [PATCH 04/25] fix(workspace-context): preserve UTF-8 render proof --- .../2026-06-24-workspace-context.i18n.yaml | 4 +- .../feature/2026-06-24-workspace-context.md | 2 +- .../2026-06-24-workspace-context.zh.md | 2 +- .../workspace-context/README.i18n.yaml | 4 +- packages/context/workspace-context/README.md | 2 +- .../context/workspace-context/README.zh.md | 2 +- .../context/workspace-context/src/render.ts | 41 ++++++++++--------- .../tests/workspace-context.spec.ts | 28 ++++++++++++- 8 files changed, 56 insertions(+), 29 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml index 71a11a4666..e07fa2dc8e 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.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 .agents/notes/implemented/feature/2026-06-24-workspace-context.md -2026-06-24-workspace-context.md: 004792bbbcf6a99e2c5cdcd4c4d640d9c7d39877 -2026-06-24-workspace-context.zh.md: a7d0ea1884c8cb69c96e5192c0fbc1d2ae5ea4dd +2026-06-24-workspace-context.md: c7b8d5eb11534b9c0cba1865743d9ff195c4a8e3 +2026-06-24-workspace-context.zh.md: 49a4fb8f3957f692ae24617222ad0fed2ad24a20 diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md index 004792bbbc..c7b8d5eb11 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md @@ -52,7 +52,7 @@ Every workspace context event stores versioned metadata with `{ action, scope, p At reconciliation time the plugin scans workspace-sourced `user/message` events and derives the latest state for each visible scope. A short per-session pending map begins only after the immutable top-level `tools/result` proves an `additionalContexts` entry survived every post-execute listener, then covers the interval before the loop appends that context to the log. Each entry records the open `{ turn, step }`: an equal durable `user/message` at or after its sequence boundary confirms and removes it, while a matching `step/end` arriving first means the loop discarded its context buffer, so the plugin removes both the pending entry and its version-cache fast path. A nested Code Mode result stages its changes under the parent's opaque execution token so repeated sub-dispatches in one run do not duplicate them; the parent result rolls that provisional state back and commits only contexts retained by outer policy. -An unchanged path and digest is suppressed. A logged removal is a tombstone, so a reappearing candidate becomes a new `set`. Resume works from persisted metadata. If compaction removes an instruction event from the visible surface, that state no longer suppresses a later load, matching the fact that the model can no longer see it. A change enters metadata or pending state only when its file-specific section retains at least one content byte, or when the original content is genuinely empty. Partial truncation commits the full-content digest once any byte survives; zero-content truncation remains eligible on a later touch. +An unchanged path and digest is suppressed. A logged removal is a tombstone, so a reappearing candidate becomes a new `set`. Resume works from persisted metadata. If compaction removes an instruction event from the visible surface, that state no longer suppresses a later load, matching the fact that the model can no longer see it. A change enters metadata or pending state only when its file-specific section retains at least one content byte, or when the original content is genuinely empty. Partial truncation commits the full-content digest once any byte survives; zero-content truncation remains eligible on a later touch. A baseline may retain budget diagnostics with no committed changes. A dynamic batch with no committed change is withheld entirely and retried on a later touch. The initial baseline's typed changes are comparison state only while its event remains in the visible session surface. A later successful filesystem touch re-adds an unchanged baseline scope after compaction, or appends baseline edits or removals as dynamic messages; it never rewrites the original event. The in-memory scope marker and provider-version cache only select and accelerate probes, so neither can suppress context the model no longer sees. During resumed baseline preparation the plugin also reconciles visible dynamic scopes, so nested changes made while the agent was offline can append an update before the first resumed request. diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md index a7d0ea1884..49a4fb8f39 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md @@ -52,7 +52,7 @@ shell 命令不会触发发现。本地 bash 调用会启动全新的 shell, 协调时,插件扫描带工作区来源的 `user/message` 事件,并派生每个可见作用域的最新状态。一个简短的逐会话待处理映射只会在不可变的顶层 `tools/result` 证明某个 `additionalContexts` 条目经过所有 post-execute 监听器后仍然保留时开始记录;随后,它覆盖循环将该上下文追加到日志之前的间隔。每个条目记录开启状态的 `{ turn, step }`:如果相同的持久 `user/message` 出现在其序列边界或之后,该条目得到确认并被移除;如果匹配的 `step/end` 先到达,则说明循环丢弃了上下文缓冲区,插件会同时移除待处理条目及其版本缓存快速路径。嵌套的 Code Mode 结果会把变更暂存在父级的不透明执行 token 下,确保一次运行中的重复子分发不会产生重复项;父级结果会回滚这份临时状态,并且只提交外层策略保留的上下文。 -路径和 digest 均未变化时会被抑制。日志中的移除操作是一条墓碑记录,因此重新出现的候选项会成为新的 `set`。恢复操作从持久化元数据继续工作。如果压缩从可见表面移除某条指令事件,该状态不再抑制后续加载,这与模型已经无法看见它的事实一致。变更只有在对应文件专属段落保留至少一个内容字节,或原始内容确实为空时,才会进入元数据或待处理状态。只要任一字节保留下来,部分截断就会提交完整内容 digest;零内容截断仍可在后续触碰中处理。 +路径和 digest 均未变化时会被抑制。日志中的移除操作是一条墓碑记录,因此重新出现的候选项会成为新的 `set`。恢复操作从持久化元数据继续工作。如果压缩从可见表面移除某条指令事件,该状态不再抑制后续加载,这与模型已经无法看见它的事实一致。变更只有在对应文件专属段落保留至少一个内容字节,或原始内容确实为空时,才会进入元数据或待处理状态。只要任一字节保留下来,部分截断就会提交完整内容 digest;零内容截断仍可在后续触碰中处理。基线可以保留字节预算诊断而不提交任何变更。动态批次若没有可提交变更,则整批不注入,并在后续触碰时重试。 只有当初始基线事件仍在可见会话表层中时,其类型化变更才用作比较状态。后续成功的文件系统触碰会在压缩后重新添加未变化的基线 scope,或把基线编辑或移除操作追加为动态消息;它绝不重写原始事件。内存中的 scope 标记和提供方版本 cache 只用于选择探测对象并加速探测,因此二者都不能抑制模型已无法看见的上下文。恢复时准备基线的过程中,插件还会协调可见的动态作用域,因此 agent 离线期间发生的嵌套变更可以在第一次恢复请求前追加更新。 diff --git a/packages/context/workspace-context/README.i18n.yaml b/packages/context/workspace-context/README.i18n.yaml index 85980bfcf0..87c524a67a 100644 --- a/packages/context/workspace-context/README.i18n.yaml +++ b/packages/context/workspace-context/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/context/workspace-context/README.md -README.md: dc8889cf99b1af691c72af621e78dfb93228ad92 -README.zh.md: ef1dd31ecd5b368aa3e92176edda5aa53268f7d8 +README.md: 0be9ea1af7205f39cf9479ed155c4ae9910041ca +README.zh.md: d1fbb073d686bef1e2dd65f37fd5df689f592d50 diff --git a/packages/context/workspace-context/README.md b/packages/context/workspace-context/README.md index dc8889cf99..0be9ea1af7 100644 --- a/packages/context/workspace-context/README.md +++ b/packages/context/workspace-context/README.md @@ -50,7 +50,7 @@ The plugin owns the complete `` framing, and every injected `us Model-visible text contains no hidden state markers. Each baseline or dynamic context event instead carries a typed `workspace-instructions` source with a list of `{ action, scope, path, digest? }` changes; the complete startup or resume baseline also carries `baseline: true`. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `user/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy. -An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope provider cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the typed source, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. A model-visible change enters the source, pending state, and version cache only when its file-specific section retains at least one content byte, or when its original content is genuinely empty. Partial truncation records the complete-content digest once any content byte survives; truncation to zero remains eligible for a later touch, while a same-digest version refresh updates only the provider cache. +An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope provider cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the typed source, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. A model-visible change enters the source, pending state, and version cache only when its file-specific section retains at least one content byte, or when its original content is genuinely empty. Partial truncation records the complete-content digest once any content byte survives; truncation to zero remains eligible for a later touch, while a same-digest version refresh updates only the provider cache. A baseline may still publish its budget diagnostic with an empty change list. A dynamic batch with no committed change is not injected at all, and a later touch retries it. The initial baseline event itself is not rewritten. Its typed changes remain authoritative only while that event is in the visible session surface; the next successful filesystem touch re-adds an unchanged baseline scope after compaction, or appends its replacement or removal. The in-memory scope marker and provider-version cache only select and accelerate probes. A hot plugin remount retains a baseline only when its typed event remains visible, while rebuilding current scope and version tracking; otherwise it injects a current baseline. A resumed loop always recomposes the current baseline and also reconciles still-visible dynamic scopes before its first request. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, or when a resumed loop prepares its baseline. diff --git a/packages/context/workspace-context/README.zh.md b/packages/context/workspace-context/README.zh.md index ef1dd31ecd..d1fbb073d6 100644 --- a/packages/context/workspace-context/README.zh.md +++ b/packages/context/workspace-context/README.zh.md @@ -50,7 +50,7 @@ These instructions apply to work under `packages/app`. Use them as guidance when 模型可见文本不含隐藏状态标记。每个基线或动态上下文事件改为携带带类型的 `workspace-instructions` 来源,其中包含 `{ action, scope, path, digest? }` 变更列表;完整的启动或恢复基线还会携带 `baseline: true`。每次相关工具 touch 时,插件会从可见会话事件重建已加载状态,并叠加一个短暂内存 pending 窗口,用于不可变顶层 `tools/result` 上存在但 loop 尚未追加的上下文。匹配的持久 `user/message` 会确认 pending 转换。如果所属 `step/end` 在匹配上下文进入日志之前到达,插件会清除 pending 转换及其版本快速路径,使下一次成功 touch 可以重新加载。嵌套 Code Mode 结果会在外层执行 token 下暂存 pending 变更,用于抑制同次运行中的重复项;外层结果会回滚该状态,再只重新提交经过外层策略的上下文。 -路径与 SHA-1 内容 digest 都未变时,不会重复注入。每会话、每 scope 提供方 cache 只存储 `{ path, version, digest, trimmedDigest }`:当提供方的不透明 `FsVersion` 与有效可见状态都匹配时,对账会跳过内容读取;版本改变会在任何模型可见更新之前触发有界读取与 SHA-1 确认。`trimmedDigest` 是针对去除空白后内容的 SHA-1,也是每目录重复 key,因此较早候选文件与某个未更改文件的内容收敛后,后者仍可被移除。恢复可行,因为 SHA-1 状态持久化在带类型的来源中,而空的内存版本 cache 只会导致一次确认读取。压缩(compaction)会在 scope 的上下文事件离开可见表层后重新启用它,即使缓存版本未变。移除是 tombstone,因此候选文件之后重新出现时会重新加载。模型可见变更只有在对应文件专属段落保留至少一个内容字节,或原始内容确实为空时,才会进入来源、pending 状态和版本 cache。只要任一内容字节保留下来,部分截断就会记录完整内容的 digest;截断到零字节则仍可在后续 touch 处理,而相同 digest 的版本刷新只更新提供方 cache。 +路径与 SHA-1 内容 digest 都未变时,不会重复注入。每会话、每 scope 提供方 cache 只存储 `{ path, version, digest, trimmedDigest }`:当提供方的不透明 `FsVersion` 与有效可见状态都匹配时,对账会跳过内容读取;版本改变会在任何模型可见更新之前触发有界读取与 SHA-1 确认。`trimmedDigest` 是针对去除空白后内容的 SHA-1,也是每目录重复 key,因此较早候选文件与某个未更改文件的内容收敛后,后者仍可被移除。恢复可行,因为 SHA-1 状态持久化在带类型的来源中,而空的内存版本 cache 只会导致一次确认读取。压缩(compaction)会在 scope 的上下文事件离开可见表层后重新启用它,即使缓存版本未变。移除是 tombstone,因此候选文件之后重新出现时会重新加载。模型可见变更只有在对应文件专属段落保留至少一个内容字节,或原始内容确实为空时,才会进入来源、pending 状态和版本 cache。只要任一内容字节保留下来,部分截断就会记录完整内容的 digest;截断到零字节则仍可在后续 touch 处理,而相同 digest 的版本刷新只更新提供方 cache。基线即使带空变更列表,仍可发布字节预算诊断。动态批次若没有可提交变更,则完全不注入,并在后续 touch 时重试。 初始基线事件自身不会被改写。其带类型的变更仅在该事件仍位于可见会话表层时才是权威状态;下一次成功的文件系统 touch 会在压缩后重新添加未变的基线 scope,或追加其替换或移除。内存中的 scope 标记和提供方版本 cache 只负责选择探测对象并加速探测。插件热重挂只有在其带类型的事件仍然可见时才保留基线,同时会重建当前 scope 与版本跟踪状态;否则会注入当前基线。恢复的 loop 始终重新组合当前基线,并在第一个请求前对账仍可见的动态 scope。没有文件 watcher,因此磁盘变更会在下一次成功 `read`、`write` 或 `edit` touch 时可见,也会在恢复 loop 准备基线时可见。 diff --git a/packages/context/workspace-context/src/render.ts b/packages/context/workspace-context/src/render.ts index bd59decdb4..e07b72cc91 100644 --- a/packages/context/workspace-context/src/render.ts +++ b/packages/context/workspace-context/src/render.ts @@ -69,18 +69,16 @@ function byteLength(value: string): number { return Buffer.byteLength(value, 'utf8') } -function zeroContentTruncatedPaths(truncated: TruncatedInstruction[]): Set { - return new Set(truncated - .filter(item => item.originalBytes > 0 && item.includedBytes === 0) - .map(item => item.displayPath)) -} - function truncateUtf8(value: string, maxBytes: number): string { - let truncated = Buffer.from(value, 'utf8').subarray(0, Math.max(0, maxBytes)).toString('utf8') - while (byteLength(truncated) > maxBytes) { - truncated = truncated.slice(0, -1) + const bytes = Buffer.from(value, 'utf8') + if (bytes.length <= maxBytes) return value + let end = Math.max(0, Math.trunc(maxBytes)) + // If the first excluded byte is a UTF-8 continuation byte, the budget cut + // through that code point. Back up to its lead byte and exclude it too. + while (end > 0 && (bytes.readUInt8(end) & 0xc0) === 0x80) { + end -= 1 } - return truncated + return bytes.subarray(0, end).toString('utf8') } function escapeInstructionFrameBody(body: string): string { @@ -199,12 +197,10 @@ export function renderInstructionChanges( } const rendered = renderInstructionContext(items.map(item => item.file), maxBytes, style) const represented = new Set(rendered.represented.map(file => file.absolutePath)) - const contentOmitted = zeroContentTruncatedPaths(rendered.truncated) return { text: rendered.text, changes: items - .filter(item => represented.has(item.file.absolutePath) - && !contentOmitted.has(item.file.displayPath)) + .filter(item => represented.has(item.file.absolutePath)) .map(item => item.change), } } @@ -294,21 +290,26 @@ function renderInstructionContext( /* v8 ignore next -- callers only reach this after a non-empty fullText was built. */ if (mostSpecific === undefined) return { text: '', omitted: [], truncated: [], represented: [] } const omitted = files.slice(0, -1).map(file => ({ absolutePath: file.absolutePath, displayPath: file.displayPath })) + const originalBytes = byteLength(mostSpecific.content) for (const candidateStyle of [style, { ...style, intro: COMPACT_WORKSPACE_CONTEXT_INTRO }]) { const truncatedFile = truncateToFit(mostSpecific, [], maxBytes, omitted, candidateStyle) + const includedBytes = byteLength(truncatedFile.content) const truncated = [{ displayPath: mostSpecific.displayPath, - originalBytes: byteLength(mostSpecific.content), - includedBytes: byteLength(truncatedFile.content), + originalBytes, + includedBytes, }] const text = buildInstructionText([truncatedFile], maxBytes, omitted, truncated, candidateStyle) - if (byteLength(text) <= maxBytes) return { text, omitted, truncated, represented: [mostSpecific] } + if (byteLength(text) <= maxBytes) { + const represented = includedBytes > 0 || originalBytes === 0 ? [mostSpecific] : [] + return { text, omitted, truncated, represented } + } } const truncated = [{ displayPath: mostSpecific.displayPath, - originalBytes: byteLength(mostSpecific.content), + originalBytes, includedBytes: 0, }] const compactNotice = escapeInstructionFrameBody(markerText(maxBytes, omitted, truncated)) @@ -316,7 +317,8 @@ function renderInstructionContext( [compactNotice, style.section(withTruncatedContent(mostSpecific, 0))].join('\n\n'), ) if (byteLength(compactWithHeading) <= maxBytes) { - return { text: compactWithHeading, omitted, truncated, represented: [mostSpecific] } + const represented = originalBytes === 0 ? [mostSpecific] : [] + return { text: compactWithHeading, omitted, truncated, represented } } const text = byteLength(compactNotice) <= maxBytes ? compactNotice : truncateUtf8(compactNotice, maxBytes) return { text, omitted, truncated, represented: [] } @@ -334,8 +336,7 @@ export function renderWorkspaceInstructionSet( options: { maxBytes: number }, ): RenderedInstructionSet { const { represented, ...rendered } = renderInstructionContext(files, options.maxBytes, BASELINE_RENDER_STYLE) - const contentOmitted = zeroContentTruncatedPaths(rendered.truncated) - return { rendered, included: represented.filter(file => !contentOmitted.has(file.displayPath)) } + return { rendered, included: represented } } /** diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index f8a375aa87..e4320fa52c 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -41,7 +41,7 @@ import { type InstructionVersionCache, type PendingInstructionChange, } from '../src/state.ts' -import { candidateScopeKey, renderInstructionChanges } from '../src/render.ts' +import { candidateScopeKey, renderInstructionChanges, renderWorkspaceInstructionSet } from '../src/render.ts' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' /** Per-candidate reconciliation scope key: directory paired with the file name. */ @@ -818,6 +818,15 @@ describe('workspace context rendering', () => { expect(Buffer.byteLength(rendered.text, 'utf8')).toBe(120) }) + it('represents a genuinely empty instruction when its compact heading fits', () => { + const file = { absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: '' } + const rendered = renderWorkspaceInstructionSet([file], { maxBytes: 117 }) + + expect(rendered.rendered.text).toContain('truncated pkg/AGENTS.md from 0 to 0 bytes') + expect(rendered.rendered.text).toContain('Instructions from: pkg/AGENTS.md') + expect(rendered.included).toEqual([file]) + }) + it('truncates the compact notice itself when the render budget is smaller than the notice', () => { const rendered = renderWorkspaceContext([ { absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'x'.repeat(1000) }, @@ -881,6 +890,23 @@ describe('workspace context rendering', () => { expect(rendered.changes).toEqual([]) }) + it('does not commit a multibyte change when the budget cuts its first code point', () => { + const change = { + action: 'set' as const, + scope: sk('pkg', 'AGENTS.md'), + path: 'pkg/AGENTS.md', + digest: 'digest', + } + const rendered = renderInstructionChanges([{ + change, + file: { absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: '😀'.repeat(100) }, + }], 366) + + expect(rendered.text).not.toContain('�') + expect(rendered.text).not.toContain('😀') + expect(rendered.changes).toEqual([]) + }) + it('keeps compact truncation notices within budget when a multibyte display path is cut', () => { const rendered = renderWorkspaceContext([ { absolutePath: '/repo/路径/AGENTS.md', displayPath: '路径/AGENTS.md', content: 'x'.repeat(1000) }, From 0fa0405deb32f373d9728e0c7211a0fd9a2a7d50 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 30 Jul 2026 03:57:57 -0700 Subject: [PATCH 05/25] test(workspace-context): pin empty rendered changes --- .../context/workspace-context/src/render.ts | 8 +++++--- .../tests/workspace-context.spec.ts | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/context/workspace-context/src/render.ts b/packages/context/workspace-context/src/render.ts index e07b72cc91..c54666698b 100644 --- a/packages/context/workspace-context/src/render.ts +++ b/packages/context/workspace-context/src/render.ts @@ -30,9 +30,11 @@ export interface RenderedWorkspaceContext { interface RenderedInstructionContext extends RenderedWorkspaceContext { /** - * Original files whose file-specific section text survived rendering. This - * is not the complement of `omitted`: a truncated file may be represented - * here and in `truncated`, while a notice-only file appears in neither. + * Original files semantically represented by rendered section text. This is + * not the complement of `omitted`: a truncated file may be represented here + * and in `truncated`, while a notice-only file appears in neither. A genuinely + * empty file counts when its heading survives because that heading conveys + * that the instruction exists and has no content. */ represented: LoadedInstructionFile[] } diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index e4320fa52c..ebaaf3a89a 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -827,6 +827,24 @@ describe('workspace context rendering', () => { expect(rendered.included).toEqual([file]) }) + it('represents a genuinely empty instruction through the framed compact-intro path', () => { + const file = { + absolutePath: '/repo/pkg/AGENTS.md', + displayPath: 'pkg/AGENTS.md', + content: '', + } + + const rendered = renderWorkspaceInstructionSet([file], { maxBytes: 300 }) + + expect(rendered.rendered.text).toContain('') + expect(rendered.rendered.text).toContain('Workspace instructions were omitted or truncated') + expect(rendered.rendered.text).toContain('Instructions from: pkg/AGENTS.md') + expect(rendered.rendered.truncated).toEqual([ + { displayPath: 'pkg/AGENTS.md', originalBytes: 0, includedBytes: 0 }, + ]) + expect(rendered.included).toEqual([file]) + }) + it('truncates the compact notice itself when the render budget is smaller than the notice', () => { const rendered = renderWorkspaceContext([ { absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'x'.repeat(1000) }, From fe2703b641e45e26c863fea1df2395992508d7d2 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 6 Aug 2026 02:13:43 -0700 Subject: [PATCH 06/25] fix(workspace-context): restore the no-unrepresented-commit gate after master merge The master merge rewrote reconcileInstructionContext and dropped this branch's gate: when no transition survives rendering (tiny budgets produce notice-only text), emit nothing and commit nothing so the next pass retries. Restore it, align the inbox one-byte test with that contract (an unrepresentable change is held back, not committed at 1 byte), and move the nested-retry test's probe assertions to sync time where reconciliation now runs. Also restore master's markdown spec casts lost in an earlier merge. --- .../client/ui-primitives/tests/markdown.spec.tsx | 2 +- packages/context/workspace-context/src/state.ts | 4 ++++ .../tests/workspace-context.spec.ts | 13 ++++++++++--- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx index 1308403b08..e67302a305 100644 --- a/packages/client/ui-primitives/tests/markdown.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.spec.tsx @@ -162,7 +162,7 @@ describe('MarkdownText', () => { expect(() => tokenizer?.call({ parser: { constructs: { attentionMarkers: {} } }, previous: null, - }, {}, () => undefined, () => undefined)).toThrow( + } as never, {} as never, () => undefined, () => undefined)).toThrow( 'micromark CommonMark attention markers are unavailable', ) }) diff --git a/packages/context/workspace-context/src/state.ts b/packages/context/workspace-context/src/state.ts index d0176eef8f..5b35862cd8 100644 --- a/packages/context/workspace-context/src/state.ts +++ b/packages/context/workspace-context/src/state.ts @@ -405,6 +405,10 @@ export async function reconcileInstructionContext( } if (items.length === 0) return undefined const rendered = renderInstructionChanges(items, resolved.maxBytes) + // When no transition survived rendering (tiny budgets render notice-only + // text), emit nothing and commit nothing — the uncommitted versions make the + // next pass retry instead of spamming notice-only contexts. + if (rendered.text.length === 0 || rendered.changes.length === 0) return undefined return { context: workspaceContextHook(rendered.text, rendered.changes), versionUpdates: retainedInstructionVersionUpdates(versionUpdates, rendered.changes), diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 5ca57fb0cd..0fa1533e4d 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -3815,13 +3815,18 @@ describe('dynamic nested workspace context injection', () => { signal: testToolSignal, callId: CallId('read-tiny-budget-1'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) + await syncWorkspaceContext(ctx, agent) const second = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-tiny-budget-2'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) + await syncWorkspaceContext(ctx, agent) expect(first.additionalContexts).toBeUndefined() expect(second.additionalContexts).toBeUndefined() + // Nothing was emitted, and the uncommitted version made the second sync + // probe the instruction file again — the retry. + expect(agent.inbox.nextStep).toHaveLength(0) expect(fs.readTargets.filter(path => path === instructionPath)).toHaveLength(2) } finally { await ctx.fiber.dispose() @@ -3916,7 +3921,7 @@ describe('workspace context inbox synchronization', () => { } }) - it('keeps a dynamic change within a one-byte positive render budget', async () => { + it('holds back a dynamic change a one-byte positive render budget cannot represent', async () => { const root = await tempRepo() const home = await tempRepo() const ctx = new Context() @@ -3934,8 +3939,10 @@ describe('workspace context inbox synchronization', () => { await syncWorkspaceContext(ctx, agent) - expect(agent.inbox.nextStep).toHaveLength(1) - expect(Buffer.byteLength(blocksText(agent.inbox.nextStep[0]?.content), 'utf8')).toBeLessThanOrEqual(1) + // One byte cannot semantically represent the transition, so nothing is + // emitted and nothing commits — the uncommitted version retries on the + // next touch instead of committing state the model never saw. + expect(agent.inbox.nextStep).toHaveLength(0) } finally { await ctx.fiber.dispose() await rm(root, { recursive: true, force: true }) From 16c1eaf546d4d59d9e8786d96280bd724665104a Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:05:27 -0700 Subject: [PATCH 07/25] fix(fs-local): bound overwrite contextual diff bases Rebuild of the fs-overwrite-diff-bound branch on current master. Adds the diffBasisMaxBytes Config field (10 MiB default, capped by runtime allocation/decode limits), gates both overwrite sides, and reads the prior basis from the bounded opened descriptor in cancellation-aware chunks; any post-stat size change returns a null basis. Also pins the one-extra-byte growth probe with a regression and drops the now-covered v8 ignore. --- ...-30-bounded-overwrite-diff-basis.i18n.yaml | 6 + ...2026-07-30-bounded-overwrite-diff-basis.md | 31 +++ ...6-07-30-bounded-overwrite-diff-basis.zh.md | 31 +++ docs/config-catalog.md | 15 +- .../core-data-structures/filesystem.i18n.yaml | 4 +- docs/core-data-structures/filesystem.md | 9 +- docs/core-data-structures/filesystem.zh.md | 9 +- packages/fs/fs-local/README.i18n.yaml | 4 +- packages/fs/fs-local/README.md | 4 +- packages/fs/fs-local/README.zh.md | 4 +- packages/fs/fs-local/src/fsio.ts | 57 ++++- packages/fs/fs-local/src/index.ts | 35 ++- packages/fs/fs-local/tests/filesystem.spec.ts | 63 ++++++ packages/fs/fs-local/tests/fsio.spec.ts | 202 +++++++++++++++++- packages/fs/fs-sandbox/README.i18n.yaml | 4 +- packages/fs/fs-sandbox/README.md | 2 + packages/fs/fs-sandbox/README.zh.md | 2 + packages/fs/fs-sandbox/src/index.ts | 8 +- packages/fs/fs/src/types.ts | 9 +- 19 files changed, 452 insertions(+), 47 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.i18n.yaml new file mode 100644 index 0000000000..f7361eda32 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.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 .agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.md +2026-07-30-bounded-overwrite-diff-basis.md: 353b538a12b8cf48dfa3a561c62d6d8ab9a8bfcf +2026-07-30-bounded-overwrite-diff-basis.zh.md: e2b473a21d16dc47b5b8bf781b8ddffdf443955b diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.md b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.md new file mode 100644 index 0000000000..353b538a12 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.md @@ -0,0 +1,31 @@ +# Agent Note: Bound overwrite contextual-diff bases at the provider + +Status: implemented + +English | [中文](2026-07-30-bounded-overwrite-diff-basis.zh.md) + +## Problem + +`dsh-fs-local` returned the complete prior file in `FsWriteOutcome.before` so consumers could build a contextual overwrite diff. That presentation-only pre-read was unbounded: a large overwrite could allocate the entire prior file, and checking an earlier path stat alone could not enforce a limit because an external process could replace or grow the file between the stat and the read. A large replacement also made the contextual hunk approach the replacement size even when the prior file was small. This closes the deferred bound recorded by [result-time applied-hunk diffs](../../archived/architecture/2026-07-02-result-time-applied-hunk-diffs.md). + +## Decision + +`LocalFileSystem.Config.diffBasisMaxBytes` is a positive safe-integer deployment setting no greater than the runtime's Buffer-allocation and string-decoding limits, with a 10 MiB default. An overwrite supplies `before` only when the UTF-8 replacement is strictly below that limit and the prior file opened for the basis also ends below it. The prior read opens a descriptor, checks that descriptor, and reads at most the configured byte count in cancellation-aware chunks; reaching the boundary returns `null`. A size change after descriptor stat also returns `null`, even if the final size remains below the limit, because a partial prefix would be an incorrect diff basis. Binary or invalid UTF-8 prior content likewise returns `null`. These outcomes do not block the atomic write. + +The local provider owns this decision because `before` is its optional, best-effort basis: it can avoid acquiring prior content that the configured pair limit has already made ineligible. `tool-fs` continues to own diff computation, retention, and presentation. The setting is independent of `tool-fs.readStreamMinSize`; read routing and overwrite presentation are different policies and need not share a value. + +`before: null` asks consumers to use their existing whole-file fallback. The limit bounds only the extra prior-content acquisition and eligibility for a contextual pair. It does not bound the caller-owned replacement, the returned `after` value, or a consumer's fallback rendering. + +## Alternatives considered + +**Keep a hardcoded threshold equal to the read tool's streaming threshold.** Rejected because the read threshold is deployment-configurable and consumer-owned. Two same-valued constants would create an unenforced cross-package coupling, while the overwrite basis is itself a deployment memory/presentation choice. + +**Gate only the prior side in the provider and cap new-content diffing in `tool-fs`.** Rejected because it would acquire prior text even when the provider's configured pair limit already excludes the replacement, and it would split one `before` eligibility rule across two plugins. Consumers remain free to impose additional output limits. + +**Trust the initial `probe()` size before using an ordinary whole-file read.** Rejected because that size can become stale before the read. The descriptor reader must enforce the bound on the object it actually reads. + +**Stream a contextual diff for arbitrarily large pairs.** Rejected for this bug fix because the current filesystem seam returns complete `before`/`after` strings and the current diff implementation consumes them. A streaming diff would require a separate cross-package protocol and presentation design. + +## Consequences + +Deployments can tune the extra overwrite-basis cost without changing read routing. At or above the exclusive limit, overwrites still succeed and remain visible through the whole-file fallback, but lose contextual hunks. Below the limit, the provider can still hold almost `diffBasisMaxBytes` of prior text in addition to the caller's replacement. The bounded descriptor read adds an open/stat/read sequence for eligible overwrites, while preventing a stale path probe from turning that sequence into an unbounded allocation. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.zh.md new file mode 100644 index 0000000000..e2b473a21d --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 在提供方限制覆写上下文 diff 基础 + +Status: implemented + +[English](2026-07-30-bounded-overwrite-diff-basis.md) | 中文 + +## Problem + +`dsh-fs-local` 会在 `FsWriteOutcome.before` 中返回完整旧文件,供消费方生成覆写上下文 diff。这个仅用于展示的预读没有上限:大文件覆写可能分配整个旧文件;而仅检查较早的路径 stat 也无法真正实施上限,因为外部进程可以在 stat 与读取之间替换文件或扩大文件。即使旧文件很小,大替换内容也会使上下文 hunk 接近替换内容本身的大小。本改动关闭了 [result-time applied-hunk diff](../../archived/architecture/2026-07-02-result-time-applied-hunk-diffs.md) 中记录的暂缓上限事项。 + +## Decision + +`LocalFileSystem.Config.diffBasisMaxBytes` 是一个不超过运行时 Buffer 分配和字符串解码上限的正安全整数部署配置,默认 10 MiB。只有当 UTF-8 替换内容严格低于该上限,且为生成基础而打开的旧文件最终也低于该上限时,覆写才提供 `before`。旧文件读取会打开文件描述符、检查该描述符,并按可响应取消的分块最多读取配置的字节数;一旦到达边界便返回 `null`。描述符 stat 后发生大小变化时同样返回 `null`,即使最终大小仍低于上限,因为部分前缀会成为错误的 diff 基础。旧内容为二进制或无效 UTF-8 时也返回 `null`。这些结果都不会阻止原子写入。 + +本地提供方拥有该决策,因为 `before` 是它提供的可选、尽力而为的基础:当配置的成对上限已使替换内容不合格时,它可以避免获取旧内容。`tool-fs` 继续拥有 diff 计算、保留与展示。该配置独立于 `tool-fs.readStreamMinSize`;读取路由与覆写展示是不同策略,无需共享数值。 + +`before: null` 要求消费方使用既有的整文件回退。该上限只限制额外获取旧内容的成本,以及上下文内容对是否合格;它不限制调用方持有的替换内容、返回的 `after` 值或消费方的回退渲染。 + +## Alternatives considered + +**保留一个与读取工具流式阈值相等的硬编码阈值。** 否决,因为读取阈值可由部署配置,且归消费方所有。两个同值常量会形成无法强制的一致性耦合,而覆写基础本身也是部署层面的内存与展示选择。 + +**提供方只限制旧内容一侧,并在 `tool-fs` 中限制新内容 diff。** 否决,因为当提供方配置的成对上限已经排除替换内容时,这仍会获取旧文本;同时会把同一条 `before` 合格规则拆到两个插件中。消费方仍可自由施加额外的输出限制。 + +**信任初次 `probe()` 的大小,再执行普通整文件读取。** 否决,因为该大小可能在读取前变旧;描述符读取必须对它真正读取的对象实施上限。 + +**为任意大的内容对流式生成上下文 diff。** 本次缺陷修复不采用,因为当前文件系统 seam 返回完整的 `before`/`after` 字符串,当前 diff 实现也消费这两个字符串。流式 diff 需要独立的跨包协议与展示设计。 + +## Consequences + +部署可以调整额外的覆写基础成本,而不改变读取路由。达到或超过排他上限时,覆写仍会成功,并通过整文件回退保持可见,但不再提供上下文 hunk。低于上限时,除调用方的替换内容外,提供方仍可能持有接近 `diffBasisMaxBytes` 的旧文本。对于合格覆写,有上限的描述符读取会增加一次 open/stat/read 序列,同时防止陈旧路径探测把该序列变成无上限分配。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index feb8aa9d86..f3a59d4ec4 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -460,10 +460,15 @@ Source: [`packages/host/frontend-static/src/index.ts:28`](../packages/host/front export interface Config { /** Base directory for relative paths. Defaults to `process.cwd()`. */ cwd?: string + /** + * Exclusive UTF-8 byte limit on each overwrite-diff side, capped by the + * runtime's safe allocation/decode maximum. Defaults to 10 MiB. + */ + diffBasisMaxBytes?: number } ``` -Source: [`packages/fs/fs-local/src/index.ts:38`](../packages/fs/fs-local/src/index.ts) +Source: [`packages/fs/fs-local/src/index.ts:39`](../packages/fs/fs-local/src/index.ts) ## `@deepseek-ai/dsh-fs-sandbox` @@ -471,10 +476,10 @@ Requires: `sandboxPolicy` ```ts config-catalog /** - * Plugin config: the local backend's knobs, verbatim (only `cwd`, the resolve - * base for relative paths). The sandbox default (mode + `workspace-write` - * fallback root) is NOT here — `ctx.sandboxPolicy` resolves each calling - * session for every enforcing capability. + * Plugin config: the local backend's knobs verbatim (`cwd` resolution default + * and `diffBasisMaxBytes` overwrite-presentation bound). The sandbox default + * (mode + `workspace-write` fallback root) is NOT here — `ctx.sandboxPolicy` + * resolves each calling session for every enforcing capability. */ export type Config = LocalConfig ``` diff --git a/docs/core-data-structures/filesystem.i18n.yaml b/docs/core-data-structures/filesystem.i18n.yaml index 91ac0ed81d..a4a42e72d2 100644 --- a/docs/core-data-structures/filesystem.i18n.yaml +++ b/docs/core-data-structures/filesystem.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 docs/core-data-structures/filesystem.md -filesystem.md: 110c1fd428b15c5094f9dcc94050cad61c324373 -filesystem.zh.md: 010ec22a5d4a29555425c3deede08b317cba7004 +filesystem.md: 4862a25e3b8922a8709b4b025acd5436a2228082 +filesystem.zh.md: 2b5c7abec1555cc2cbb909eaae60d9022ae297dd diff --git a/docs/core-data-structures/filesystem.md b/docs/core-data-structures/filesystem.md index 110c1fd428..4862a25e3b 100644 --- a/docs/core-data-structures/filesystem.md +++ b/docs/core-data-structures/filesystem.md @@ -134,10 +134,11 @@ interface FsWriteOutcome { version: FsVersion /** * The file's content BEFORE the write, or `null` when the file did not exist - * (a create) or was undiffable (binary/non-UTF-8). LF-normalized storage text - * (the diff basis), never a diff — a consumer computes the result-time - * contextual diff from `before`/`after` when `before` is present, else falls - * back to a whole-file diff. + * (a create) or the backend declined a contextual basis (for example, a + * binary/non-UTF-8 prior file or either overwrite side reaching its exclusive limit). + * LF-normalized storage text (the diff basis), never a diff — a consumer + * computes the result-time contextual diff from `before`/`after` when + * `before` is present, else falls back to a whole-file diff. */ before: string | null /** The file's content AFTER the write, LF-normalized to share `before`'s diff basis. */ diff --git a/docs/core-data-structures/filesystem.zh.md b/docs/core-data-structures/filesystem.zh.md index 010ec22a5d..2b5c7abec1 100644 --- a/docs/core-data-structures/filesystem.zh.md +++ b/docs/core-data-structures/filesystem.zh.md @@ -134,10 +134,11 @@ interface FsWriteOutcome { version: FsVersion /** * The file's content BEFORE the write, or `null` when the file did not exist - * (a create) or was undiffable (binary/non-UTF-8). LF-normalized storage text - * (the diff basis), never a diff — a consumer computes the result-time - * contextual diff from `before`/`after` when `before` is present, else falls - * back to a whole-file diff. + * (a create) or the backend declined a contextual basis (for example, a + * binary/non-UTF-8 prior file or either overwrite side reaching its exclusive limit). + * LF-normalized storage text (the diff basis), never a diff — a consumer + * computes the result-time contextual diff from `before`/`after` when + * `before` is present, else falls back to a whole-file diff. */ before: string | null /** The file's content AFTER the write, LF-normalized to share `before`'s diff basis. */ diff --git a/packages/fs/fs-local/README.i18n.yaml b/packages/fs/fs-local/README.i18n.yaml index cc14be584f..cbc0c56619 100644 --- a/packages/fs/fs-local/README.i18n.yaml +++ b/packages/fs/fs-local/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/fs/fs-local/README.md -README.md: 6d344fa3fef7f6bda6c0daa50184661156a925a7 -README.zh.md: 4c94de64561d805684f91f02d5b0dfc375f0d04f +README.md: 9efee1ed3c33c825b20b4565f3c6cef7200ce4a2 +README.zh.md: 5554017f3d528e25decffe2f866c843039bb0457 diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index 6d344fa3fe..9efee1ed3c 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -18,7 +18,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) - **`stat` / `lstat`** — return target metadata or `undefined` when absent. `stat` reports `FsInfo` for an already resolved target (`version` = an opaque token derived from bigint `dev:ino:size:mtimeNs:ctimeNs`, `type` of `file`/`directory`/`other`, byte `size`); path-shaped `lstat` reports `FsPathInfo` without following the final symlink and can therefore return `symlink`. Both check cancellation before and after their asynchronous metadata probe, so an abort that lands in flight reports `FS_ABORTED` rather than stale absence. - **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` streams it in chunks (cross-chunk decoding) so a huge file never has to be held whole in memory. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) decides which to call by size and owns the line windowing. - **`listDir`** — lists one directory level in stable `name.localeCompare()` order. Each entry carries the child basename, type, resolved child target (`displayPath` under the listed directory, `targetKey` as the realpath identity), and cheap stat metadata (`version`, plus `size` for regular files). It never opens or decodes file contents. Missing targets report `FS_NOT_FOUND`, file/special-file targets report `FS_NOT_DIRECTORY`, aborted calls report `FS_ABORTED`, permission failures report `FS_PERMISSION_DENIED`, and other listing or child metadata I/O failures report `FS_IO_ERROR`. Broken/disappeared children are returned as `other` without metadata, but permission/IO failures while resolving a child fail the whole listing with a structured `FsError`. -- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`; on Windows a new file inherits the destination directory's DACL, while replacement copies the target DACL onto the empty temp before writing and publishes through `ReplaceFileW` so the original access policy survives ([Windows DACL preservation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md)). The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`). +- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`; on Windows a new file inherits the destination directory's DACL, while replacement copies the target DACL onto the empty temp before writing and publishes through `ReplaceFileW` so the original access policy survives ([Windows DACL preservation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md)). The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`). An overwrite returns the prior text as its contextual diff basis only when both the opened prior file and UTF-8 replacement are strictly below `config.diffBasisMaxBytes` (default 10 MiB). The descriptor read enforces that limit even if an external writer replaces or changes the file size after the initial probe. Otherwise the provider returns `before: null`, so presentation uses its whole-file fallback. - **`editText`** — atomic literal read-modify-write over the same primitive, serialized per target by a mutation lock. The `expected` guard is OPTIONAL: when supplied it verifies the version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content); omitting it edits the current content unconditionally. A missing target reports `FS_STALE_VERSION` either way. LF-normalizes for matching, restores the file's dominant CRLF/LF style, and rejects empty `oldString` / zero matches (`FS_EDIT_NOT_FOUND`) or ambiguous multi-matches without `replace_all` (`FS_AMBIGUOUS_EDIT`). The package-root SDK surface is the default/named `LocalFileSystem` class plus `Config`. Raw I/O lives in `src/fsio.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the thin service wiring. @@ -34,8 +34,8 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work - **`config.cwd` is not a sandbox** — it is a resolution default, not containment: absolute paths and `..` escape it. Enforce containment with a stricter `ctx.fs` backend or a permission plugin on the `tools/execute` waterfall ([capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md#consequences)). -- **An overwrite reads the whole prior file into memory** — solely as the UI diff basis; bounding that pre-read above a size threshold is deferred (`TODO(overwrite-diff-bound)`). - **Version tokens are `mtimeMs:size`** — an external change that preserves both within the filesystem's timestamp granularity defeats the stale guard. - **`editText` holds the whole file (plus the edited copy) in memory** — streaming exists only on the read path. +- **A sub-limit overwrite still buffers a contextual basis** — `writeText` may retain up to just below `config.diffBasisMaxBytes` of prior text in addition to the caller-owned replacement; the bound does not cap the returned `after` value or presentation's whole-file fallback. - **Binary detection is asymmetric** — reads NUL-sample only the first 8192 bytes while edits scan the whole buffer, so a file with a late NUL reads fine but rejects edits. - **The per-target mutation lock is in-process only** — a writer in another process is caught only by the optional version guard, never serialized. diff --git a/packages/fs/fs-local/README.zh.md b/packages/fs/fs-local/README.zh.md index 4c94de6456..5554017f3d 100644 --- a/packages/fs/fs-local/README.zh.md +++ b/packages/fs/fs-local/README.zh.md @@ -18,7 +18,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) - **`stat` / `lstat`**:返回目标元数据;目标不存在时返回 `undefined`。`stat` 为已解析目标报告 `FsInfo`(`version` 是由 bigint `dev:ino:size:mtimeNs:ctimeNs` 派生的不透明 token,`type` 为 `file`/`directory`/`other`,`size` 以字节计);路径形态的 `lstat` 不跟随最后一个符号链接,报告 `FsPathInfo`,因此可以返回 `symlink`。两者都会在异步元数据探测前后检查取消,因此异步探测进行期间发生的中止会报告 `FS_ABORTED`,而非已失效的「不存在」结果。 - **`readText` / `streamText`**:只支持 UTF-8。`readText` 读取整个文件;`streamText` 按分片流式读取(跨分片解码),因此超大文件无需整体保存在内存中。两者都会拒绝无效 UTF-8、包含 NUL 字节的二进制样本(`FS_NOT_TEXT`)以及非普通文件目标。`read` 工具(`@deepseek-ai/dsh-tool-fs`)按大小决定调用哪个方法,并负责行窗口逻辑。 - **`listDir`**:按稳定的 `name.localeCompare()` 顺序列出一层目录。每个条目携带子项 basename、类型、解析后的子目标(`displayPath` 位于所列目录下,`targetKey` 是 realpath 身份)和低成本 stat 元数据(`version`,普通文件另有 `size`)。它绝不会打开或解码文件内容。缺失目标报告 `FS_NOT_FOUND`,文件/特殊文件目标报告 `FS_NOT_DIRECTORY`,已中止调用报告 `FS_ABORTED`,权限失败报告 `FS_PERMISSION_DENIED`,其他列出或子项元数据 I/O 失败报告 `FS_IO_ERROR`。损坏/消失的子项以无元数据的 `other` 返回,但解析子项时出现权限/I/O 失败会让整个列表以结构化 `FsError` 失败。 -- **`writeText`**:原子写入。它会向排他打开的临时文件(`wx`、`0o600`)写入;该文件位于目标旁随机命名的私有暂存目录(`0o700`)内。完成写入和 fsync 后,以 rename 覆盖目标。现有文件的 mode 会保留,新文件默认为 `0o600`;Windows 上的新文件继承目标目录的 DACL,而替换会在写入前把目标 DACL 复制到空临时文件,并通过 `ReplaceFileW` 发布,使原访问策略得以保留(见 [Windows DACL 保留 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md))。`expected` 防护是可选(OPTIONAL)的:省略时无条件创建或覆盖;`createIfAbsent` 创建缺失目标并拒绝现有目标(`FS_NOT_OBSERVED`);`replaceIfVersion` 只在观察到的版本上替换(目标缺失或版本不匹配均为 `FS_STALE_VERSION`)。 +- **`writeText`**:原子写入。它会向排他打开的临时文件(`wx`、`0o600`)写入;该文件位于目标旁随机命名的私有暂存目录(`0o700`)内。完成写入和 fsync 后,以 rename 覆盖目标。现有文件的 mode 会保留,新文件默认为 `0o600`;Windows 上的新文件继承目标目录的 DACL,而替换会在写入前把目标 DACL 复制到空临时文件,并通过 `ReplaceFileW` 发布,使原访问策略得以保留(见 [Windows DACL 保留 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md))。`expected` 防护是可选(OPTIONAL)的:省略时无条件创建或覆盖;`createIfAbsent` 创建缺失目标并拒绝现有目标(`FS_NOT_OBSERVED`);`replaceIfVersion` 只在观察到的版本上替换(目标缺失或版本不匹配均为 `FS_STALE_VERSION`)。仅当打开后的旧文件和 UTF-8 替换内容都严格低于 `config.diffBasisMaxBytes`(默认 10 MiB)时,覆写才返回旧文本作为上下文 diff 基础。即使外部写入方在初次探测后替换文件或改变文件大小,文件描述符读取仍会强制执行该上限;否则提供方返回 `before: null`,由展示层使用整文件回退。 - **`editText`**:在同一原语之上执行原子式的字面量读取-修改-写入,并通过变更锁按目标串行化。`expected` 防护是可选(OPTIONAL)的:提供时,会在字面量匹配之前校验版本(陈旧编辑报告 `FS_STALE_VERSION`,绝不会针对较新内容报告 `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT`);省略时,无条件编辑当前内容。无论哪种情况,目标缺失都报告 `FS_STALE_VERSION`。匹配时规范化为 LF,随后恢复文件主要的 CRLF/LF 风格;空 `oldString` / 零匹配报告 `FS_EDIT_NOT_FOUND`,未设置 `replace_all` 的多个匹配则报告 `FS_AMBIGUOUS_EDIT`。 包根目录的 SDK 接口包含默认/具名 `LocalFileSystem` 类和 `Config`。原始 I/O 位于 `src/fsio.ts`(不依赖 Cordis,单独进行单元测试);`src/index.ts` 是轻量服务接线。 @@ -34,8 +34,8 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) ## 已知限制与暂缓事项 - **`config.cwd` 不是沙箱**:它是解析默认值,而非约束;绝对路径和 `..` 可以逃逸。请使用更严格的 `ctx.fs` 后端或 `tools/execute` waterfall(瀑布式事件)上的权限插件实施约束(见[能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md#consequences))。 -- **覆盖会把整个旧文件读入内存**:只用于 UI diff;在大小阈值之上限制这次预读取的工作延期处理(`TODO(overwrite-diff-bound)`)。 - **版本 token 是 `mtimeMs:size`**:如果外部变更在文件系统时间戳粒度内保持两者不变,就能绕过陈旧防护。 - **`editText` 会把整个文件及编辑后的副本保存在内存中**:只有读取路径支持流式处理。 +- **低于上限的覆写仍会缓冲上下文基础**:`writeText` 除调用方持有的替换内容外,最多还会保留略低于 `config.diffBasisMaxBytes` 的旧文本;该上限不限制返回的 `after` 值,也不限制展示层的整文件回退。 - **二进制检测不对称**:读取只对前 8192 字节执行 NUL 采样,编辑则扫描整个 buffer,因此 NUL 出现在后部的文件可以读取,但编辑会被拒绝。 - **每目标变更锁仅限进程内**:其他进程中的写入方只会被可选版本防护发现,绝不会被串行化。 diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index c93e4ddaeb..b47d6f2bcd 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -15,6 +15,8 @@ import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import { copyFileDaclWin32, replaceFileWin32 } from './win32.ts' const BINARY_SAMPLE_BYTES = 8192 +// Bound one non-abortable FileHandle.read so cancellation is observed between chunks. +const DIFF_BASIS_READ_CHUNK_BYTES = 64 * 1024 function isENOENT(error: unknown): boolean { return error instanceof Error && 'code' in error && error.code === 'ENOENT' @@ -70,9 +72,8 @@ function versionOf(info: BigIntStats): FsVersion { } /** - * Test seam: lets specs pin the atomic-write temp names (to prove - * exclusive-open behavior without a name race) and observe the staged temp - * file before it is renamed over the target. + * Test seam: lets specs pin the atomic-write temp names (to prove exclusive-open behavior without + * a name race), override native boundaries, and observe the staged temp file before publication. */ export interface FsIoInternals { /** Override the host platform for native-publication unit coverage. */ @@ -564,17 +565,53 @@ export async function readForEdit( } /** - * Best-effort overwrite diff basis. Binary or invalid UTF-8 returns `null` so the write still - * succeeds and presentation falls back to a whole-file diff. + * Best-effort overwrite diff basis. Binary, invalid UTF-8, or a file at/above the byte limit + * returns `null` so the write still succeeds and presentation falls back to a whole-file diff. + * The bound is enforced on the opened descriptor rather than a prior path stat, so concurrent + * external replacement or size changes cannot make this helper buffer more than `maxBytes`. * @param absolutePath - the file to read (typically a target key); it must exist. + * @param maxBytes - exclusive upper bound for bytes held as the contextual-diff basis. * @param signal - aborts the read (`FS_ABORTED`). - * @returns the LF-normalized text, or null for a binary or non-UTF-8 file. + * @returns the LF-normalized text, or null for a non-regular, at/above-limit, binary, non-UTF-8, + * or descriptor-size-changed file. */ -export async function readTextForDiff(absolutePath: string, signal?: AbortSignal): Promise { - const buffer = await readFileAbortable(absolutePath, 'read', signal) - if (buffer.includes(0)) return null +export async function readTextForDiff( + absolutePath: string, + maxBytes: number, + signal?: AbortSignal, +): Promise { + throwIfAborted(signal, 'read') + const handle = await open(absolutePath, 'r') + let buffer: Buffer + let total = 0 + let openedSize = 0 try { - return normalizeLineEndings(new TextDecoder('utf-8', { fatal: true }).decode(buffer)) + throwIfAborted(signal, 'read') + const info = await handle.stat() + throwIfAborted(signal, 'read') + /* v8 ignore next -- requires a post-preflight replacement with a non-file; + * direct coverage is not portable to Windows. */ + if (!info.isFile()) return null + if (info.size >= maxBytes) return null + openedSize = info.size + // One extra byte detects growth after stat without retaining per-read backing buffers. + buffer = Buffer.allocUnsafe(openedSize + 1) + while (total < buffer.length) { + throwIfAborted(signal, 'read') + const length = Math.min(buffer.length - total, DIFF_BASIS_READ_CHUNK_BYTES) + const { bytesRead } = await handle.read(buffer, total, length, null) + if (bytesRead === 0) break + total += bytesRead + } + } finally { + await handle.close() + } + throwIfAborted(signal, 'read') + if (total !== openedSize) return null + const basis = buffer.subarray(0, total) + if (basis.includes(0)) return null + try { + return normalizeLineEndings(new TextDecoder('utf-8', { fatal: true }).decode(basis)) } catch (error: unknown) { /* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes; any other throw is an unreachable runtime fault. */ if (!(error instanceof TypeError)) throw error diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index 18433f3f7c..33a1c354b6 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -5,6 +5,7 @@ */ import { Context } from 'cordis' +import { constants as bufferConstants } from 'node:buffer' import { resolve } from 'node:path' import z from 'schemastery' import { FileSystem, FsError, FsVersion } from '@deepseek-ai/dsh-fs' @@ -38,9 +39,19 @@ import type { FsIoInternals } from './fsio.ts' export interface Config { /** Base directory for relative paths. Defaults to `process.cwd()`. */ cwd?: string + /** + * Exclusive UTF-8 byte limit on each overwrite-diff side, capped by the + * runtime's safe allocation/decode maximum. Defaults to 10 MiB. + */ + diffBasisMaxBytes?: number } type ResolvedConfig = Required +const DEFAULT_DIFF_BASIS_MAX_BYTES = 10 * 1024 * 1024 +const MAX_DIFF_BASIS_BYTES = Math.min( + bufferConstants.MAX_LENGTH, + bufferConstants.MAX_STRING_LENGTH, +) /** * The host-filesystem backend. Reads resolve relative paths from {@link Config.cwd} @@ -51,11 +62,12 @@ type ResolvedConfig = Required export class LocalFileSystem extends FileSystem { static Config: z = z.object({ cwd: z.string().default(process.cwd()), + diffBasisMaxBytes: z.number().default(DEFAULT_DIFF_BASIS_MAX_BYTES), }) /** Validated config (schemastery applied the defaults before construction). */ readonly config: ResolvedConfig - /** Test seam forwarded to fsio (force streaming path, pin temp names). */ + /** Test seam forwarded to fsio for atomic-publication boundaries. */ internals: FsIoInternals = {} /** Per-targetKey tail promise: serializes mutating ops so the read→guard→write * window can't interleave, making concurrent writes/edits deterministically @@ -64,7 +76,13 @@ export class LocalFileSystem extends FileSystem { constructor(ctx: Context, config: Config) { super(ctx) - this.config = config as ResolvedConfig + const resolved = config as ResolvedConfig + if (!Number.isSafeInteger(resolved.diffBasisMaxBytes) + || resolved.diffBasisMaxBytes <= 0 + || resolved.diffBasisMaxBytes > MAX_DIFF_BASIS_BYTES) { + throw new Error(`fs-local: diffBasisMaxBytes must be a positive safe integer no greater than ${MAX_DIFF_BASIS_BYTES}`) + } + this.config = resolved } /** Run `op` with exclusive access to `targetKey` (FIFO per key). */ @@ -150,9 +168,16 @@ export class LocalFileSystem extends FileSystem { } // No expectation means an unconditional but still atomic write. - // Preserve prior text for contextual diffs; null falls back to a whole-file diff. - // TODO(overwrite-diff-bound): cap this UI-only pre-read for large files. - const before = existing ? await readTextForDiff(target.targetKey, signal) : null + // Capture an optional contextual-diff basis before the write. The bounded + // reader checks the opened file itself, so an external replacement after + // `probe()` cannot turn this best-effort presentation read into an + // unbounded allocation. Either side at/above the configured limit yields + // `before: null`; consumers retain their whole-file fallback. + const diffable = existing !== null + && Buffer.byteLength(content, 'utf8') < this.config.diffBasisMaxBytes + const before = diffable + ? await readTextForDiff(target.targetKey, this.config.diffBasisMaxBytes, signal) + : null await writeFileAtomic(target.targetKey, content, existing?.mode, signal, this.internals) const after = await probe(target.targetKey) return { diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts index 61e2c0e999..5b66eaa2a0 100644 --- a/packages/fs/fs-local/tests/filesystem.spec.ts +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -7,6 +7,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { constants as bufferConstants } from 'node:buffer' import { mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, unlink, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -42,13 +43,39 @@ async function versionOf(target: FsTarget): Promise { return info.version } +async function remountWithDiffLimit(diffBasisMaxBytes: number): Promise { + await fiber.dispose() + fiber = await ctx.plugin(LocalFileSystem, { cwd: dir, diffBasisMaxBytes }) + fs = ctx.fs as LocalFileSystem +} + describe('registration', () => { it('registers LocalFileSystem as ctx.fs with a default cwd', async () => { const bare = new Context() const bareFiber = await bare.plugin(LocalFileSystem) expect((bare.fs as LocalFileSystem).config.cwd).toBe(process.cwd()) + expect((bare.fs as LocalFileSystem).config.diffBasisMaxBytes).toBe(10 * 1024 * 1024) await bareFiber.dispose() }) + + it('rejects non-positive, fractional, unsafe, or unallocatable diff-basis limits', async () => { + const maxDiffBasisBytes = Math.min( + bufferConstants.MAX_LENGTH, + bufferConstants.MAX_STRING_LENGTH, + ) + const valid = new Context() + const validFiber = await valid.plugin(LocalFileSystem, { diffBasisMaxBytes: maxDiffBasisBytes }) + expect((valid.fs as LocalFileSystem).config.diffBasisMaxBytes).toBe(maxDiffBasisBytes) + await validFiber.dispose() + + for (const diffBasisMaxBytes of [0, -1, 1.5, maxDiffBasisBytes + 1, Number.MAX_SAFE_INTEGER + 1]) { + const invalid = new Context() + await expect(invalid.plugin(LocalFileSystem, { diffBasisMaxBytes })).rejects.toThrow( + `fs-local: diffBasisMaxBytes must be a positive safe integer no greater than ${maxDiffBasisBytes}`, + ) + await invalid.fiber.dispose() + } + }) }) describe('resolve', () => { @@ -384,6 +411,42 @@ describe('writeText', () => { expect(outcome.after).toBe('now valid') }) + it('an overwrite of a prior file AT the whole-file bound reports before:null (undiffable), still succeeds', async () => { + // The configured bound keeps the fixture small; 8 bytes at a bound of 8 + // pins the exclusive edge without coupling this provider to a read tool. + await remountWithDiffLimit(8) + await writeFile(join(dir, 'big.txt'), '12345678') + const target = await fs.resolve('big.txt') + const outcome = await fs.writeText(target, 'tiny') + expect(outcome.operation).toBe('update') + expect(outcome.before).toBeNull() + expect(outcome.after).toBe('tiny') + }) + + it('an overwrite whose NEW content is at the whole-file bound reports before:null (no huge contextual diff)', async () => { + // The bound gates BOTH sides of the diff pair: a small prior file rewritten + // with at/above-bound content yields no contextual-hunk basis either, since + // a small-to-huge rewrite's hunk is as large as the new content — the + // consumer must fall back to the whole-file diff card, exactly like a + // create of the same size. + await remountWithDiffLimit(8) + await writeFile(join(dir, 'grow.txt'), 'tiny') + const target = await fs.resolve('grow.txt') + const outcome = await fs.writeText(target, '12345678') + expect(outcome.operation).toBe('update') + expect(outcome.before).toBeNull() + expect(outcome.after).toBe('12345678') + }) + + it('an overwrite with BOTH sides below the whole-file bound keeps its contextual before basis', async () => { + await remountWithDiffLimit(8) + await writeFile(join(dir, 'small.txt'), '1234567') + const target = await fs.resolve('small.txt') + const outcome = await fs.writeText(target, 'new') + expect(outcome.before).toBe('1234567') + expect(outcome.after).toBe('new') + }) + it('releases per-target mutation locks after success and failure', async () => { const target = await fs.resolve('a.txt') await fs.writeText(target, 'created', { kind: 'createIfAbsent' }) diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index 15588e40b9..32232c424c 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -5,7 +5,7 @@ * policy and lives in `dsh-fs-policy`, so it is not tested here. */ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { chmod, mkdtemp, readFile, rename, rm, stat, symlink, unlink, writeFile, mkdir, readdir, realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -16,6 +16,7 @@ import { probe, probeNoFollow, readForEdit, + readTextForDiff, readWholeText, resolveLocalTarget, restoreLineEndings, @@ -316,6 +317,205 @@ describe('readWholeText', () => { }) }) +describe('readTextForDiff', () => { + it('returns normalized text only when the opened file is strictly below the limit', async () => { + const file = join(dir, 'basis.txt') + await writeFile(file, 'a\r\nb') + expect(await readTextForDiff(file, 5)).toBe('a\nb') + expect(await readTextForDiff(file, 4)).toBeNull() + }) + + it('bounds the actual opened file rather than trusting an earlier path size', async () => { + const file = join(dir, 'replaced.txt') + await writeFile(file, 'tiny') + const earlierSize = (await stat(file)).size + await writeFile(file, '123456789') + expect(earlierSize).toBeLessThan(8) + expect(await readTextForDiff(file, 8)).toBeNull() + }) + + it('returns null when the opened file shrinks after descriptor stat', async () => { + const file = join(dir, 'shrinking.txt') + await writeFile(file, 'abcdef') + vi.resetModules() + vi.doMock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + async open(...args: Parameters) { + const handle = await actual.open(...args) + return { + close: handle.close.bind(handle), + read: handle.read.bind(handle), + async stat(...statArgs: Parameters) { + const info = await handle.stat(...statArgs) + await writeFile(file, 'abc') + return info + }, + } + }, + } + }) + + try { + const { readTextForDiff: isolatedReadTextForDiff } = await import('../src/fsio.ts') + expect(await isolatedReadTextForDiff(file, 8)).toBeNull() + } finally { + vi.doUnmock('node:fs/promises') + vi.resetModules() + } + }) + + it('returns null when the opened file grows after descriptor stat', async () => { + // Pins the one-extra-byte EOF probe: with a buffer of exactly openedSize a + // grown file would read openedSize bytes and pass the consistency check. + const file = join(dir, 'growing.txt') + await writeFile(file, 'abcdef') + vi.resetModules() + vi.doMock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + async open(...args: Parameters) { + const handle = await actual.open(...args) + return { + close: handle.close.bind(handle), + read: handle.read.bind(handle), + async stat(...statArgs: Parameters) { + const info = await handle.stat(...statArgs) + await writeFile(file, 'abcdef-grown') + return info + }, + } + }, + } + }) + + try { + const { readTextForDiff: isolatedReadTextForDiff } = await import('../src/fsio.ts') + expect(await isolatedReadTextForDiff(file, 32)).toBeNull() + } finally { + vi.doUnmock('node:fs/promises') + vi.resetModules() + } + }) + + it('returns null for binary and invalid UTF-8 without blocking the caller write', async () => { + await writeFile(join(dir, 'bin'), Buffer.from([0x68, 0x00, 0x69])) + await writeFile(join(dir, 'bad'), Buffer.from([0x68, 0xff, 0x69])) + expect(await readTextForDiff(join(dir, 'bin'), 8)).toBeNull() + expect(await readTextForDiff(join(dir, 'bad'), 8)).toBeNull() + }) + + it('honors a pre-aborted signal', async () => { + const file = join(dir, 'basis.txt') + await writeFile(file, 'text') + await expect(readTextForDiff(file, 8, AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' }) + }) + + it.each(['open', 'stat'] as const)('observes cancellation immediately after %s', async (stage) => { + const file = join(dir, 'basis.txt') + await writeFile(file, 'text') + const reached = Promise.withResolvers() + const release = Promise.withResolvers() + let statCalls = 0 + const allocate = vi.spyOn(Buffer, 'allocUnsafe') + vi.resetModules() + vi.doMock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + async open(...args: Parameters) { + const handle = await actual.open(...args) + if (stage === 'open') { + reached.resolve(undefined) + await release.promise + } + return { + close: handle.close.bind(handle), + read: handle.read.bind(handle), + async stat(...statArgs: Parameters) { + statCalls += 1 + const info = await handle.stat(...statArgs) + if (stage === 'stat') { + reached.resolve(undefined) + await release.promise + } + return info + }, + } + }, + } + }) + + try { + const { readTextForDiff: isolatedReadTextForDiff } = await import('../src/fsio.ts') + const controller = new AbortController() + const pending = isolatedReadTextForDiff(file, 8, controller.signal) + await reached.promise + const allocationCalls = allocate.mock.calls.length + controller.abort() + release.resolve(undefined) + await expect(pending).rejects.toMatchObject({ code: 'FS_ABORTED' }) + expect(statCalls).toBe(stage === 'open' ? 0 : 1) + expect(allocate).toHaveBeenCalledTimes(allocationCalls) + } finally { + release.resolve(undefined) + allocate.mockRestore() + vi.doUnmock('node:fs/promises') + vi.resetModules() + } + }) + + it('bounds descriptor reads and observes cancellation before the next chunk', async () => { + const file = join(dir, 'large-basis.txt') + const fileBytes = 200 * 1024 + await writeFile(file, 'x'.repeat(fileBytes)) + const firstRead = Promise.withResolvers() + const releaseFirstRead = Promise.withResolvers() + const readLengths: number[] = [] + vi.resetModules() + vi.doMock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + async open(...args: Parameters) { + const handle = await actual.open(...args) + return { + stat: handle.stat.bind(handle), + close: handle.close.bind(handle), + async read(buffer: Buffer, offset: number, length: number, position: number | null) { + readLengths.push(length) + const result = await handle.read(buffer, offset, length, position) + if (readLengths.length === 1) { + firstRead.resolve(undefined) + await releaseFirstRead.promise + } + return result + }, + } + }, + } + }) + + try { + const { readTextForDiff: isolatedReadTextForDiff } = await import('../src/fsio.ts') + const controller = new AbortController() + const pending = isolatedReadTextForDiff(file, fileBytes + 1, controller.signal) + await firstRead.promise + expect(readLengths).toEqual([64 * 1024]) + controller.abort() + releaseFirstRead.resolve(undefined) + await expect(pending).rejects.toMatchObject({ code: 'FS_ABORTED' }) + expect(readLengths).toHaveLength(1) + } finally { + releaseFirstRead.resolve(undefined) + vi.doUnmock('node:fs/promises') + vi.resetModules() + } + }) +}) + describe('streamWholeText', () => { it('streams the whole file as decoded text', async () => { const file = join(dir, 'a.txt') diff --git a/packages/fs/fs-sandbox/README.i18n.yaml b/packages/fs/fs-sandbox/README.i18n.yaml index 35a33d135b..4f7771cd3c 100644 --- a/packages/fs/fs-sandbox/README.i18n.yaml +++ b/packages/fs/fs-sandbox/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/fs/fs-sandbox/README.md -README.md: c40fc7999ab85a70702f65a5675208163f5fc351 -README.zh.md: 15db5abbfc5307c0570925026ec435d8dbb51bf2 +README.md: ae1fd746c711a86e308a02e0054ba478e8d913c0 +README.zh.md: e25a3467c06fbd93de9bb75d6b364e8da5a451fe diff --git a/packages/fs/fs-sandbox/README.md b/packages/fs/fs-sandbox/README.md index c40fc7999a..ae1fd746c7 100644 --- a/packages/fs/fs-sandbox/README.md +++ b/packages/fs/fs-sandbox/README.md @@ -4,6 +4,8 @@ English | [中文](README.zh.md) `SandboxedFileSystem` extends [`LocalFileSystem`](../fs-local/README.md) and registers as `ctx.fs`. It inherits every text-storage mechanic verbatim (resolve, stat, read/stream, list, the atomic write, the read-match-write edit critical section) and adds only a per-call MODE fence on `writeText`/`editText`. Reads always pass through — every mode permits reading. +Its plugin config is the local backend config unchanged: `cwd` remains the relative-path resolution default, and `diffBasisMaxBytes` bounds the optional overwrite contextual-diff basis. + Loading it INSTEAD OF `dsh-fs-local`, together with a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/README.md), is the whole swap; the model-facing tools (`dsh-tool-fs`) are untouched. The tool layer resolves the calling session's mode and cwd into the SAME per-call policy bash receives, so the two families never confine to different roots. ## The fence diff --git a/packages/fs/fs-sandbox/README.zh.md b/packages/fs/fs-sandbox/README.zh.md index 15db5abbfc..e25a3467c0 100644 --- a/packages/fs/fs-sandbox/README.zh.md +++ b/packages/fs/fs-sandbox/README.zh.md @@ -4,6 +4,8 @@ `SandboxedFileSystem` 扩展 [`LocalFileSystem`](../fs-local/README.md) 并注册为 `ctx.fs`。它逐字继承全部文本存储机制(解析、stat、读取/流式读取、列出、原子写入、按读取、匹配、写入顺序执行的编辑临界区),只为 `writeText`/`editText` 增加按调用的模式围栏。读取始终直接通过:所有模式都允许读取。 +它原样复用本地后端配置:`cwd` 仍是相对路径的解析默认值,`diffBasisMaxBytes` 则限制可选的覆写上下文 diff 基础。 + 只需加载它来替代 `dsh-fs-local`,并同时加载 [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/README.md),即可完成替换;面向模型的工具(`dsh-tool-fs`)无需改动。工具层把调用会话的模式和 cwd 解析为与 bash 相同的按调用策略,因此两个能力族绝不会约束到不同根目录。 ## 围栏 diff --git a/packages/fs/fs-sandbox/src/index.ts b/packages/fs/fs-sandbox/src/index.ts index 5e0121c89a..51248149ff 100644 --- a/packages/fs/fs-sandbox/src/index.ts +++ b/packages/fs/fs-sandbox/src/index.ts @@ -41,10 +41,10 @@ import type {} from '@deepseek-ai/dsh-sandbox-policy' import { isPathUnder } from './containment.ts' /** - * Plugin config: the local backend's knobs, verbatim (only `cwd`, the resolve - * base for relative paths). The sandbox default (mode + `workspace-write` - * fallback root) is NOT here — `ctx.sandboxPolicy` resolves each calling - * session for every enforcing capability. + * Plugin config: the local backend's knobs verbatim (`cwd` resolution default + * and `diffBasisMaxBytes` overwrite-presentation bound). The sandbox default + * (mode + `workspace-write` fallback root) is NOT here — `ctx.sandboxPolicy` + * resolves each calling session for every enforcing capability. */ export type Config = LocalConfig diff --git a/packages/fs/fs/src/types.ts b/packages/fs/fs/src/types.ts index f5753f09bd..a44f4c6bc7 100644 --- a/packages/fs/fs/src/types.ts +++ b/packages/fs/fs/src/types.ts @@ -123,10 +123,11 @@ export interface FsWriteOutcome { version: FsVersion /** * The file's content BEFORE the write, or `null` when the file did not exist - * (a create) or was undiffable (binary/non-UTF-8). LF-normalized storage text - * (the diff basis), never a diff — a consumer computes the result-time - * contextual diff from `before`/`after` when `before` is present, else falls - * back to a whole-file diff. + * (a create) or the backend declined a contextual basis (for example, a + * binary/non-UTF-8 prior file or either overwrite side reaching its exclusive limit). + * LF-normalized storage text (the diff basis), never a diff — a consumer + * computes the result-time contextual diff from `before`/`after` when + * `before` is present, else falls back to a whole-file diff. */ before: string | null /** The file's content AFTER the write, LF-normalized to share `before`'s diff basis. */ From d7705e2dcd8e8f8ca44e2188d9284014b478cdab Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:56:08 -0700 Subject: [PATCH 08/25] test(fs-local): pin multibyte byte-length gating --- packages/fs/fs-local/tests/filesystem.spec.ts | 12 ++++++++++++ packages/fs/fs-local/tests/fsio.spec.ts | 2 -- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts index 5b66eaa2a0..4f52e232d1 100644 --- a/packages/fs/fs-local/tests/filesystem.spec.ts +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -438,6 +438,18 @@ describe('writeText', () => { expect(outcome.after).toBe('12345678') }) + it('gates the NEW content by UTF-8 byte length, not character count', async () => { + // Three CJK characters are 9 UTF-8 bytes: below an 8-byte bound by + // characters but at/above it by bytes, so the basis must be declined. + await remountWithDiffLimit(8) + await writeFile(join(dir, 'cjk.txt'), 'tiny') + const target = await fs.resolve('cjk.txt') + const outcome = await fs.writeText(target, '你好吗') + expect(outcome.operation).toBe('update') + expect(outcome.before).toBeNull() + expect(outcome.after).toBe('你好吗') + }) + it('an overwrite with BOTH sides below the whole-file bound keeps its contextual before basis', async () => { await remountWithDiffLimit(8) await writeFile(join(dir, 'small.txt'), '1234567') diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index 32232c424c..d800997d0f 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -367,8 +367,6 @@ describe('readTextForDiff', () => { }) it('returns null when the opened file grows after descriptor stat', async () => { - // Pins the one-extra-byte EOF probe: with a buffer of exactly openedSize a - // grown file would read openedSize bytes and pass the consistency check. const file = join(dir, 'growing.txt') await writeFile(file, 'abcdef') vi.resetModules() From 9a299f9827e6d34158ce3e50d4ab5062c886f9a7 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Sat, 8 Aug 2026 11:49:42 -0700 Subject: [PATCH 09/25] fix(fs-local): degrade basis I/O failures to null Descriptor-phase errnos in readTextForDiff fold to before: null so a file deleted or made unreadable after the caller's preflight cannot fail the committed write; cancellation and non-errno faults still propagate. Drops the now-covered isFile v8 ignore, extends llm-replay with catalog capability parity (defaultMaxTokens/reasoningEfforts), and records the fs-write-overwrite-bounded keyless snapshot pinning the over-limit whole-file fallback through the real acp-agent composition. --- ...-30-bounded-overwrite-diff-basis.i18n.yaml | 4 +- ...2026-07-30-bounded-overwrite-diff-basis.md | 2 +- ...6-07-30-bounded-overwrite-diff-basis.zh.md | 2 +- examples/acp-agent/tests/acp.snapshot.ts | 16 ++++ .../tests/fs-diff-bound.cordis.snapshot.yml | 46 ++++++++++ .../acp-agent/tests/fs-diff-bound.cordis.yml | 29 +++++++ .../fs-write-overwrite-bounded/input.json | 7 ++ .../fs-write-overwrite-bounded/session.jsonl | 42 +++++++++ .../stdout.expected.jsonl | 4 + .../workspace/data.txt | 1 + packages/fs/fs-local/src/fsio.ts | 86 +++++++++++-------- packages/fs/fs-local/tests/fsio.spec.ts | 58 +++++++++++++ packages/support/llm-replay/src/index.ts | 27 +++++- 13 files changed, 281 insertions(+), 43 deletions(-) create mode 100644 examples/acp-agent/tests/fs-diff-bound.cordis.snapshot.yml create mode 100644 examples/acp-agent/tests/fs-diff-bound.cordis.yml create mode 100644 examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/input.json create mode 100644 examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/stdout.expected.jsonl create mode 100644 examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/workspace/data.txt diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.i18n.yaml index f7361eda32..da4e330119 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.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 .agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.md -2026-07-30-bounded-overwrite-diff-basis.md: 353b538a12b8cf48dfa3a561c62d6d8ab9a8bfcf -2026-07-30-bounded-overwrite-diff-basis.zh.md: e2b473a21d16dc47b5b8bf781b8ddffdf443955b +2026-07-30-bounded-overwrite-diff-basis.md: 7a09934bd1798059de43a092f338d37aa9ccbd9a +2026-07-30-bounded-overwrite-diff-basis.zh.md: 1d6bdd1068d119aae859132d9f8216ca29d0dc11 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.md b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.md index 353b538a12..7a09934bd1 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.md @@ -10,7 +10,7 @@ English | [中文](2026-07-30-bounded-overwrite-diff-basis.zh.md) ## Decision -`LocalFileSystem.Config.diffBasisMaxBytes` is a positive safe-integer deployment setting no greater than the runtime's Buffer-allocation and string-decoding limits, with a 10 MiB default. An overwrite supplies `before` only when the UTF-8 replacement is strictly below that limit and the prior file opened for the basis also ends below it. The prior read opens a descriptor, checks that descriptor, and reads at most the configured byte count in cancellation-aware chunks; reaching the boundary returns `null`. A size change after descriptor stat also returns `null`, even if the final size remains below the limit, because a partial prefix would be an incorrect diff basis. Binary or invalid UTF-8 prior content likewise returns `null`. These outcomes do not block the atomic write. +`LocalFileSystem.Config.diffBasisMaxBytes` is a positive safe-integer deployment setting no greater than the runtime's Buffer-allocation and string-decoding limits, with a 10 MiB default. An overwrite supplies `before` only when the UTF-8 replacement is strictly below that limit and the prior file opened for the basis also ends below it. The prior read opens a descriptor, checks that descriptor, and reads at most the configured byte count in cancellation-aware chunks; reaching the boundary returns `null`. A size change after descriptor stat also returns `null`, even if the final size remains below the limit, because a partial prefix would be an incorrect diff basis. Binary or invalid UTF-8 prior content likewise returns `null`, as does any descriptor-phase errno — a prior file deleted or made unreadable between the caller's preflight and the basis open cannot fail a write the caller already committed to; only cancellation and non-errno faults propagate. These outcomes do not block the atomic write. The local provider owns this decision because `before` is its optional, best-effort basis: it can avoid acquiring prior content that the configured pair limit has already made ineligible. `tool-fs` continues to own diff computation, retention, and presentation. The setting is independent of `tool-fs.readStreamMinSize`; read routing and overwrite presentation are different policies and need not share a value. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.zh.md index e2b473a21d..1d6bdd1068 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -`LocalFileSystem.Config.diffBasisMaxBytes` 是一个不超过运行时 Buffer 分配和字符串解码上限的正安全整数部署配置,默认 10 MiB。只有当 UTF-8 替换内容严格低于该上限,且为生成基础而打开的旧文件最终也低于该上限时,覆写才提供 `before`。旧文件读取会打开文件描述符、检查该描述符,并按可响应取消的分块最多读取配置的字节数;一旦到达边界便返回 `null`。描述符 stat 后发生大小变化时同样返回 `null`,即使最终大小仍低于上限,因为部分前缀会成为错误的 diff 基础。旧内容为二进制或无效 UTF-8 时也返回 `null`。这些结果都不会阻止原子写入。 +`LocalFileSystem.Config.diffBasisMaxBytes` 是一个不超过运行时 Buffer 分配和字符串解码上限的正安全整数部署配置,默认 10 MiB。只有当 UTF-8 替换内容严格低于该上限,且为生成基础而打开的旧文件最终也低于该上限时,覆写才提供 `before`。旧文件读取会打开文件描述符、检查该描述符,并按可响应取消的分块最多读取配置的字节数;一旦到达边界便返回 `null`。描述符 stat 后发生大小变化时同样返回 `null`,即使最终大小仍低于上限,因为部分前缀会成为错误的 diff 基础。旧内容为二进制或无效 UTF-8 时也返回 `null`;描述符阶段的任何 errno 同样如此——旧文件在调用方预检之后、基础读取打开之前被删除或变得不可读,不能让调用方已经提交的写入失败;只有取消和非 errno 故障会继续向上传播。这些结果都不会阻止原子写入。 本地提供方拥有该决策,因为 `before` 是它提供的可选、尽力而为的基础:当配置的成对上限已使替换内容不合格时,它可以避免获取旧内容。`tool-fs` 继续拥有 diff 计算、保留与展示。该配置独立于 `tool-fs.readStreamMinSize`;读取路由与覆写展示是不同策略,无需共享数值。 diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index c09dd7d2ed..79c63fb642 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -52,6 +52,7 @@ const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) const FS_SEARCH_CONFIG = fileURLToPath(new URL('./fs-search.cordis.yml', import.meta.url)) const PARTIAL_LANDLOCK_CONFIG = fileURLToPath(new URL('../partial-landlock.cordis.yml', import.meta.url)) const PWSH_CONFIG = fileURLToPath(new URL('./pwsh.cordis.yml', import.meta.url)) +const FS_DIFF_BOUND_CONFIG = fileURLToPath(new URL('./fs-diff-bound.cordis.yml', import.meta.url)) const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny' @@ -253,6 +254,21 @@ const SCENARIOS: Scenario[] = [ { name: 'fs-write', hasModelTurn: true, recorded: true }, { name: 'fs-edit', hasModelTurn: true, recorded: true }, { name: 'fs-write-overwrite', hasModelTurn: true, recorded: true }, + // An overwrite whose replacement is at/above the configured diff-basis bound: + // the persisted result meta carries no contextual hunks and presentation + // falls back to the whole-file diff. The overlay leaves the prompt and tool + // sequence identical to text-turn, but the freshly recorded header carries + // the current adapter capability fields, so the scenario pins its own class. + { + name: 'fs-write-overwrite-bounded', + hasModelTurn: true, + recorded: true, + pinsHeader: true, + headerClass: 'fs-diff-bound', + systemPromptSource: 'text-turn', + toolSchemasSource: 'text-turn', + configPath: FS_DIFF_BOUND_CONFIG, + }, { name: 'fs-read-window', hasModelTurn: true, recorded: true }, { name: 'fs-policy-reject', hasModelTurn: true, recorded: true }, { name: 'multi-turn', hasModelTurn: true, recorded: true }, diff --git a/examples/acp-agent/tests/fs-diff-bound.cordis.snapshot.yml b/examples/acp-agent/tests/fs-diff-bound.cordis.snapshot.yml new file mode 100644 index 0000000000..a216a34cfd --- /dev/null +++ b/examples/acp-agent/tests/fs-diff-bound.cordis.snapshot.yml @@ -0,0 +1,46 @@ +# Keyless replay counterpart to fs-diff-bound.cordis.yml. Replay patches apply +# directly against the live cordis.yml because include patches cannot target +# entries behind a nested include; the acp-agent restatement keeps the recorded +# deepseek-v4-flash model and raw JSONL persistence for the harness's harvest. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ../cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: + maxBytes: 65536 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + - id: fs-sandbox + name: '@deepseek-ai/dsh-fs-sandbox' + config: + cwd: !!js process.cwd() + diffBasisMaxBytes: 64 + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + # Capability parity with the live adapter so replay + # reconstructs the freshly recorded request header. + - id: deepseek-v4-flash + contextWindow: 1000000 + defaultMaxTokens: 256000 + reasoningEfforts: ['off', 'high', 'max'] + defaultReasoningEffort: max + - id: deepseek-v4-pro diff --git a/examples/acp-agent/tests/fs-diff-bound.cordis.yml b/examples/acp-agent/tests/fs-diff-bound.cordis.yml new file mode 100644 index 0000000000..9be25cdb25 --- /dev/null +++ b/examples/acp-agent/tests/fs-diff-bound.cordis.yml @@ -0,0 +1,29 @@ +# Live counterpart for the bounded-overwrite-diff snapshot: the base stack with +# the fs backend's overwrite diff-basis limit shrunk so a modest replacement +# crosses the exclusive bound and the write result falls back to a whole-file +# diff. A config patch replaces the row's whole config, so `cwd` is restated +# verbatim, and the acp-agent restatement re-pins `deepseek-v4-flash` to match +# the recorded corpus and its pinned request headers. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ../cordis.yml + patches: + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" + workspaceContext: + maxBytes: 65536 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + - id: fs-sandbox + name: '@deepseek-ai/dsh-fs-sandbox' + config: + cwd: !!js process.cwd() + diffBasisMaxBytes: 64 diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/input.json b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/input.json new file mode 100644 index 0000000000..480c37827a --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "First use the read tool to read data.txt in the current directory. Then use the write tool (NOT bash) to replace its entire contents with exactly this single line: The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound. Then reply with exactly the single word DONE." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/session.jsonl new file mode 100644 index 0000000000..688bf9cb70 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/session.jsonl @@ -0,0 +1,42 @@ +{"type":"session","version":0,"id":"14b14f51-2428-43a0-bcc5-5f392d4faa19","createdAt":1786204699215,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1786204699218,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"First use the read tool to read data.txt in the current directory. Then use the write tool (NOT bash) to replace its entire contents with exactly this single line: The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"41d72cfe-0e37-474f-83dc-2b15bacf9c0d"}]}} +{"type":"turn/start","seq":1,"time":1786204699219,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1786204699220,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1786204699259,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1786204699259,"data":{"content":[{"type":"text","text":"First use the read tool to read data.txt in the current directory. Then use the write tool (NOT bash) to replace its entire contents with exactly this single line: The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"41d72cfe-0e37-474f-83dc-2b15bacf9c0d"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1786204699260,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"e374fb32-1cad-4e2d-9cd3-66ac8fcf9588"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1786204699261,"data":{"title":"First use the read tool","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1786204699262,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"max"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1786204699262,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}} +{"type":"assistant/chunk","seq":9,"time":1786204701601,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1786204701602,"data":{"turn":1,"step":1,"index":0,"dt":[60,22,2,1,0,1,0,19,2,1,1,17,21,2,0,20,2,1,21,0,0,0,1,21,2],"texts":["The"," user"," wants"," me"," to"," read"," data",".txt"," first",","," then"," write"," to"," replace"," its"," contents"," with"," the"," exact"," line",","," then"," reply"," D","ONE","."]}} +{"type":"assistant/chunk","seq":36,"time":1786204701863,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":37,"time0":1786204701864,"data":{"turn":1,"step":1,"index":1,"dt":[21,2,0,21,2,1,0,26,1,0,17],"id":"call_00_Jxz49JNt6i4oaDnzes2I0794","name":"read","args":["","{","\"","file","_path","\"",": ","\"","data",".txt","\"","}"]}} +{"type":"assistant/chunk","seq":49,"time":1786204701981,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to read data.txt first, then write to replace its contents with the exact line, then reply DONE."}}}} +{"type":"assistant/chunk","seq":50,"time":1786204701982,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Jxz49JNt6i4oaDnzes2I0794","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}}}} +{"type":"assistant/chunk","seq":51,"time":1786204701982,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":5803,"outputTokens":71,"cacheReadTokens":0,"reasoningTokens":26}}}} +{"type":"assistant/chunk","seq":52,"time":1786204701982,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":53,"time":1786204701988,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to read data.txt first, then write to replace its contents with the exact line, then reply DONE."},{"type":"tool-call","id":"call_00_Jxz49JNt6i4oaDnzes2I0794","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9060e190-9971-4838-81bf-48c3e3888609"},"usage":{"inputTokens":5803,"outputTokens":71,"cacheReadTokens":0,"reasoningTokens":26}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52],"surfaceOp":"append"} +{"type":"tool/call","seq":54,"time":1786204701990,"data":{"turn":1,"step":1,"callId":"call_00_Jxz49JNt6i4oaDnzes2I0794","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}} +{"type":"tool/result","seq":55,"time":1786204702006,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Jxz49JNt6i4oaDnzes2I0794"},"content":[{"type":"tool-result","toolCallId":"call_00_Jxz49JNt6i4oaDnzes2I0794","content":[{"type":"text","text":"{{cwd}}/data.txt\nfile\n\n1: original contents\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"1406fd7d-f181-41d0-b0db-ef196010f620"},"meta":{"path":"{{cwd}}/data.txt","offset":1,"lines":[{"number":1,"text":"original contents"}],"totalLines":1}},"sourceEventSeqs":[54],"surfaceOp":"append"} +{"type":"step/end","seq":56,"time":1786204702006,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":57,"time":1786204702016,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":58,"time":1786204703539,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":59,"time0":1786204703539,"data":{"turn":1,"step":2,"index":0,"dt":[0,1,0,0,0,1,0,0,1,0,12,2,0,0,0,22,35,1,0,0,0,0,0,1,0,8,2,0,0,71,1,0,0,1,0],"id":"call_00_ET_7mLiYX652hJA9GW6d1bl4653","name":"write","args":["","{","\"","file","_path","\"",": ","\"","data",".txt","\"",", ","\"","content","\"",": ","\"","The"," replacement"," line"," is"," deliberately"," longer"," than"," the"," configured"," sixty","-four"," byte"," diff","-b","asis"," bound",".","\"","}"]}} +{"type":"assistant/chunk","seq":95,"time":1786204703720,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_00_ET_7mLiYX652hJA9GW6d1bl4653","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound.\"}"}}}} +{"type":"assistant/chunk","seq":96,"time":1786204703720,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":202,"outputTokens":76,"cacheReadTokens":5760,"reasoningTokens":0}}}} +{"type":"assistant/chunk","seq":97,"time":1786204703720,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":98,"time":1786204703722,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_00_ET_7mLiYX652hJA9GW6d1bl4653","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"46d3792a-eded-45e7-8151-00ca0584f10b"},"usage":{"inputTokens":202,"outputTokens":76,"cacheReadTokens":5760,"reasoningTokens":0}},"sourceEventSeqs":[58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97],"surfaceOp":"append"} +{"type":"tool/call","seq":99,"time":1786204703722,"data":{"turn":1,"step":2,"callId":"call_00_ET_7mLiYX652hJA9GW6d1bl4653","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound.\"}"}} +{"type":"tool/result","seq":100,"time":1786204703740,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_ET_7mLiYX652hJA9GW6d1bl4653"},"content":[{"type":"tool-result","toolCallId":"call_00_ET_7mLiYX652hJA9GW6d1bl4653","content":[{"type":"text","text":"{{cwd}}/data.txt\nfile\n\nUpdated file\n"}],"isError":false}],"role":"user","id":"98c41fc1-6ce6-445f-94f7-32aa7e1c6ea7"},"meta":{"diffs":[]}},"sourceEventSeqs":[99],"surfaceOp":"append"} +{"type":"step/end","seq":101,"time":1786204703740,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":102,"time":1786204703749,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":103,"time":1786204705029,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":104,"time":1786204705029,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":105,"time":1786204705053,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":106,"time":1786204705055,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":107,"time":1786204705055,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":100,"outputTokens":3,"cacheReadTokens":6016,"reasoningTokens":0}}}} +{"type":"assistant/chunk","seq":108,"time":1786204705056,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":109,"time":1786204705057,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ddf50859-b0b9-404d-a71c-a1f11ff53341"},"usage":{"inputTokens":100,"outputTokens":3,"cacheReadTokens":6016,"reasoningTokens":0}},"sourceEventSeqs":[103,104,105,106,107,108],"surfaceOp":"append"} +{"type":"step/end","seq":110,"time":1786204705057,"data":{"turn":1,"step":3}} +{"type":"turn/end","seq":111,"time":1786204705058,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/stdout.expected.jsonl new file mode 100644 index 0000000000..82ae8907ca --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/workspace/data.txt b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/workspace/data.txt new file mode 100644 index 0000000000..3359a4b8d9 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/workspace/data.txt @@ -0,0 +1 @@ +original contents \ No newline at end of file diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index b47d6f2bcd..cba29e873c 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -565,15 +565,16 @@ export async function readForEdit( } /** - * Best-effort overwrite diff basis. Binary, invalid UTF-8, or a file at/above the byte limit - * returns `null` so the write still succeeds and presentation falls back to a whole-file diff. - * The bound is enforced on the opened descriptor rather than a prior path stat, so concurrent - * external replacement or size changes cannot make this helper buffer more than `maxBytes`. - * @param absolutePath - the file to read (typically a target key); it must exist. + * Best-effort overwrite diff basis. Binary, invalid UTF-8, a file at/above the byte limit, + * or a file deleted/made unreadable after the caller's preflight returns `null` so the write + * still succeeds and presentation falls back to a whole-file diff. The bound is enforced on + * the opened descriptor rather than a prior path stat, so concurrent external replacement or + * size changes cannot make this helper buffer more than `maxBytes`. + * @param absolutePath - the file to read (typically a target key). * @param maxBytes - exclusive upper bound for bytes held as the contextual-diff basis. - * @param signal - aborts the read (`FS_ABORTED`). + * @param signal - aborts the read (`FS_ABORTED`); cancellation propagates, unlike I/O failure. * @returns the LF-normalized text, or null for a non-regular, at/above-limit, binary, non-UTF-8, - * or descriptor-size-changed file. + * descriptor-size-changed, or unreadable file. */ export async function readTextForDiff( absolutePath: string, @@ -581,41 +582,50 @@ export async function readTextForDiff( signal?: AbortSignal, ): Promise { throwIfAborted(signal, 'read') - const handle = await open(absolutePath, 'r') - let buffer: Buffer - let total = 0 - let openedSize = 0 try { - throwIfAborted(signal, 'read') - const info = await handle.stat() - throwIfAborted(signal, 'read') - /* v8 ignore next -- requires a post-preflight replacement with a non-file; - * direct coverage is not portable to Windows. */ - if (!info.isFile()) return null - if (info.size >= maxBytes) return null - openedSize = info.size - // One extra byte detects growth after stat without retaining per-read backing buffers. - buffer = Buffer.allocUnsafe(openedSize + 1) - while (total < buffer.length) { + const handle = await open(absolutePath, 'r') + let buffer: Buffer + let total = 0 + let openedSize = 0 + try { throwIfAborted(signal, 'read') - const length = Math.min(buffer.length - total, DIFF_BASIS_READ_CHUNK_BYTES) - const { bytesRead } = await handle.read(buffer, total, length, null) - if (bytesRead === 0) break - total += bytesRead + const info = await handle.stat() + throwIfAborted(signal, 'read') + if (!info.isFile()) return null + if (info.size >= maxBytes) return null + openedSize = info.size + // One extra byte detects growth after stat without retaining per-read backing buffers. + buffer = Buffer.allocUnsafe(openedSize + 1) + while (total < buffer.length) { + throwIfAborted(signal, 'read') + const length = Math.min(buffer.length - total, DIFF_BASIS_READ_CHUNK_BYTES) + const { bytesRead } = await handle.read(buffer, total, length, null) + if (bytesRead === 0) break + total += bytesRead + } + } finally { + await handle.close() + } + throwIfAborted(signal, 'read') + if (total !== openedSize) return null + const basis = buffer.subarray(0, total) + if (basis.includes(0)) return null + try { + return normalizeLineEndings(new TextDecoder('utf-8', { fatal: true }).decode(basis)) + } catch (error: unknown) { + /* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes; + * any other throw is an unreachable runtime fault. */ + if (!(error instanceof TypeError)) throw error + return null } - } finally { - await handle.close() - } - throwIfAborted(signal, 'read') - if (total !== openedSize) return null - const basis = buffer.subarray(0, total) - if (basis.includes(0)) return null - try { - return normalizeLineEndings(new TextDecoder('utf-8', { fatal: true }).decode(basis)) } catch (error: unknown) { - /* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes; any other throw is an unreachable runtime fault. */ - if (!(error instanceof TypeError)) throw error - return null + // Cancellation is the caller's intent and still propagates. + if (error instanceof FsError) throw error + // A descriptor-phase errno — deleted or made unreadable after the caller's + // preflight, or a faulted read — costs only the optional basis: a committed + // write must not fail for a presentation-only pre-read. + if (error instanceof Error && 'code' in error) return null + throw error } } diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index d800997d0f..a30c171fd9 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -398,6 +398,64 @@ describe('readTextForDiff', () => { } }) + it('returns null when the file vanishes before the basis open (deletion race)', async () => { + expect(await readTextForDiff(join(dir, 'deleted-after-preflight.txt'), 32)).toBeNull() + }) + + it('returns null when the opened descriptor is no longer a regular file', async () => { + const file = join(dir, 'swapped.txt') + await writeFile(file, 'abcdef') + vi.resetModules() + vi.doMock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + async open(...args: Parameters) { + const handle = await actual.open(...args) + return { + close: handle.close.bind(handle), + read: handle.read.bind(handle), + async stat(...statArgs: Parameters) { + const info = await handle.stat(...statArgs) + return Object.assign(info, { isFile: () => false }) + }, + } + }, + } + }) + + try { + const { readTextForDiff: isolatedReadTextForDiff } = await import('../src/fsio.ts') + expect(await isolatedReadTextForDiff(file, 32)).toBeNull() + } finally { + vi.doUnmock('node:fs/promises') + vi.resetModules() + } + }) + + it('propagates a non-errno fault instead of masking it as a null basis', async () => { + const file = join(dir, 'faulted.txt') + await writeFile(file, 'abcdef') + vi.resetModules() + vi.doMock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + async open() { + throw new TypeError('forged programming fault') + }, + } + }) + + try { + const { readTextForDiff: isolatedReadTextForDiff } = await import('../src/fsio.ts') + await expect(isolatedReadTextForDiff(file, 32)).rejects.toThrow('forged programming fault') + } finally { + vi.doUnmock('node:fs/promises') + vi.resetModules() + } + }) + it('returns null for binary and invalid UTF-8 without blocking the caller write', async () => { await writeFile(join(dir, 'bin'), Buffer.from([0x68, 0x00, 0x69])) await writeFile(join(dir, 'bad'), Buffer.from([0x68, 0xff, 0x69])) diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index 9af72bbbec..de43d19e96 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -24,7 +24,7 @@ import type { StreamChunk, TokenUsage, } from '@deepseek-ai/dsh-llm' -import { LlmAdapter, LlmError, assertNever, resolveRetryPolicy } from '@deepseek-ai/dsh-llm' +import { LlmAdapter, LlmError, ReasoningEffortId, assertNever, resolveRetryPolicy } from '@deepseek-ai/dsh-llm' /** * One recorded model call. `throw` may replay prefix chunks before failing; @@ -51,6 +51,18 @@ export interface ReplayModelConfig { description?: string /** Optional positive integer context capacity published by the replay adapter. */ contextWindow?: number + /** + * Optional per-request output cap the replay route materializes when callers + * omit one, so replay reconstructs the request header a live catalog produced. + */ + defaultMaxTokens?: number + /** Optional reasoning-effort ids the replay route accepts, in display order. */ + reasoningEfforts?: string[] + /** + * Optional effort materialized when callers omit one; must appear in + * {@link reasoningEfforts} or call resolution rejects the route. + */ + defaultReasoningEffort?: string } /** One provider route exposed by the replay adapter. */ @@ -585,6 +597,19 @@ class ReplayAdapter extends LlmAdapter { ...configuredModel?.contextWindow === undefined ? {} : { context: { contextWindow: configuredModel.contextWindow } }, + ...configuredModel?.defaultMaxTokens === undefined + ? {} + : { defaultMaxTokens: configuredModel.defaultMaxTokens }, + ...configuredModel?.reasoningEfforts === undefined + ? {} + : { + reasoning: { + efforts: configuredModel.reasoningEfforts.map(id => ({ id: ReasoningEffortId(id), name: id })), + ...configuredModel.defaultReasoningEffort === undefined + ? {} + : { defaultEffort: ReasoningEffortId(configuredModel.defaultReasoningEffort) }, + }, + }, }) } From 6f54fd203d55e3a7cda5245d7977df854c3c2e7a Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:23:32 -0700 Subject: [PATCH 10/25] test(llm-replay): cover catalog capability parity Also re-generates the config catalog after the docs-restructure merge. --- docs/config-catalog.md | 16 +++++++++++++-- .../llm-replay/tests/llm-replay.spec.ts | 20 +++++++++++++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index b9ce45cd3c..d370fc945f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -444,7 +444,7 @@ export interface Config { } ``` -Source: [`packages/fs/fs-local/src/index.ts:39`](../packages/fs/fs-local/src/index.ts) +Source: [`packages/fs/fs-local/src/index.ts:40`](../packages/fs/fs-local/src/index.ts) ## `@deepseek-ai/dsh-fs-sandbox` @@ -907,12 +907,24 @@ export interface ReplayModelConfig { description?: string /** Optional positive integer context capacity published by the replay adapter. */ contextWindow?: number + /** + * Optional per-request output cap the replay route materializes when callers + * omit one, so replay reconstructs the request header a live catalog produced. + */ + defaultMaxTokens?: number + /** Optional reasoning-effort ids the replay route accepts, in display order. */ + reasoningEfforts?: string[] + /** + * Optional effort materialized when callers omit one; must appear in + * {@link reasoningEfforts} or call resolution rejects the route. + */ + defaultReasoningEffort?: string } ``` Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/support/llm-replay/src/index.ts:744`](../packages/support/llm-replay/src/index.ts) +Source: [`packages/support/llm-replay/src/index.ts:769`](../packages/support/llm-replay/src/index.ts) ## `@deepseek-ai/dsh-llm-retry` diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index 4be9e01c9d..8a30a23c5d 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -578,8 +578,14 @@ describe('installLlmReplay (through the real LlmService)', () => { backoff: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }, }, models: [ - { id: 'flash', contextWindow: 128_000 }, - { id: 'pro', name: 'Pro', description: 'Larger model' }, + { + id: 'flash', + contextWindow: 128_000, + defaultMaxTokens: 64_000, + reasoningEfforts: ['off', 'max'], + defaultReasoningEffort: 'max', + }, + { id: 'pro', name: 'Pro', description: 'Larger model', reasoningEfforts: ['high'] }, ], }, { id: 'empty' }, @@ -597,8 +603,18 @@ describe('installLlmReplay (through the real LlmService)', () => { await expect(ctx.llm.listModels('empty')).resolves.toEqual([]) await expect(ctx.llm.resolveModelInfo('deepseek', 'flash')).resolves.toMatchObject({ context: { contextWindow: 128_000 }, + defaultMaxTokens: 64_000, + reasoning: { + efforts: [{ id: 'off', name: 'off' }, { id: 'max', name: 'max' }], + defaultEffort: 'max', + }, }) await expect(ctx.llm.resolveModelInfo('deepseek', 'pro')).resolves.not.toHaveProperty('context') + // Efforts without a configured default preserve the provider's own default. + await expect(ctx.llm.resolveModelInfo('deepseek', 'pro')).resolves.toMatchObject({ + reasoning: { efforts: [{ id: 'high', name: 'high' }] }, + }) + await expect(ctx.llm.resolveModelInfo('deepseek', 'pro')).resolves.not.toHaveProperty('defaultMaxTokens') await expect(ctx.llm.resolveModelInfo('deepseek', 'unlisted')).resolves.not.toHaveProperty('context') await expect(ctx.llm.resolveModelInfo('empty', 'unlisted')).resolves.not.toHaveProperty('context') expect(ctx.llm.providerRetryPolicy('deepseek')).toMatchObject({ From cc527dfa9aba44e0f253ea21dcf3e5b45ef5f712 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 17:02:11 +0800 Subject: [PATCH 11/25] fix(web): order preset before subagent list --- packages/client/ui-agent-preset/README.i18n.yaml | 4 ++-- packages/client/ui-agent-preset/README.md | 2 +- packages/client/ui-agent-preset/README.zh.md | 2 +- packages/client/ui-agent-preset/src/client/index.ts | 2 +- packages/client/ui-agent-preset/tests/apply.spec.ts | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 6943e47673..b1314b349e 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/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-agent-preset/README.md -README.md: 32a4e7d9e25d3c70d2cc2e8a01c94d093d19659c -README.zh.md: b65a1bdf926f7a34bc3813833ca5ac2d3b6dfabd +README.md: 0f1daeaa6014d4c3c88e6a69ff90cf1ecacdbaf7 +README.zh.md: 08e25d9e98b83a94a434248bb3dff60da1cc31ba diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index 32a4e7d9e2..0f1daeaa60 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -18,7 +18,7 @@ A session that has started is refused rather than queued: the host answers `agen ## The session-header label -A third surface, beside the session title: the preset THIS session runs, as static chrome. A control there would promise a switch the host refuses outright. It reads the preset from the session's own summary — a resumed session runs what it was created with, not today's default — and resolves the display name against the same roster the General row reads. +A third surface, beside the session title: the preset THIS session runs, as static chrome. It precedes the subagent catalog in the header action row. A control there would promise a switch the host refuses outright. It reads the preset from the session's own summary — a resumed session runs what it was created with, not today's default — and resolves the display name against the same roster the General row reads. ## What it reads and writes diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index b65a1bdf92..08e25d9e98 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -18,7 +18,7 @@ chip 以部署默认值打开,其选择是**暂存**的——该界面先于 ## 会话标题旁的标签 -第三个表层,位于会话标题旁:**本会话**所运行的 preset,作为静态装饰呈现。在那里放一个控件,等于承诺一次宿主会断然拒绝的切换。它从会话自身的摘要读取 preset——被恢复的会话运行的是它创建时的那一份,而非今天的默认值——并在 General 行所读的同一份名单上解析显示名称。 +第三个表层,位于会话标题旁:**本会话**所运行的 preset,作为静态装饰呈现。它在头部操作行中排在 subagent 列表之前。在那里放一个控件,等于承诺一次宿主会断然拒绝的切换。它从会话自身的摘要读取 preset——被恢复的会话运行的是它创建时的那一份,而非今天的默认值——并在 General 行所读的同一份名单上解析显示名称。 ## 它读什么、写什么 diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index 97a7f66ce2..0737992337 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -157,7 +157,7 @@ export function apply(ctx: ClientContext): void { const label = scope.slots.register({ name: 'conversation.session.header.actions', id: 'agent-preset', - order: 20, + order: 0, locale: 'settings.agentPreset', inject: labelInjected, }, AgentPresetLabel) diff --git a/packages/client/ui-agent-preset/tests/apply.spec.ts b/packages/client/ui-agent-preset/tests/apply.spec.ts index 6272501183..4a6f58075f 100644 --- a/packages/client/ui-agent-preset/tests/apply.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.spec.ts @@ -304,7 +304,7 @@ describe('ui-agent-preset apply', () => { expect(chip.component).toBe(AgentPresetSeat) const label = slots.entries('conversation.session.header.actions')[0]! expect(label.component).toBe(AgentPresetLabel) - expect(label.options).toMatchObject({ id: 'agent-preset', order: 20 }) + expect(label.options).toMatchObject({ id: 'agent-preset', order: 0 }) await fiber.dispose() expect(slots.entries('conversation.hero.agentPreset')).toHaveLength(0) expect(slots.entries('conversation.session.header.actions')).toHaveLength(0) From e53f44865065d4583c109c2bfea64439d9c9bd27 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 17:46:34 +0800 Subject: [PATCH 12/25] fix(subagent): compose children from their parent's preset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool and prompt-section visibility is inherited along dsh-scope's parent chain, and an agent's scope key is minted with no parent. Per-session agent presets moved every model-facing row onto the agent plane and made AgentPresets.mount() the one thing that binds that link, from the api-proxy's session create, resume, and fork paths. The two in-process subagent drivers installed only the per-child persona and tool filter, so a child's scope chain had length one and its registry view resolved the global layer alone — which is empty wherever a preset roster is composed. One-shot children reached the model with no tools, continuable ones with only the host-plane `report`, and neither carried its parent's persona, workspace context, or skill catalog. AgentPresets.composeFrom() joins one agent to the standing composition another already runs on. It is a bind, not a mount: the child gets its parent's exact generation, so a composition edited since the parent started cannot fork it onto another one, and it is synchronous, which is what lets a child creation window use it. applyChildComposition() now takes the parent and performs the join first, making a child composed without it unrepresentable at the call sites. childSessionMeta() records the joined id so a cold read rebuilds the composition the child actually ran under. The audit that followed found two api-proxy readers on the wrong authority: presenterScopeFor() and the live-agent branch of assertPresetUnchanged() both read header.agentPreset, which goes stale the moment a blank session switches preset. A switched session's cold transcript resolved presenters in the older composition's layer and silently degraded to generic cards, and the gateway refused to adopt a live session under the preset it actually runs while accepting the one it left. Both now resolve through resolveSessionPreset(), matching the resume branch fifteen lines above. The owning architecture Agent Note carried the stale claim that the header records what a session runs; it is corrected to name the header/log pair and its three readers. Fixes #2165 --- ...-08-03-per-session-agent-presets.i18n.yaml | 4 +- .../2026-08-03-per-session-agent-presets.md | 2 +- ...2026-08-03-per-session-agent-presets.zh.md | 2 +- ...-agents-join-their-parent-preset.i18n.yaml | 6 + ...0-child-agents-join-their-parent-preset.md | 47 +++++++ ...hild-agents-join-their-parent-preset.zh.md | 47 +++++++ apps/cli/package.json | 1 + apps/cli/tests/web-agent-presets.e2e.ts | 54 ++++++++ docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 +- docs/module-graph.zh.md | 3 +- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 37 ++++++ docs/subsystems/core.zh.md | 37 ++++++ packages/host/apiproxy/src/api-proxy.ts | 24 ++-- .../tests/api-proxy-agent-preset.spec.ts | 39 ++++++ .../preset/agent-presets/README.i18n.yaml | 4 +- packages/preset/agent-presets/README.md | 10 ++ packages/preset/agent-presets/README.zh.md | 10 ++ packages/preset/agent-presets/src/index.ts | 54 +++++++- packages/preset/agent-presets/src/mount.ts | 36 ++++-- .../preset/agent-presets/tests/mount.spec.ts | 73 +++++++++++ .../tool-cordis/src/api-catalog.ts | 8 ++ .../subagent/subagent-inprocess/package.json | 3 + .../subagent/subagent-inprocess/src/index.ts | 2 +- .../tests/fixtures/plugins/preset-tool.js | 20 +++ .../fixtures/presets/coding/agent.cordis.yml | 5 + .../tests/preset-inheritance.spec.ts | 116 ++++++++++++++++++ packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 4 + packages/subagent/subagent/README.zh.md | 4 + packages/subagent/subagent/package.json | 5 + packages/subagent/subagent/src/child-agent.ts | 47 +++++-- .../subagent/subagent/src/continuation.ts | 2 +- packages/subagent/subagent/tsconfig.json | 3 + pnpm-lock.yaml | 15 +++ 36 files changed, 698 insertions(+), 41 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md create mode 100644 packages/subagent/subagent-inprocess/tests/fixtures/plugins/preset-tool.js create mode 100644 packages/subagent/subagent-inprocess/tests/fixtures/presets/coding/agent.cordis.yml create mode 100644 packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml index f3d763058b..f9e1b3c024 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.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 .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md -2026-08-03-per-session-agent-presets.md: 6f1643c25008c3363cb10adb7fbff7afeea31cbe -2026-08-03-per-session-agent-presets.zh.md: 7afe9ade5c98fadb96384a7e0acd47531c370e0c +2026-08-03-per-session-agent-presets.md: c39117ab0de001650a95f98ccf3e42f3a5034c92 +2026-08-03-per-session-agent-presets.zh.md: 5e98a2865013a355c134317ded8a4f2ddaccf42c diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md index 6f1643c250..c39117ab0d 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md @@ -31,7 +31,7 @@ Which preset an unnamed session gets is a user setting (`agent-presets.default`) ## Consequences -**The effective default is read per resolution, never snapshotted.** A cached value would need a `watch` subscription and a reload path to stay honest, and the resolved scope already re-reads a hot-reloaded document. Reading through is also what makes the boundary correct rather than merely cheap: the new value applies to the next session created, and every running session keeps the composition it was built from. That invariant is the same one the session header enforces from the other side — the header records the id a session actually runs, so a resume rebuilds that composition rather than today's default, and the gateway rejects an attempt to adopt a live session under a different one. A snapshot would make the two disagree at exactly the moment the setting changes. +**The effective default is read per resolution, never snapshotted.** A cached value would need a `watch` subscription and a reload path to stay honest, and the resolved scope already re-reads a hot-reloaded document. Reading through is also what makes the boundary correct rather than merely cheap: the new value applies to the next session created, and every running session keeps the composition it was built from. That invariant is the same one the session log enforces from the other side — the header records the id a session was CREATED with and an `agent-preset/selected` event records any later blank-session switch, so a reader resolves the pair (`resolveSessionPreset`) and never the header alone: a resume rebuilds the composition its history was produced under rather than today's default, a cold transcript's presenters resolve in that composition's layer, and the gateway rejects an attempt to adopt a live session under a preset other than the one it currently runs. A snapshot would make the two disagree at exactly the moment the setting changes. **A directly-plugged subtree is invisible to the boot audit.** It never links itself to an `Entry`, so it is absent from `ctx.loader.entries()` and `assertEntriesActivated` cannot see it. The mount audits its own rows instead, reading the tree through an `Include` subclass that publishes it. diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md index 7afe9ade5c..5e98a28650 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md @@ -31,7 +31,7 @@ Status: implemented ## 后果 -**有效默认值在每次解析时读取,从不快照。** 缓存下来就需要一个 `watch` 订阅和一条重载路径才能保持诚实,而解析后的 scope 本来就会重读热重载过的文档。读穿也不只是省事,它让边界本身是对的:新值作用于**下一个新建的会话**,每个运行中的会话保持它被构建时的那份组装。这条不变量正是 session header 从另一侧执行的同一条——header 记录会话实际运行的 id,因此恢复重建的是那份组装而不是当下的默认值,网关也会拒绝把一个活着的会话收编到另一个 preset 之下。快照会让两者恰好在设置改变的那一刻各说各话。 +**有效默认值在每次解析时读取,从不快照。** 缓存下来就需要一个 `watch` 订阅和一条重载路径才能保持诚实,而解析后的 scope 本来就会重读热重载过的文档。读穿也不只是省事,它让边界本身是对的:新值作用于**下一个新建的会话**,每个运行中的会话保持它被构建时的那份组装。这条不变量正是 session 日志从另一侧执行的同一条——header 记录会话**创建时**的 id,此后空白期的任何切换由 `agent-preset/selected` 事件记录,因此读取方解析的是两者之和(`resolveSessionPreset`)、绝不单看 header:恢复重建的是其历史所产出的那份组装而不是当下的默认值,冷读记录的 presenter 在那份组装的层里解析,网关也会拒绝把一个活着的会话收编到它当前运行的 preset 以外的 preset 之下。快照会让两者恰好在设置改变的那一刻各说各话。 **直接挂载的子树对启动审计不可见。** 它不会把自己关联到 `Entry`,因此不在 `ctx.loader.entries()` 中,`assertEntriesActivated` 也看不到它。改由挂载过程自行校验各行,通过一个会公开自身 tree 的 `Include` 子类读取。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml new file mode 100644 index 0000000000..9afec2a879 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.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 .agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md +2026-08-10-child-agents-join-their-parent-preset.md: c9917c48d10c2b2515284405ea52aed8b476f8b1 +2026-08-10-child-agents-join-their-parent-preset.zh.md: 09e4de5292b65e50bb3973704fd803c819be9f1c diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md new file mode 100644 index 0000000000..c9917c48d1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md @@ -0,0 +1,47 @@ +# Agent Note: Child agents join their parent's preset composition + +Status: implemented + +English | [中文](2026-08-10-child-agents-join-their-parent-preset.zh.md) + +## Problem + +Tool and prompt-section visibility is inherited along `dsh-scope`'s parent chain, and an agent's scope key is minted with no parent. [Per-session agent presets](../architecture/2026-08-03-per-session-agent-presets.md) moved every model-facing row onto the agent plane and made `AgentPresets.mount()` the one thing that binds that parent link — from the api-proxy's session create, resume, and fork paths. The two in-process subagent drivers compose their children through `applyChildComposition()`, which installed only the per-child persona and tool filter, so a child's scope chain had length one and its registry view resolved the global layer alone. + +That layer is now empty in any deployment with a preset roster: the web-app patch layer disables every host-plane tool row. A one-shot child therefore reached the model with zero tools, a continuable child with only the host-plane `report`, and neither carried its parent's persona, workspace context, plan-mode section, or skill catalog. The fork path had already been given the same treatment for the same reason; delegation had not. + +The child's durable header compounded it. `childSessionMeta()` recorded no preset, so a cold read of a child session resolved the deployment default — a tool set the child never ran under, which is exactly what the model-visible ⟺ logged rule exists to prevent. + +## Decision + +`AgentPresets.composeFrom(agentCtx, parentCtx)` joins one agent to the standing composition another already runs on, and returns the preset id joined. It locates the parent's mount through `standingMountFor()` — the agent's key is parented to its preset's standing key, the same relation `serviceForAgent()` reads — and binds the child's key to that same standing key, keeping the binding under the roster's sole re-link authority. A parent that joined no preset yields no join and no error, which is the rosterless deployment: its model-facing rows sit in the host composition, where the child already resolves them through the global layer. + +This is a bind, not a mount, and both differences are load-bearing. The child gets its parent's exact generation, so a composition file edited since the parent started cannot hand the child a different one than its parent's history was produced under, and a preset deleted since cannot fail a child whose parent keeps running. It is also synchronous, which is what lets the child creation windows use it — both in-process drivers compose inside a synchronous `setup`. + +`applyChildComposition(childCtx, parent, composition)` takes the parent and performs the join before applying the child's own registrations. The parameter is the point: it makes composing a child without the join unrepresentable at the call sites, rather than leaving each new driver to remember a second step. `childSessionMeta()` records the joined id through `AgentPresets.composedPreset()`, read from the parent's live scope chain rather than its header, because a parent that switched preset while blank runs on the newer composition while its header still names the older one. + +`dsh-subagent` reaches the roster through `ctx.get('agentPresets')` with a type-only import and an optional peer dependency — the documented opportunistic-consumption pattern it already uses for `sandboxPolicy` and `approval`. + +## Alternatives considered + +**Re-mount the parent's preset by id in the child's setup.** Rejected on both semantics and mechanics. It re-reads the roster and re-stats the composition file, so an edit since the parent started forks the child onto a different generation, and a preset deleted since fails the child while its parent runs on. `mount()` is also asynchronous, which the synchronous creation windows cannot accept without restructuring both drivers. + +**Bind the child's key to the PARENT's key rather than to the standing mount.** Rejected because it changes what a child inherits: the parent's own scope layer carries its per-agent restrictions, which would then intersect into every descendant, and a child outliving its parent would hang off a disposed agent's key. Joining the standing mount gives the child its parent's composition and nothing else. + +**Extend the continuable activation setup registry to cover one-shot children.** Rejected because that registry's contribution type is synchronous `(childCtx) => () => void` with per-installation revocation, modelling deployment capabilities that come and go, while a preset join is a one-time bind with no revocation of its own. Widening it would have made the omission possible again for any driver that skipped the registry. + +**Let `dsh-subagent` import `resolveSessionPreset` and mount by the resolved id.** Rejected because it makes the preset roster a hard module edge for a package that must work without one, and it lands back on the remount semantics above. + +**Leave the durable header alone and fix only the live join.** Rejected because the live child and the same child read cold would then disagree about which composition produced its history — the same class of defect, moved rather than fixed. + +## Testing + +`packages/preset/agent-presets/tests/mount.spec.ts` covers the join against real fixture compositions: the child sees its parent's tools and prompt sections, no second generation is mounted, the join survives the parent's disposal (a background child outliving its parent), the reported id matches, a parent without a preset joins nothing, and an unscoped context is refused. + +`packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts` asserts the model-visible result through `startInProcessRun()` on a host composition carrying no model-facing rows: the schemas in the child's own request, its parent's prompt section, the recorded header preset, and a parent that switched preset while blank. + +## Consequences + +Delegation now costs a scope-parent bind per child and nothing else — no extra plugin instances, no roster read, no failure mode. A child's capabilities are exactly its parent's, minus whatever its own `toolFilter` removes; a per-subagent preset ("agent types") remains unbuilt and would be a new request field rather than a change to this join. + +`applyChildComposition()` changed shape, so any future out-of-tree in-process driver must supply the parent. That is the intended cost: the previous signature let a caller compose a capability-less child and get no error. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md new file mode 100644 index 0000000000..09e4de5292 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md @@ -0,0 +1,47 @@ +# Agent Note: Child agents join their parent's preset composition + +Status: implemented + +[English](2026-08-10-child-agents-join-their-parent-preset.md) | 中文 + +## Problem + +工具与提示段的可见性沿 `dsh-scope` 的父链继承,而 agent 的 scope key 铸造出来时没有父。[逐会话 agent preset](../architecture/2026-08-03-per-session-agent-presets.md) 把所有面向模型的行搬到了 agent 平面,并让 `AgentPresets.mount()` 成为绑定那条父链的唯一途径——调用点在 api-proxy 的会话创建、恢复与 fork 路径上。两个进程内 subagent 驱动通过 `applyChildComposition()` 组装子 agent,而它只安装了逐子 agent 的 persona 与工具限制,于是子 agent 的 scope 链长度为一,其注册表视图只能解析到全局层。 + +在任何配置了 preset roster 的部署里,那一层现在是空的:web-app 补丁层禁用了全部宿主平面工具行。因此一次性子 agent 抵达模型时工具为零,可继续子 agent 只剩宿主平面的 `report`,两者都不带父方的 persona、工作区上下文、plan-mode 段与技能目录。fork 路径此前已因同一理由做过相同处理;委派没有。 + +子 agent 的持久化 header 让问题更进一步。`childSessionMeta()` 不记录任何 preset,于是冷读一个子会话解析到的是部署默认值——一套该子 agent 从未运行过的工具集,而这正是"模型可见 ⟺ 已记录"规则要杜绝的情形。 + +## Decision + +`AgentPresets.composeFrom(agentCtx, parentCtx)` 让一个 agent 加入另一个 agent 已在运行的常驻组装,并返回所加入的 preset id。它通过 `standingMountFor()` 定位父方的挂载——agent 的 key 认父到其 preset 的常驻 key,正是 `serviceForAgent()` 读取的同一关系——再把子 agent 的 key 绑到同一个常驻 key 上,绑定句柄仍归 roster 独有的重链权威持有。未加入任何 preset 的父方不产生加入、也不报错,那就是无 roster 的部署:它面向模型的行位于宿主组装中,子 agent 已经能通过全局层解析到它们。 + +这是认父而非挂载,两处差别都要紧。子 agent 拿到的是父方那个确切的代际,因此父方启动后被编辑过的组装文件不可能把与父方历史所产出时不同的另一个代际交给它,此后被删除的 preset 也不可能让一个父方仍在运行的子 agent 失败。它还是同步的,这正是子 agent 创建窗口能够使用它的前提——两个进程内驱动都在同步的 `setup` 中完成组装。 + +`applyChildComposition(childCtx, parent, composition)` 接收父方,并在应用子 agent 自身注册之前完成加入。这个参数正是要点所在:它让"组装子 agent 却不做该加入"在各调用点无法表达,而不是把第二个步骤留给每个新驱动去记住。`childSessionMeta()` 通过 `AgentPresets.composedPreset()` 记录所加入的 id,该值从父方**活着的** scope 链读取而不是从其 header 读取,因为在空白期切换过 preset 的父方运行在更新的那份组装上,而它的 header 仍写着旧的那个。 + +`dsh-subagent` 以类型级导入加可选 peer 依赖的方式,通过 `ctx.get('agentPresets')` 触达 roster——这正是它对 `sandboxPolicy` 与 `approval` 已在使用的、有明确文档的机会性消费模式。 + +## Alternatives considered + +**在子 agent 的 setup 里按 id 重新挂载父方的 preset。** 语义与机制两方面都不成立而被否决。它会重读 roster 并重新 stat 组装文件,因此父方启动后的一次编辑就会把子 agent 分叉到另一个代际,而此后被删除的 preset 会让子 agent 失败、父方却照常运行。`mount()` 还是异步的,同步的创建窗口无法在不重构两个驱动的前提下接受它。 + +**把子 agent 的 key 绑到**父方的** key 而不是常驻挂载上。** 否决,因为这改变了子 agent 继承的内容:父方自己的 scope 层携带其逐 agent 限制,那些限制会就此与每个后代求交,而活得比父方久的子 agent 会挂在一个已 dispose 的 agent key 上。加入常驻挂载给到子 agent 的是父方的组装,仅此而已。 + +**扩展可继续 activation setup 注册表以覆盖一次性子 agent。** 否决,因为该注册表的贡献类型是同步的 `(childCtx) => () => void` 并带有逐次安装的撤销,建模的是会来会走的部署能力,而 preset 加入是一次性认父、自身没有撤销可言。扩展它反而会让任何绕过该注册表的驱动重新具备遗漏的可能。 + +**让 `dsh-subagent` 导入 `resolveSessionPreset` 并按解析出的 id 挂载。** 否决,因为这会给一个必须在没有 roster 时也能工作的包引入硬模块边,而且最终仍落回上述的重新挂载语义。 + +**只修活着的加入,不动持久化 header。** 否决,因为那样活着的子 agent 与冷读同一个子 agent 会对"哪份组装产出了这段历史"给出不同答案——同一类缺陷,只是被搬了个地方而不是被修掉。 + +## Testing + +`packages/preset/agent-presets/tests/mount.spec.ts` 用真实 fixture 组装覆盖该加入:子 agent 看到父方的工具与提示段、不会挂载出第二个代际、加入在父方 dispose 后依然成立(活得比父方久的后台子 agent)、上报的 id 一致、没有 preset 的父方不产生加入、以及无 scope 的上下文被拒绝。 + +`packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts` 在一个不含任何面向模型行的宿主组装上,通过 `startInProcessRun()` 断言模型可见的结果:子 agent 自身请求中的 schema、父方的提示段、记录下来的 header preset,以及在空白期切换过 preset 的父方。 + +## Consequences + +委派现在的成本是每个子 agent 一次 scope 认父,再无其他——没有额外的插件实例、没有 roster 读取、没有新的失败模式。子 agent 的能力恰好等于父方的能力,减去它自己的 `toolFilter` 所移除的部分;逐 subagent 的 preset("agent 类型")仍未构建,那会是一个新的请求字段,而不是对这次加入的改动。 + +`applyChildComposition()` 的形态变了,因此将来任何仓库外的进程内驱动都必须提供父方。这是刻意付出的代价:此前的签名允许调用方组装出一个毫无能力的子 agent 而不报任何错。 diff --git a/apps/cli/package.json b/apps/cli/package.json index d312dd28d9..03bd4c3db9 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -77,6 +77,7 @@ "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@types/js-yaml": "^4.0.9", diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 1bfaed8c67..004de203c8 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -11,6 +11,7 @@ import type { PatchOptions } from '@cordisjs/plugin-include' import { beforeAll, describe, expect, it } from 'vitest' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { resolveSessionPreset, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets' +import { applyChildComposition, childSessionMeta } from '@deepseek-ai/dsh-subagent' import { CallId } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-skill' import type {} from '@deepseek-ai/dsh-tools' @@ -422,6 +423,59 @@ describe('a forked session', () => { }) }) +describe('a delegated child', () => { + it('runs on the composition its parent runs on', async () => { + const parent = await ctx.agents.create({ + sessionId: SessionId('preset-child-parent'), + meta: { agentPreset: 'standard' }, + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + // Exactly what an in-process subagent driver's creation window does. + const child = await parent.agent.ctx.agents.create({ + sessionId: SessionId('preset-child'), + meta: childSessionMeta(parent.agent, 1, 0), + setup: (agentCtx) => { + applyChildComposition(agentCtx, parent.agent, {}) + }, + }) + try { + expect(toolNames(ctx, child.agent)).toEqual(toolNames(ctx, parent.agent)) + // The shipped `standard` preset is the whole coding agent; an empty + // child here is the defect, and equality alone would not catch it. + expect(toolNames(ctx, child.agent)).toContain('bash') + expect(child.agent.session.header.agentPreset).toBe('standard') + } finally { + await child.dispose() + await parent.dispose() + } + }) + + it('follows a parent that switched preset while blank', async () => { + const parent = await ctx.agents.create({ + sessionId: SessionId('preset-child-switch-parent'), + meta: { agentPreset: 'standard' }, + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + await ctx.agentPresets.recompose(parent.agent.ctx, 'minimal') + const child = await parent.agent.ctx.agents.create({ + sessionId: SessionId('preset-child-switch'), + meta: childSessionMeta(parent.agent, 1, 0), + setup: (agentCtx) => { + applyChildComposition(agentCtx, parent.agent, {}) + }, + }) + try { + // The live scope chain is the authority, not the parent's creation + // header — which still names `standard`. + expect(toolNames(ctx, child.agent)).toEqual(toolNames(ctx, parent.agent)) + expect(child.agent.session.header.agentPreset).toBe('minimal') + } finally { + await child.dispose() + await parent.dispose() + } + }) +}) + describe('authoring a preset on the shipped composition', () => { let authorCtx: Context let userRoot: string diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 66486e97b7..b24ae41404 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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 docs/module-graph.md -module-graph.md: a2407f5d394020834172288e3d03518d1e8045db -module-graph.zh.md: 364033c29d773a764ce3f8f0036edeac7c9e0b21 +module-graph.md: 398b49ff2aa795c377abe19bf7a8649078aa0585 +module-graph.zh.md: 5cfcc612b27d64e098a51fda326190fdae1d7e7e diff --git a/docs/module-graph.md b/docs/module-graph.md index a2407f5d39..398b49ff2a 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -822,6 +822,7 @@ flowchart TD pkg_command_compact --> pkg_compact pkg_command_compact --> pkg_invariants pkg_subagent --> pkg_agent + pkg_subagent --> pkg_agent_presets pkg_subagent --> pkg_brand pkg_subagent --> pkg_invariants pkg_subagent --> pkg_llm @@ -1386,7 +1387,7 @@ flowchart TD | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`command-compact`](../packages/compact/command-compact) | `compact` | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 364033c29d..5cfcc612b2 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -824,6 +824,7 @@ flowchart TD pkg_command_compact --> pkg_compact pkg_command_compact --> pkg_invariants pkg_subagent --> pkg_agent + pkg_subagent --> pkg_agent_presets pkg_subagent --> pkg_brand pkg_subagent --> pkg_invariants pkg_subagent --> pkg_llm @@ -1388,7 +1389,7 @@ flowchart TD | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`command-compact`](../packages/compact/command-compact) | `compact` | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 0b2b87a954..7b5ba07cd7 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.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 docs/subsystems/core.md -core.md: af27484160769156836f377e5b3aba2521280005 -core.zh.md: 12935f4d881f371cfe2c3c5bed85ef88f57ec71a +core.md: b66d07194cbdb44037d4ea3a972f4646b7854c52 +core.zh.md: ce95df4d88160e0ffc9c0031a7aad47e72a4b4f1 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index af27484160..b66d07194c 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -419,6 +419,43 @@ async resolve(id?: string): Promise */ async mount(agentCtx: Context, id?: string): Promise +/** + * Join one agent to the SAME standing composition another already runs on. + * + * This is how a child agent inherits its parent's capabilities. It is a bind, + * not a mount: the parent's generation is already composed, so the child gets + * that exact instance — the same plugin objects, the same tool registrations, + * the same prompt sections. Re-resolving the parent's preset by id instead + * would re-read the roster, and a composition file edited since the parent + * started would hand the child a DIFFERENT generation than the one its + * parent's history was produced under (and a preset deleted since would fail + * the child outright while its parent keeps running). + * + * Synchronous and infallible for that reason, which is what lets a child + * creation window use it: the two in-process subagent drivers compose their + * children inside a synchronous `setup`. + * + * A parent that joined no preset — a rosterless deployment — yields no join + * and no error: there, the model-facing rows sit in the host composition and + * the child already sees them through the global layer. + * @param agentCtx - the joining agent's scope context. + * @param parentCtx - the scope context of the agent whose composition to join. + * @returns the preset id joined, or undefined when the parent joined none. + * @throws when `agentCtx` carries no scope, or has already joined a preset. + */ +composeFrom(agentCtx: Context, parentCtx: Context): string | undefined + +/** + * The preset one live agent runs on. + * + * Read from the live scope chain rather than from the session, so it answers + * for an agent whose session has not recorded a preset yet — a child agent + * whose durable header is being built from its parent's composition. + * @param agentCtx - the agent's scope context. + * @returns the preset id, or undefined when the agent joined none. + */ +composedPreset(agentCtx: Context): string | undefined + /** * Read one preset's composition text. * @param id - the preset id. diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 12935f4d88..ce95df4d88 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -427,6 +427,43 @@ async resolve(id?: string): Promise */ async mount(agentCtx: Context, id?: string): Promise +/** + * Join one agent to the SAME standing composition another already runs on. + * + * This is how a child agent inherits its parent's capabilities. It is a bind, + * not a mount: the parent's generation is already composed, so the child gets + * that exact instance — the same plugin objects, the same tool registrations, + * the same prompt sections. Re-resolving the parent's preset by id instead + * would re-read the roster, and a composition file edited since the parent + * started would hand the child a DIFFERENT generation than the one its + * parent's history was produced under (and a preset deleted since would fail + * the child outright while its parent keeps running). + * + * Synchronous and infallible for that reason, which is what lets a child + * creation window use it: the two in-process subagent drivers compose their + * children inside a synchronous `setup`. + * + * A parent that joined no preset — a rosterless deployment — yields no join + * and no error: there, the model-facing rows sit in the host composition and + * the child already sees them through the global layer. + * @param agentCtx - the joining agent's scope context. + * @param parentCtx - the scope context of the agent whose composition to join. + * @returns the preset id joined, or undefined when the parent joined none. + * @throws when `agentCtx` carries no scope, or has already joined a preset. + */ +composeFrom(agentCtx: Context, parentCtx: Context): string | undefined + +/** + * The preset one live agent runs on. + * + * Read from the live scope chain rather than from the session, so it answers + * for an agent whose session has not recorded a preset yet — a child agent + * whose durable header is being built from its parent's composition. + * @param agentCtx - the agent's scope context. + * @returns the preset id, or undefined when the agent joined none. + */ +composedPreset(agentCtx: Context): string | undefined + /** * Read one preset's composition text. * @param id - the preset id. diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 5576f2e75c..a1f98d4f28 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -33,6 +33,7 @@ import { PresetNotWritableError, resolveSessionPreset, SETTINGS_NAMESPACE as AGENT_PRESET_SETTINGS_NAMESPACE, UnknownPresetError, } from '@deepseek-ai/dsh-agent-presets' +import type { PresetBearingSession } from '@deepseek-ai/dsh-agent-presets' import type {} from '@deepseek-ai/dsh-tools' import type { ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame, @@ -1350,17 +1351,26 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro * The registry view scope a transcript's presenters resolve in. * * A live agent is that scope itself (its chain passes through its preset's - * standing layer). A cold session names its preset on the header, and the + * standing layer). A cold session resolves its preset from the LOG, and the * preset's STANDING key serves without resuming anything — ensuring the * mount composes plugins but starts no agent, session, or turn. No roster, * no recorded preset, or a preset the roster no longer supplies all fall * back to the global layer: the transcript still serves, with the generic * cards a viewless entry renders. + * + * Reading the header alone would render a session that switched while blank + * through the composition it was CREATED with. Every tool only the newer + * preset registers resolves to no presenter there, and the transcript + * silently degrades to generic cards for exactly the calls its history is + * made of. * @param sessionId - the transcript being read. - * @param header - that session's header (attached or inspected). + * @param session - that session's header and log (attached or inspected). * @returns the scope to pass to presenter lookups, or undefined for global. */ - async function presenterScopeFor(sessionId: SessionId, header: SessionHeader): Promise { + async function presenterScopeFor( + sessionId: SessionId, + session: PresetBearingSession, + ): Promise { const live = ctx.get('agents')?.get(sessionId) if (live !== undefined) return live const presets = ctx.get('agentPresets') @@ -1370,7 +1380,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // through the DEFAULT preset's standing layer: that is the composition // an unnamed session composes today, and presenters are pure display, // so the worst a mismatch produces is the generic card it had anyway. - return await presets.standingKeyFor(header.agentPreset) + return await presets.standingKeyFor(resolveSessionPreset(session)) } catch { // Swallows only the unknown/unusable-preset rejection from the roster: // a deleted or broken preset must degrade this read, never fail it. @@ -1463,7 +1473,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // Beside the cwd check for the same reason, and after the await so it // covers every path that yields a live agent — freshly created, adopted // live, resumed from disk, or recovered by the concurrent-creation catch. - assertPresetUnchanged(sessionId, presetId, agent.session.header.agentPreset) + assertPresetUnchanged(sessionId, presetId, resolveSessionPreset(agent.session)) if (agent.session.header.cwd !== cwd) { throw new SessionCwdConflict(sessionId, cwd, agent.session.header.cwd) } @@ -2003,7 +2013,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro details: {}, }) } - const page = historyPage(ctx, state.events, beforeSeq, maxMessages, await presenterScopeFor(sessionId, state.header)) + const page = historyPage(ctx, state.events, beforeSeq, maxMessages, await presenterScopeFor(sessionId, state)) return ok(request, { events: page.events, hasMore: page.hasMore, @@ -2982,7 +2992,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // The scope presenters resolve in — the live agent, else the recorded // preset's standing key, else the global layer — so a cold session's // '/' popup lists the catalog its composition actually serves. - const scope = await presenterScopeFor(sessionId, session.header) + const scope = await presenterScopeFor(sessionId, session) try { const skills = (await skillRegistry.list({ cwd, scope })).filter(isUserInvocable) return ok(request, { diff --git a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts index eb707c01f6..996af59986 100644 --- a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts @@ -186,6 +186,24 @@ describe('session.create with an agent preset', () => { }) }) + it('adopts a live session under the preset it SWITCHED to', async () => { + const { api, ctx } = await harness(['standard', 'minimal']) + await api.sessions.create(request({ sessionId: SessionId('s4b'), agentPreset: 'standard' })) + // Exactly what `agentPreset.select` leaves behind on a blank session: the + // header keeps the creation fact, the log states what the agent runs. + ctx.sessions.get(SessionId('s4b'))?.append('agent-preset/selected', { agentPreset: 'minimal' }) + + const adopted = await api.sessions.create(request({ sessionId: SessionId('s4b'), agentPreset: 'minimal' })) + const stale = await api.sessions.create(request({ sessionId: SessionId('s4b'), agentPreset: 'standard' })) + + // Comparing against the header would invert both answers: the preset the + // session actually runs would be refused, and the one it left would pass. + expect(adopted.result.ok).toBe(true) + expect(stale.result.ok).toBe(false) + if (stale.result.ok) throw new Error('unreachable') + expect(stale.result.error.details).toMatchObject({ existingPreset: 'minimal' }) + }) + it('adopts a live session unchanged when the caller names no preset', async () => { const { api } = await harness(['standard', 'minimal']) await api.sessions.create(request({ sessionId: SessionId('s5'), agentPreset: 'minimal' })) @@ -660,6 +678,27 @@ describe('session.history presenter scope', () => { expect(standingKeyRequests).toEqual([]) }) + it('resolves a switched session from the LOG, not its creation header', async () => { + // The header is a creation fact; a switch while blank is a logged event, + // and every turn after it ran under the newer composition. Reading the + // header would render that history through the older preset's layer, + // where the tools it is made of have no presenter at all. + const meta = { id: SessionId('p4'), createdAt: 1, cwd: '/tmp/p4', agentPreset: 'standard' } + const { api } = await harness(['standard', 'minimal'], { + list: () => Promise.resolve([meta]), + inspect: () => Promise.resolve({ + meta, + events: [{ type: 'agent-preset/selected', seq: 1, time: 0, data: { agentPreset: 'minimal' } }], + }), + }) + + standingKeyRequests.length = 0 + const response = await api.sessions.history(request({ sessionId: SessionId('p4') })) + + expect(response.result.ok).toBe(true) + expect(standingKeyRequests).toEqual(['minimal']) + }) + it('serves a COLD transcript whose standing mount is no longer usable', async () => { // A genuinely cold session: persistence knows it, no live agent exists. const meta = { id: SessionId('p3'), createdAt: 1, cwd: '/tmp/p3', agentPreset: 'standard' } diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index 65af853308..9c7f2c54ad 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/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/preset/agent-presets/README.md -README.md: b6d469b26a0254adc654e5cc49d3df2d10817b2d -README.zh.md: 60c7bc695c27bf2c0169a0e405aa84aa711b9b21 +README.md: 5ccf1d7b224d0e3a67b3aeb9dc6679d6e802063f +README.zh.md: ed79cf48b96ed927feec8860b6211cedc369cdda diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md index b6d469b26a..5ccf1d7b22 100644 --- a/packages/preset/agent-presets/README.md +++ b/packages/preset/agent-presets/README.md @@ -14,6 +14,8 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal - `ctx.agentPresets.list(): Promise` Every preset the configured roots currently supply, earlier root winning a duplicate id; broken presets included, each carrying its reason. - `ctx.agentPresets.resolve(id?): Promise` One preset by id, defaulting to `defaultId`. Throws naming the available ids when no root supplies it. A broken preset resolves — deleting, reading, and reporting one all need the row. - `ctx.agentPresets.mount(agentCtx, id?): Promise` Compose one agent from a preset — ensure its standing mount (single-flight) and parent the agent's scope key to it — returning the preset for the caller to record. Refuses a broken preset up front with its discovery-reported reason, so every unloadable shape fails the same way before the loader is involved. +- `ctx.agentPresets.composeFrom(agentCtx, parentCtx): string | undefined` Join one agent to the standing composition another already runs on, returning the preset id joined — `undefined` when the parent joined none, which is the rosterless deployment and not an error. A bind rather than a mount, so it is synchronous and cannot fail. +- `ctx.agentPresets.composedPreset(agentCtx): string | undefined` The preset one LIVE agent runs on, read from its scope chain rather than from its session — the only answer available for an agent whose durable header is still being built. - `ctx.agentPresets.recompose(agentCtx, id): Promise` Re-link one agent to a different preset's standing composition. Valid only while the agent has produced nothing — **the caller owns that check**; the new mount is ensured before the link moves, so a failure leaves the agent as it was. Refuses a broken preset like `mount()`. - `ctx.agentPresets.standingKeyFor(id?): Promise` The standing scope key a host reader with no agent (a cold transcript read) resolves preset registrations in; ensures the mount without starting an agent, session, or turn. Refuses a broken preset like `mount()`. - `ctx.agentPresets.authorable: boolean` Whether any configured root has `user` trust, and therefore whether a preset can be created at all. @@ -27,6 +29,14 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal The agent factory's `setup(agentCtx)` hook is the one supported call site. Only there is the join installed while the agent is still unpublished, so a rejected composition rolls the whole creation back rather than leaving a half-composed session. The standing subtree is owned by the roster service's own fiber — deliberately its UNTRACED context, because a subtree minted from a traced `this.ctx` resolves every service through the caller's shadow fiber instead of each entry's own inject store — so it survives every agent and unwinds only with the whole tree. Each generation records its composition file's stamp (mtime and size): a session that finds the stamp stale starts the next generation, while every session already joined keeps the one it runs on — the composition a running session joined outlives its file changing or disappearing underneath it, and files are the only composition editor, so the stamp is what carries an edit to later sessions. +### Composing a child agent + +A subagent's child joins its parent's standing composition through `composeFrom()`, never through `mount()`. Every model-facing row lives on the agent plane, so the tool registry's global layer is empty and a child that joins nothing reaches the model with no tools at all and none of its parent's prompt sections. + +Re-mounting the parent's preset by id would differ from the bind in two ways that both matter. A composition file edited since the parent started would hand the child a DIFFERENT generation than the one its parent's history was produced under, and a preset deleted since would fail the child outright while its parent keeps running. The bind is also synchronous, which is what lets the in-process subagent drivers use it at all — they compose their children inside a synchronous creation window. + +The child records the joined id on its own durable header ([`dsh-subagent`](../../subagent/subagent/README.md)), so a cold read of the child's history rebuilds the composition it actually ran under rather than the deployment default. + ### Which preset a session runs The creation header names the preset a session STARTED with; `resolveSessionPreset(session)` names the one it RUNS. They differ whenever a blank session switched, so every reconstruction path — the summary a picker reads, a resume, a fork — resolves rather than reading the header. diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index 60c7bc695c..ed79cf48b9 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -14,6 +14,8 @@ - `ctx.agentPresets.list(): Promise` 当前各根目录提供的全部 preset;id 重复时靠前的根目录胜出;损坏的 preset 也在其中,各自携带原因。 - `ctx.agentPresets.resolve(id?): Promise` 按 id 取一个 preset,缺省取 `defaultId`。没有任何根目录提供该 id 时抛错,并列出可用 id。损坏的 preset 照样解析——删除、读取与上报都需要这一行。 - `ctx.agentPresets.mount(agentCtx, id?): Promise` 用一个 preset 组装一个 agent——确保其常驻挂载(并发去重)并把 agent 的 scope key 认父到它——返回该 preset 供调用方记录。对损坏的 preset 直接以发现时记下的原因拒绝,所以每种不可加载的形态都在加载器介入之前以同一方式失败。 +- `ctx.agentPresets.composeFrom(agentCtx, parentCtx): string | undefined` 让一个 agent 加入另一个 agent 已在运行的常驻组装,返回所加入的 preset id——父方未加入任何 preset 时返回 `undefined`,那是无 roster 的部署,不是错误。这是认父而非挂载,因此同步且不会失败。 +- `ctx.agentPresets.composedPreset(agentCtx): string | undefined` 某个**活着的** agent 正在运行的 preset,从其 scope 链读取而不是从其会话读取——对于持久化 header 尚在构建中的 agent,这是唯一能拿到的答案。 - `ctx.agentPresets.recompose(agentCtx, id): Promise` 把一个 agent 重链到另一个 preset 的常驻组装。仅在该 agent 尚无任何产出时合法——**由调用方负责该检查**;新挂载在链移动之前确保完成,失败时 agent 原封不动。与 `mount()` 一样拒绝损坏的 preset。 - `ctx.agentPresets.standingKeyFor(id?): Promise` 没有 agent 的宿主读取方(冷读记录)解析 preset 注册所用的常驻 scope key;确保挂载而不启动任何 agent、会话或轮次。与 `mount()` 一样拒绝损坏的 preset。 - `ctx.agentPresets.authorable: boolean` 是否有任一配置根目录具备 `user` 信任级别,因而 preset 是否可创建。 @@ -27,6 +29,14 @@ agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有在那里,认父是在 agent 尚未发布时完成的,因此组装被拒绝会让整次创建回滚,而不会留下一个组装到一半的会话。常驻子树归 roster 服务自己的 fiber 所有——刻意用其未追踪的上下文,因为从被追踪的 `this.ctx` 派生的子树会经调用方的 shadow fiber 解析一切服务、无视各 entry 自己的 inject store——所以它比任何 agent 都活得久,只随整棵树卸载。每个代际记录其组装文件的 stamp(mtime 与大小):发现 stamp 过期的会话会开启下一个代际,而所有已加入的会话保持各自正在运行的那个——正在运行的会话所加入的组装在其文件被修改或删除后继续存活;文件是唯一的组装编辑器,stamp 正是把编辑送达后续会话的机制。 +### 组装子 agent + +subagent 的子 agent 通过 `composeFrom()` 加入其父方的常驻组装,绝不走 `mount()`。所有面向模型的行都在 agent 平面,工具注册表的全局层是空的,因此没有加入任何组装的子 agent 抵达模型时既没有任何工具,也没有父方的任何提示段。 + +按 id 重新挂载父方的 preset 与认父有两处差别,且两处都要紧。父方启动后被编辑过的组装文件会把与父方历史所产出时**不同**的一个代际交给子 agent;而此后被删除的 preset 会让子 agent 直接失败,尽管其父方仍在正常运行。认父还是同步的,这正是进程内 subagent 驱动能够使用它的前提——它们在同步的创建窗口里组装子 agent。 + +子 agent 会把所加入的 id 记在自己的持久化 header 上(见 [`dsh-subagent`](../../subagent/subagent/README.md)),因此冷读子 agent 的历史时重建的是它实际运行过的组装,而不是部署默认值。 + ### 会话实际运行的是哪个 preset 创建头部记录的是会话**以什么开始**,`resolveSessionPreset(session)` 给出的才是它**实际运行的**。空白会话一旦切换过,两者就不同,因此所有重建路径——选择器读取的摘要、resume、fork——都走解析,而非直接读头部。 diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 58f523c4e3..0fb428c425 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -28,7 +28,7 @@ import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings' import { discoverPresets } from './discovery.ts' import { copyComposition, deleteComposition, readComposition } from './authoring.ts' -import { mountPreset, serviceForAgent } from './mount.ts' +import { mountPreset, serviceForAgent, standingMountFor } from './mount.ts' import { PresetExistsError } from './authoring.ts' import { PresetMountError, UnknownPresetError, type AgentPreset, type Config } from './types.ts' @@ -51,8 +51,8 @@ export { METADATA_FILE, readPresetMetadata, renderPresetMetadata, type PresetMetadata, } from './metadata.ts' export { - inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent, - type PresetMount, + inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent, standingMountFor, + type JoinedPresetMount, type PresetMount, } from './mount.ts' export { copyComposition, deleteComposition, InvalidPresetIdError, PresetExistsError, @@ -238,6 +238,54 @@ export class AgentPresets extends Service { return preset } + /** + * Join one agent to the SAME standing composition another already runs on. + * + * This is how a child agent inherits its parent's capabilities. It is a bind, + * not a mount: the parent's generation is already composed, so the child gets + * that exact instance — the same plugin objects, the same tool registrations, + * the same prompt sections. Re-resolving the parent's preset by id instead + * would re-read the roster, and a composition file edited since the parent + * started would hand the child a DIFFERENT generation than the one its + * parent's history was produced under (and a preset deleted since would fail + * the child outright while its parent keeps running). + * + * Synchronous and infallible for that reason, which is what lets a child + * creation window use it: the two in-process subagent drivers compose their + * children inside a synchronous `setup`. + * + * A parent that joined no preset — a rosterless deployment — yields no join + * and no error: there, the model-facing rows sit in the host composition and + * the child already sees them through the global layer. + * @param agentCtx - the joining agent's scope context. + * @param parentCtx - the scope context of the agent whose composition to join. + * @returns the preset id joined, or undefined when the parent joined none. + * @throws when `agentCtx` carries no scope, or has already joined a preset. + */ + composeFrom(agentCtx: Context, parentCtx: Context): string | undefined { + const agentKey = scopeOf(agentCtx) + if (agentKey === undefined) { + throw new Error('agent-presets: refusing to compose an unscoped context; the scope key is what joins an agent to its preset') + } + const standing = standingMountFor(parentCtx) + if (standing === undefined) return undefined + this.bindings.set(agentKey, bindScopeParent(agentKey, standing.key)) + return standing.presetId + } + + /** + * The preset one live agent runs on. + * + * Read from the live scope chain rather than from the session, so it answers + * for an agent whose session has not recorded a preset yet — a child agent + * whose durable header is being built from its parent's composition. + * @param agentCtx - the agent's scope context. + * @returns the preset id, or undefined when the agent joined none. + */ + composedPreset(agentCtx: Context): string | undefined { + return standingMountFor(agentCtx)?.presetId + } + /** Whether this deployment configures a root locally authored presets go to. */ get authorable(): boolean { return this.config.roots.some(root => root.trust === 'user') diff --git a/packages/preset/agent-presets/src/mount.ts b/packages/preset/agent-presets/src/mount.ts index eb890255ca..e968a97c25 100644 --- a/packages/preset/agent-presets/src/mount.ts +++ b/packages/preset/agent-presets/src/mount.ts @@ -202,6 +202,33 @@ export function leakedServices(ctx: Context, mount: Fiber): string[] { return leaked.sort((left, right) => left.localeCompare(right)) } +/** A live standing mount located through one agent already joined to it. */ +export type JoinedPresetMount = PresetMount & { + /** The standing key, definite because it is what the lookup matched on. */ + readonly key: ScopeKey +} + +/** + * The standing composition one agent is joined to. + * + * The agent's own key is parented to its preset's standing key, so the mount + * is found by matching that parent rather than by walking up from the agent — + * the mount is not under the agent's fiber. An agent that joined no preset — + * a deployment composing no roster, or a child agent before its join — has no + * parent link and resolves to undefined. + * @param agentCtx - the agent's scope context. + * @returns the mount the agent joined, or undefined when it joined none. + */ +export function standingMountFor(agentCtx: Context): JoinedPresetMount | undefined { + const agentKey = scopeOf(agentCtx) + if (agentKey === undefined) return undefined + const standingKey = scopeParentOf(agentKey) + if (standingKey === undefined) return undefined + return livePresetMounts().find( + (candidate): candidate is JoinedPresetMount => candidate.key === standingKey, + ) +} + /** * One agent's instance of a service its preset mounted. * @@ -231,14 +258,7 @@ export function serviceForAgent( agent: { ctx: Context }, name: K, ): Context[K] | undefined { - // The agent's own key is parented to its preset's standing key; the mount - // is no longer under the agent's fiber, so the search roots at the standing - // mount instead of walking up from the agent. - const agentKey = scopeOf(agent.ctx) - if (agentKey === undefined) return undefined - const standingKey = scopeParentOf(agentKey) - if (standingKey === undefined) return undefined - const mount = livePresetMounts().find(candidate => candidate.key === standingKey) + const mount = standingMountFor(agent.ctx) if (mount === undefined) return undefined const store = ctx.reflect.store for (const key of Object.getOwnPropertySymbols(store)) { diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index 77c549a689..afe297a406 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -153,6 +153,79 @@ describe('composing an agent from a preset', () => { }) }) +describe('composing a child agent from its parent', () => { + /** Create one agent joined to `parent`'s composition, as a child creation window does. */ + async function childOf(ctx: Context, id: string, parent: Agent): Promise { + const handle = await ctx.agents.create({ + sessionId: SessionId(id), + setup: (childCtx: Context) => void ctx.agentPresets.composeFrom(childCtx, parent.ctx), + }) + return handle.agent + } + + it('gives the child its parent\'s tools and prompt sections', async () => { + const parent = await agentOn(ctx, 'sess-parent', 'standard') + + const child = await childOf(ctx, 'sess-child', parent) + + expect(toolNames(ctx, child)).toEqual(['alpha']) + const prompt = await ctx.systemPrompt.assemble(assembleContextFor(child)) + expect(prompt.sections.map(section => section.name)).toContain('preset:alpha') + }) + + it('joins the parent\'s own generation rather than remounting its preset', async () => { + const parent = await agentOn(ctx, 'sess-shared', 'standard') + const before = livePresetMounts().length + + await childOf(ctx, 'sess-shared-child', parent) + + // A remount would compose a second copy of every row in the preset; the + // child must run on the plugin instances its parent already runs on. + expect(livePresetMounts()).toHaveLength(before) + }) + + it('keeps the child composed after its parent is disposed', async () => { + const parentHandle = await ctx.agents.create({ + sessionId: SessionId('sess-dying-parent'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), + }) + const child = await childOf(ctx, 'sess-orphan', parentHandle.agent) + + await parentHandle.dispose() + + // Standing mounts outlive the agents that joined them, so a child outliving + // its parent — a background subagent — keeps the composition it started on. + expect(toolNames(ctx, child)).toEqual(['alpha']) + }) + + it('reports the preset id the child joined, for the durable header', async () => { + const parent = await agentOn(ctx, 'sess-named', 'minimal') + + const child = await childOf(ctx, 'sess-named-child', parent) + + expect(ctx.agentPresets.composedPreset(parent.ctx)).toBe('minimal') + expect(ctx.agentPresets.composedPreset(child.ctx)).toBe('minimal') + }) + + it('composes nothing when the parent joined no preset', async () => { + // The rosterless deployment: model-facing rows sit in the host composition + // and the child already resolves them through the registry's global layer. + const bare = (await ctx.agents.create({ sessionId: SessionId('sess-bare-parent') })).agent + + const child = await childOf(ctx, 'sess-bare-child', bare) + + expect(ctx.agentPresets.composedPreset(bare.ctx)).toBeUndefined() + expect(ctx.agentPresets.composeFrom(child.ctx, bare.ctx)).toBeUndefined() + expect(toolNames(ctx, child)).toEqual([]) + }) + + it('refuses to compose an unscoped context', async () => { + const parent = await agentOn(ctx, 'sess-unscoped-parent', 'standard') + + expect(() => ctx.agentPresets.composeFrom(ctx, parent.ctx)).toThrow(/unscoped context/) + }) +}) + describe('rejecting a composition that cannot be used', () => { it('refuses to mount into a context that carries no agent scope', async () => { await expect(ctx.agentPresets.mount(ctx, 'standard')) diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index fdb852533d..907c3bd266 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -110,6 +110,14 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'async mount(agentCtx: Context, id?: string): Promise', jsDoc: '/**\n * Compose one agent from a preset: ensure the preset\'s standing mount, then\n * parent the agent\'s scope key to it so the mount\'s registrations and\n * listeners cover this agent.\n *\n * Call from the agent factory\'s `setup(agentCtx)`; a rejection there rolls\n * the agent creation back, so a broken preset never yields a half-composed\n * session.\n * @param agentCtx - the agent\'s scope context.\n * @param id - the preset id, or `undefined` for {@link defaultId}.\n * @returns the preset that was composed, for the caller to record.\n * @throws when the preset is unknown or its composition is unusable.\n */', }, + { + signature: 'composeFrom(agentCtx: Context, parentCtx: Context): string | undefined', + jsDoc: '/**\n * Join one agent to the SAME standing composition another already runs on.\n *\n * This is how a child agent inherits its parent\'s capabilities. It is a bind,\n * not a mount: the parent\'s generation is already composed, so the child gets\n * that exact instance — the same plugin objects, the same tool registrations,\n * the same prompt sections. Re-resolving the parent\'s preset by id instead\n * would re-read the roster, and a composition file edited since the parent\n * started would hand the child a DIFFERENT generation than the one its\n * parent\'s history was produced under (and a preset deleted since would fail\n * the child outright while its parent keeps running).\n *\n * Synchronous and infallible for that reason, which is what lets a child\n * creation window use it: the two in-process subagent drivers compose their\n * children inside a synchronous `setup`.\n *\n * A parent that joined no preset — a rosterless deployment — yields no join\n * and no error: there, the model-facing rows sit in the host composition and\n * the child already sees them through the global layer.\n * @param agentCtx - the joining agent\'s scope context.\n * @param parentCtx - the scope context of the agent whose composition to join.\n * @returns the preset id joined, or undefined when the parent joined none.\n * @throws when `agentCtx` carries no scope, or has already joined a preset.\n */', + }, + { + signature: 'composedPreset(agentCtx: Context): string | undefined', + jsDoc: '/**\n * The preset one live agent runs on.\n *\n * Read from the live scope chain rather than from the session, so it answers\n * for an agent whose session has not recorded a preset yet — a child agent\n * whose durable header is being built from its parent\'s composition.\n * @param agentCtx - the agent\'s scope context.\n * @returns the preset id, or undefined when the agent joined none.\n */', + }, { signature: 'async read(id: string): Promise', jsDoc: '/**\n * Read one preset\'s composition text.\n * @param id - the preset id.\n * @returns the composition exactly as stored.\n * @throws when no configured root supplies that id.\n */', diff --git a/packages/subagent/subagent-inprocess/package.json b/packages/subagent/subagent-inprocess/package.json index e18752db74..36646e7f7a 100644 --- a/packages/subagent/subagent-inprocess/package.json +++ b/packages/subagent/subagent-inprocess/package.json @@ -45,9 +45,12 @@ } }, "devDependencies": { + "@cordisjs/plugin-include": "^1.0.4", + "@cordisjs/plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index acb4e4d36e..ee7779447a 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -125,7 +125,7 @@ export async function startInProcessRun( if (inheritedPolicy !== undefined) { childSession.append('approval/policy', { policy: inheritedPolicy, source: 'delegation' }) } - applyChildComposition(childCtx, { + applyChildComposition(childCtx, parent, { persona: request.persona, toolFilter: request.toolFilter, }) diff --git a/packages/subagent/subagent-inprocess/tests/fixtures/plugins/preset-tool.js b/packages/subagent/subagent-inprocess/tests/fixtures/plugins/preset-tool.js new file mode 100644 index 0000000000..6fb224094d --- /dev/null +++ b/packages/subagent/subagent-inprocess/tests/fixtures/plugins/preset-tool.js @@ -0,0 +1,20 @@ +// A preset row standing in for the agent-plane tool rows a real preset mounts. +// Import-free on purpose — the Loader resolves entry modules through Node's ESM +// resolver, which cannot see this workspace's TypeScript sources. +export const name = 'preset-tool' +export const inject = ['tools', 'systemPrompt'] + +export function apply(ctx, config) { + ctx.effect(() => ctx.tools.register({ + name: config.tool, + description: `fixture tool ${config.tool}`, + parameters: { type: 'object', properties: {}, additionalProperties: false }, + output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: String(value) }] }, + execute: () => Promise.resolve(config.tool), + })) + ctx.effect(() => ctx.systemPrompt.section({ + name: `preset:${config.tool}`, + order: 10, + text: `section for ${config.tool}`, + })) +} diff --git a/packages/subagent/subagent-inprocess/tests/fixtures/presets/coding/agent.cordis.yml b/packages/subagent/subagent-inprocess/tests/fixtures/presets/coding/agent.cordis.yml new file mode 100644 index 0000000000..a801659220 --- /dev/null +++ b/packages/subagent/subagent-inprocess/tests/fixtures/presets/coding/agent.cordis.yml @@ -0,0 +1,5 @@ +# Agent-plane composition: the model-facing row lives here, not in the host. +- id: only + name: ../../plugins/preset-tool.js + config: + tool: preset_only diff --git a/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts b/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts new file mode 100644 index 0000000000..01c190a833 --- /dev/null +++ b/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts @@ -0,0 +1,116 @@ +/** + * Composition inheritance: a child runs on the preset its parent runs on. + * + * With every model-facing row on the agent plane, the tool registry's global + * layer is empty, so a child that joins no preset reaches the model with no + * tools at all. These assert the model-visible result — the schemas in the + * child's own request — rather than the join that produces it. + */ + +import { afterEach, describe, expect, it } from 'vitest' +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import AgentPresets from '@deepseek-ai/dsh-agent-presets' +import { SessionId } from '@deepseek-ai/dsh-session' +import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import { startInProcessRun } from '../src/index.ts' + +const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') +const ROOTS = [{ path: join(FIXTURES, 'presets'), trust: 'system' as const }] + +const contexts: Context[] = [] + +afterEach(async () => { + for (const ctx of contexts.splice(0).reverse()) await ctx.fiber.dispose() +}) + +/** A host composition carrying no model-facing rows, plus the preset roster. */ +async function setupPresetHost(): Promise<{ ctx: Context; adapter: MockAdapter; parent: Agent }> { + const ctx = new Context() + contexts.push(ctx) + ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(AgentPresets, { default: 'coding', roots: ROOTS }) + const adapter = new MockAdapter([textResponse('parent idle'), textResponse('child done')]) + ctx.llm.registerAdapter(['mock'], adapter) + const handle = await ctx.agents.create({ + sessionId: SessionId('parent'), + agentOptions: { provider: 'mock', model: 'mock' }, + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'coding'), + }) + return { ctx, adapter, parent: handle.agent } +} + +/** The one-shot spawn request shape both in-process providers build. */ +function spawnRequest(parent: Agent) { + return { + label: 'child task', + prompt: [{ type: 'text' as const, text: 'child task' }], + parent, + signal: new AbortController().signal, + descriptor: snapshotSubagentDescriptor({ + mode: 'one-shot' as const, + provider: 'spawn', + label: 'child task', + }), + } +} + +describe('a child agent composed in-process', () => { + it('reaches the model with its parent\'s preset tools', async () => { + const { ctx, adapter, parent } = await setupPresetHost() + + const run = await startInProcessRun(spawnRequest(parent), {}) + await run.result + + const childRequest = adapter.requests.at(-1) + expect(childRequest?.tools?.map(tool => tool.name)).toEqual(['preset_only']) + expect(ctx.tools.schemas(run.localAgent).map(schema => schema.name)).toEqual(['preset_only']) + await run.dispose() + }) + + it('carries its parent\'s prompt sections', async () => { + const { parent } = await setupPresetHost() + + const run = await startInProcessRun(spawnRequest(parent), {}) + await run.result + + expect(run.localAgent?.session.events.some(event => + event.type === 'request/header' + && JSON.stringify(event.data).includes('section for preset_only'))).toBe(true) + await run.dispose() + }) + + it('records the composition it ran under on the child header', async () => { + const { parent } = await setupPresetHost() + + const run = await startInProcessRun(spawnRequest(parent), {}) + await run.result + + // Without this the child's own history reads back under the deployment + // default, which is a different tool set than the one it actually used. + expect(run.localAgent?.session.header.agentPreset).toBe('coding') + await run.dispose() + }) + + it('follows a parent that switched preset while blank', async () => { + const { ctx, parent } = await setupPresetHost() + await ctx.agentPresets.recompose(parent.ctx, 'coding') + + const run = await startInProcessRun(spawnRequest(parent), {}) + await run.result + + expect(ctx.tools.schemas(run.localAgent).map(schema => schema.name)).toEqual(['preset_only']) + await run.dispose() + }) +}) diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 1241aa8042..495949dc0c 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/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/subagent/subagent/README.md -README.md: 762030629c09305c48adebc71244655a5faa6585 -README.zh.md: 535cc25895e04e82b6667e6d2769f2dcbfa49cff +README.md: b69428e4af7d1f53adb22be1e59beb79c054713f +README.zh.md: 9f5eb5f1c508135c21bf3923f60f4f872de8e9f6 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 762030629c..b69428e4af 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -40,6 +40,10 @@ Start-time features are advertised in `provider.capabilities` because the servic - `toolFilter` — apply the requested child tool restriction. - `persona` — apply a per-child persona. +Every in-process child is composed by one call, `applyChildComposition(childCtx, parent, composition)`, which joins the parent's agent-preset composition before applying the child's own persona and tool filter. The join is what gives the child its capabilities: with every model-facing row on the agent plane, a child that joined nothing would reach the model with an empty tool registry ([`dsh-agent-presets`](../../preset/agent-presets/README.md)). Taking the parent as a parameter is deliberate — it makes composing a child WITHOUT that join unrepresentable at the call sites, which is the defect the one call exists to prevent. A deployment composing no preset roster joins nothing and needs nothing: its model-facing rows sit in the host composition, where the child already resolves them through the tool registry's global layer. + +`childSessionMeta()` records the joined preset id on the child's durable header for the same reason a top-level session records its own: the preset decides the tool schemas and prompt sections the model saw, so a cold read of the child's history has to rebuild that composition rather than the deployment default. It is read from the parent's live scope chain, not from the parent header, because a parent that switched preset while blank runs on the newer composition while its header still names the older one. + Continuable creation is the optional `SubagentProvider.prepareContinuable?()` method: its presence is the capability check, so the service rejects a configured continuable start on a provider without it, while a provider that has it may still serve ordinary one-shot delegations. The method returns only a detached `ContinuableCreateSpec` (`{ seed? }`) — data, never a capability: it carries no Agent, `AgentHandle`, prompt delivery, result, disposal, or resume operation, because the continuation manager owns identity reservation, composition, Agent creation, prompt delivery, cold resume, ownership, and disposal after preparation. A one-shot `SubagentRun` represents one disposable foreground delegation with one result and no cold-resume operation. ## The durable descriptor diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 535cc25895..9f5eb5f1c5 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -40,6 +40,10 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 - `toolFilter`:应用请求的子 agent 工具限制; - `persona`:应用每个子 agent 独立的 persona。 +每个进程内子 agent 都由一次调用完成组装:`applyChildComposition(childCtx, parent, composition)` 先加入父方的 agent-preset 组装,再应用该子 agent 自己的 persona 与工具限制。加入组装正是子 agent 获得能力的途径:所有面向模型的行都在 agent 平面,没有加入任何组装的子 agent 抵达模型时工具注册表是空的(见 [`dsh-agent-presets`](../../preset/agent-presets/README.md))。把父方作为参数是刻意的——这让"组装一个子 agent 却不做该加入"在各调用点无法表达,而这正是这一次调用所要杜绝的缺陷。未组装 preset roster 的部署不加入任何组装、也不需要加入:它的面向模型的行位于宿主组装中,子 agent 已经能通过工具注册表的全局层解析到它们。 + +`childSessionMeta()` 把所加入的 preset id 记在子 agent 的持久化 header 上,理由与顶层会话记录自己的那一个相同:preset 决定了模型所见的工具 schema 与提示段,因此冷读子 agent 的历史时必须重建那份组装,而不是部署默认值。该值从父方**活着的** scope 链读取,而不是从父方 header 读取,因为在空白期切换过 preset 的父方运行在更新的那份组装上,而它的 header 仍写着旧的那个。 + 可继续创建对应可选的 `SubagentProvider.prepareContinuable?()` 方法:方法是否存在就是能力检查,因此服务会在没有该方法的提供方上拒绝已配置的可继续启动,而具备该方法的提供方仍可服务普通一次性委派。该方法只返回分离的 `ContinuableCreateSpec`(`{ seed? }`)——这是数据,绝非能力:它不携带任何 Agent、`AgentHandle`、提示词投递、结果、dispose 或恢复操作,因为准备之后,继续执行管理器拥有身份预留、组合、Agent 创建、提示词投递、冷恢复、所有权和 dispose。一次性 `SubagentRun` 表示一次可 dispose 的前台委派,只有一个结果,且没有冷恢复操作。 ## 持久化描述符 diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index ba1dd0fbc4..35504c9dc4 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -34,6 +34,7 @@ }, "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-agent-presets": "^0.0.1", "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", @@ -47,6 +48,9 @@ "cordis": "^4.0.0-rc.7" }, "peerDependenciesMeta": { + "@deepseek-ai/dsh-agent-presets": { + "optional": true + }, "@deepseek-ai/dsh-session-persistence": { "optional": true }, @@ -62,6 +66,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/subagent/subagent/src/child-agent.ts b/packages/subagent/subagent/src/child-agent.ts index c477954468..c501a19a56 100644 --- a/packages/subagent/subagent/src/child-agent.ts +++ b/packages/subagent/subagent/src/child-agent.ts @@ -12,6 +12,12 @@ import type { Context } from 'cordis' import type { Agent, AgentOptions, CreateAgentOptions } from '@deepseek-ai/dsh-agent' import type { SessionId } from '@deepseek-ai/dsh-session' import type { ToolRestriction } from '@deepseek-ai/dsh-tools' +// Type-only: make `ctx.get('agentPresets')` resolve to the preset roster when +// composed — a child inherits its parent's composition opportunistically (the +// documented `ctx.get` pattern), never as a hard dep. A rosterless deployment +// keeps its model-facing rows on the host plane, where the child already sees +// them through the tool registry's global layer. +import type {} from '@deepseek-ai/dsh-agent-presets' import { delegationDepthOf } from './depth.ts' /** Thrown when starting a child would exceed the requested depth cap. */ @@ -72,8 +78,15 @@ export function resolveChildAgentOptions( /** * Build the child session's durable creation metadata: the parent's workspace, * its direct lineage, coarse product origin, the recursion budget that must - * survive persistence, and the seed boundary that separates inherited parent - * history from child work. + * survive persistence, the seed boundary that separates inherited parent + * history from child work, and the composition the child runs under. + * + * The preset is read from the parent's LIVE scope chain rather than from its + * header, because a parent that switched preset while blank runs on the newer + * composition and its header still names the older one. Recording it is what + * makes a child's history reconstructable: without it a cold read of the child + * resolves the deployment default and rebuilds turns under a tool set the + * child never had. * @param parent - the delegating parent agent. * @param childDepth - the resolved delegation depth to persist. * @param lineageSeedLength - how many leading events came from the parent's log. @@ -85,8 +98,10 @@ export function childSessionMeta( lineageSeedLength: number, ): NonNullable { const parentHeader = parent.session.header + const agentPreset = parent.ctx.get('agentPresets')?.composedPreset(parent.ctx) return { ...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {}, + ...agentPreset === undefined ? {} : { agentPreset }, parentSession: parentHeader.id, // Navigation classification only; the descriptor remains the authority // for mode and continuation capability. @@ -106,13 +121,31 @@ export interface ChildComposition { } /** - * Apply one child's scoped composition inside its creation window: a shadowing - * persona section and a tool restriction, both owned by the child's scope and - * therefore invisible to its parent and siblings. + * Compose one child inside its creation window: join its parent's preset, then + * apply the child's own shadowing persona section and tool restriction, both + * owned by the child's scope and therefore invisible to its parent and + * siblings. + * + * The join comes first and the child's own registrations second, which is the + * order the layering already implies — the nearest scope wins a name, and a + * per-child restriction intersects with everything its chain admits — but + * stating it here keeps the two steps from being read as independent. + * + * Both steps live in ONE call because a child composed with only the second is + * exactly the defect this function exists to prevent: with every model-facing + * row on the agent plane, a child that joins no preset sees an empty tool + * registry and none of its parent's prompt sections. Taking the parent as a + * parameter is what makes that omission unrepresentable at the call sites. * @param childCtx - the child agent's scoped creation context. - * @param composition - the persona and tool filter to install. + * @param parent - the delegating parent whose composition the child joins. + * @param composition - the per-child persona and tool filter to install. */ -export function applyChildComposition(childCtx: Context, composition: ChildComposition): void { +export function applyChildComposition( + childCtx: Context, + parent: Agent, + composition: ChildComposition, +): void { + childCtx.get('agentPresets')?.composeFrom(childCtx, parent.ctx) if (composition.persona !== undefined) { childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: composition.persona }) } diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 3425a12851..1cb16daca6 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -885,7 +885,7 @@ export class SubagentContinuationManager { // some other owner holds — a duplicate would reject there with rollback. inputs.signal.throwIfAborted() const setup = (childCtx: Context): AgentSetupCommit => { - applyChildComposition(childCtx, inputs.composition) + applyChildComposition(childCtx, parent, inputs.composition) return this.setupRegistry.apply(childCtx) } const observer = this.host.observeActivation(provider, childId, parent) diff --git a/packages/subagent/subagent/tsconfig.json b/packages/subagent/subagent/tsconfig.json index c72f2ef68d..afc6138bd9 100644 --- a/packages/subagent/subagent/tsconfig.json +++ b/packages/subagent/subagent/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../core/scope" }, + { + "path": "../../preset/agent-presets" + }, { "path": "../../session/session-persistence" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2507f83973..a814f2a7d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -309,6 +309,9 @@ importers: '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../packages/settings/settings + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../../packages/subagent/subagent '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../packages/core/system-prompt @@ -6285,6 +6288,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-presets': + specifier: workspace:^ + version: link:../../preset/agent-presets '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -6539,6 +6545,12 @@ importers: packages/subagent/subagent-inprocess: devDependencies: + '@cordisjs/plugin-include': + specifier: ^1.0.4 + version: link:../../../vendor/include + '@cordisjs/plugin-loader': + specifier: ^1.0.0-rc.5 + version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -6548,6 +6560,9 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../support/agent-loop-testkit + '@deepseek-ai/dsh-agent-presets': + specifier: workspace:^ + version: link:../../preset/agent-presets '@deepseek-ai/dsh-fs-sandbox': specifier: workspace:^ version: link:../../fs/fs-sandbox From 59a2e4d825226acc254dc37545835f2e466d0220 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Mon, 10 Aug 2026 19:07:35 +0800 Subject: [PATCH 13/25] fix(ci): restore the native Windows coverage denominator to green The windows-native job has been red since #1990 put the sandbox-windows-acl sources into the Windows 100%-per-file denominator without tests carrying them, and #1543 dropped the authoring.ts V8 ignore for the POSIX-only owner-execute branch. Non-blocking at merge time, the red state has propagated to every later pull request. Cover every in-process ACL-sandbox failure branch with stub-based failure-path suites (ffi/acl/token/spawn/index), following the package's existing failure-paths pattern; the package now measures 100% per file under the Windows denominator. Exclude only the runner entry from the win32 denominator: it executes exclusively as a spawned child outside the instrumented run, and its behavior is pinned end-to-end by the runner suite. Restore the authoring.ts narrow V8 ignore and add one for the dispose token guard whose absent-token arm is lifecycle-unreachable. Update the dual-lane Agent Note with the denominator composition. --- ...8-native-windows-pull-request-ci.i18n.yaml | 4 +- ...26-08-08-native-windows-pull-request-ci.md | 2 +- ...08-08-native-windows-pull-request-ci.zh.md | 2 +- .../preset/agent-presets/src/authoring.ts | 1 + .../sandbox/sandbox-windows-acl/src/ffi.ts | 2 + .../sandbox/sandbox-windows-acl/src/index.ts | 2 + .../tests/acl-failure-paths.spec.ts | 456 ++++++++++++++++++ .../tests/failure-paths.spec.ts | 318 +++++++++++- .../sandbox-windows-acl/tests/ffi.spec.ts | 190 ++++++++ .../tests/index-failure-paths.spec.ts | 388 +++++++++++++++ .../tests/token-failure-paths.spec.ts | 436 +++++++++++++++++ vitest.config.ts | 10 + 12 files changed, 1806 insertions(+), 5 deletions(-) create mode 100644 packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts create mode 100644 packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts create mode 100644 packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts create mode 100644 packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 07eb13b5cd..d6e9a87840 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.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 .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 6a62fddb79670c3ab4cc0446796dbffd7130aed9 -2026-08-08-native-windows-pull-request-ci.zh.md: 990b8ed1434934337b8ff20c5f3be2c03cd9c61b +2026-08-08-native-windows-pull-request-ci.md: 1c6a1c4dcf50ac6fc5d30ea5904fb81249e55dfe +2026-08-08-native-windows-pull-request-ci.zh.md: 4342362815ecf738a4730dc1417c1be0eaddf3af diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 6a62fddb79..1c6a1c4dcf 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -18,7 +18,7 @@ Every pull request also starts an ordinary independent `windows-native` job name The native job is deliberately absent from `all-checks-passed.needs` and does not use `continue-on-error`: the aggregate neither waits for it nor changes conclusion because of it, while the job retains its own unmasked result. Workspace build, production-site, and 100%-per-file coverage failures make the native job fail. The broader static, documentation, package, and built-artifact portability inventory remains observational. Linux remains the owner of duplicate lint and snapshot enforcement, while native Windows independently enforces supported-source coverage. -The 16-core lane gives coverage a two-worker budget, split into one instrumented worker and one exempt-heavy worker, runs two top-level gates concurrently, and allows eight publint workers. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX; the two-gate schedule prevents the exempt-heavy Oxlint probe from racing the workspace build over its temporary contract files. Asynchronous fixtures whose real process, Git, SQLite, watcher, or lazy grammar startup can exceed Vitest's default polling window use explicit bounded waits without changing their asserted outcomes. The LSP sources remain in the denominator; only intrinsically peer-platform source arms use narrow annotated V8 ignores, with their behavior tests retained on the owning platform. +The 16-core lane gives coverage a two-worker budget, split into one instrumented worker and one exempt-heavy worker, runs two top-level gates concurrently, and allows eight publint workers. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX; the two-gate schedule prevents the exempt-heavy Oxlint probe from racing the workspace build over its temporary contract files. Asynchronous fixtures whose real process, Git, SQLite, watcher, or lazy grammar startup can exceed Vitest's default polling window use explicit bounded waits without changing their asserted outcomes. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform. The 16-core allocation is the measured capacity point for this inventory. Relative to the previous two-core serial job, six coverage workers produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, but later exact-head repeats exposed unreliable fixtures and worker exits under four, three, and two concurrent instrumented workers. The selected budget therefore reduces that fan-out to one while retaining the exempt-heavy suite as a second concurrent coverage worker and preserving two-way top-level overlap. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 990b8ed143..4342362815 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -18,7 +18,7 @@ Status: implemented 原生作业被刻意排除在 `all-checks-passed.needs` 之外,且不使用 `continue-on-error`:聚合流程既不等待它,也不会因它改变结论;该作业则保留自身未被掩盖的结果。工作区构建、生产网站和逐文件 100% 覆盖率检查失败会使原生作业失败。更广泛的静态检查、文档、包和构建产物可移植性清单仍作为观测项报告。重复的 lint 与快照强制检查仍由 Linux 负责,原生 Windows 则独立强制执行受支持源码覆盖率。 -16 核通道为覆盖率分配 2 个工作线程,其中 1 个用于插桩套件,1 个用于免覆盖率项较多的套件;同时运行 2 项顶层门禁,并允许 8 个 publint 工作线程。每个 Vitest 项目都使用 fork 工作线程,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享工作线程中复现;双门禁调度可避免免覆盖率项较多的 Oxlint 探测与工作区构建在临时约定文件上发生竞态。对于真实进程、Git、SQLite、watcher 或延迟语法启动可能超过 Vitest 的默认轮询窗口的异步 fixture,系统会使用显式的有界等待,而不改变其断言结果。LSP 源码继续计入分母;只有本质上属于另一平台的源码分支使用窄范围且带注释的 V8 ignore,其行为测试仍保留在所属平台。 +16 核通道为覆盖率分配 2 个工作线程,其中 1 个用于插桩套件,1 个用于免覆盖率项较多的套件;同时运行 2 项顶层门禁,并允许 8 个 publint 工作线程。每个 Vitest 项目都使用 fork 工作线程,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享工作线程中复现;双门禁调度可避免免覆盖率项较多的 Oxlint 探测与工作区构建在临时约定文件上发生竞态。对于真实进程、Git、SQLite、watcher 或延迟语法启动可能超过 Vitest 的默认轮询窗口的异步 fixture,系统会使用显式的有界等待,而不改变其断言结果。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。 16 核配置是这项清单经实测选定的容量规格。与此前的双核串行作业相比,6 个覆盖率工作线程曾分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,但后续的分支头精确复跑先后在 4 个、3 个和 2 个插桩工作线程并发时暴露出不稳定的 fixture 与工作线程退出。因此,所选预算将这一扇出降至 1,同时保留免覆盖率项较多的套件作为第二个并发覆盖率工作线程,并继续让两项顶层门禁重叠执行。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork 工作线程内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。 diff --git a/packages/preset/agent-presets/src/authoring.ts b/packages/preset/agent-presets/src/authoring.ts index 5ac4e55874..0f8788ee9b 100644 --- a/packages/preset/agent-presets/src/authoring.ts +++ b/packages/preset/agent-presets/src/authoring.ts @@ -105,6 +105,7 @@ async function tightenModes(dir: string): Promise { if (entry.isDirectory()) { await tightenModes(target) } else { + /* v8 ignore next -- Windows exposes no POSIX owner-execute bit; the POSIX lane covers both file modes. */ await chmod(target, ((await stat(target)).mode & 0o100) === 0 ? 0o600 : 0o700) } } diff --git a/packages/sandbox/sandbox-windows-acl/src/ffi.ts b/packages/sandbox/sandbox-windows-acl/src/ffi.ts index 99b3cfaff3..698f0dc2ee 100644 --- a/packages/sandbox/sandbox-windows-acl/src/ffi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/ffi.ts @@ -168,12 +168,14 @@ export const PROCESS_INFORMATION = koffi.struct('PROCESS_INFORMATION', { dwThreadId: 'uint32', }) +/* v8 ignore start -- layout-mismatch guards fire only on ABI breakage; verify/abi-probe.cpp pins both sizes. */ if (STARTUPINFOW.size !== abi.STARTUPINFOW_SIZE) { throw new Error(`STARTUPINFOW layout mismatch: koffi computed ${STARTUPINFOW.size}, header probe says ${abi.STARTUPINFOW_SIZE}`) } if (PROCESS_INFORMATION.size !== abi.PROCESS_INFORMATION_SIZE) { throw new Error(`PROCESS_INFORMATION layout mismatch: koffi computed ${PROCESS_INFORMATION.size}, header probe says ${abi.PROCESS_INFORMATION_SIZE}`) } +/* v8 ignore stop */ /** * Allocate one pointer-sized slot (for `T **` out-parameters). diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index efa966a441..4878d2a183 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -360,6 +360,8 @@ export class AclSandbox { } } const token = this.token + /* v8 ignore next -- init assigns this.api only after this.token, so an initialized instance always + has its token; the guard mirrors the write-SID guard's defensive shape. */ if (token !== undefined) { try { if (api.closeHandle(token) === 0) throwLastError(api, 'CloseHandle', 'restricted token') diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts new file mode 100644 index 0000000000..e6d5914bd9 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts @@ -0,0 +1,456 @@ +/** + * ACL failure-path tests with stub binding tables (the failure-paths.spec.ts + * pattern): every checked Win32 call in the lock, read-merge-write, and + * grant-skip sequence has a failing counterpart, and each failure closes the + * handles it created before throwing. The exact-ACE skip and the DACL-walk + * defenses are driven through crafted in-memory ACL/SID buffers. Pure + * stubs — no real Win32 calls, so these run on every platform; the + * real-FFI round-trip lives in acl.spec.ts (win32 only). + */ + +import { tmpdir } from 'node:os' +import { describe, expect, it, vi } from 'vitest' +import koffi from 'koffi' + +import { grantWrite, revokeWrite, withPathLock } from '../src/acl.ts' +import { allocBytes, ptrAddress } from '../src/ffi.ts' +import type { NativePtr, Win32Bindings } from '../src/ffi.ts' +import { Win32Error } from '../src/errors.ts' +import * as abi from '../src/win32-abi.ts' + +const PVOID = koffi.pointer('void') + +/** The stub the grant/revoke happy path needs; every call succeeds until a field is overridden per test. */ +function aclApi(overrides: Partial = {}): Win32Bindings { + return { + getTempPathW: vi.fn((_length: number, buffer: Buffer) => { + const temp = tmpdir().replace(/[\\/]$/u, '') + buffer.write(temp, 'utf16le') + return temp.length + }), + createFileW: vi.fn(() => 7n), + lockFileEx: vi.fn(() => 1), + unlockFileEx: vi.fn(() => 1), + closeHandle: vi.fn(() => 1), + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, 0n) // no explicit DACL: the merge builds one + koffi.encode(descriptor, PVOID, 0n) + return 0 + }), + setEntriesInAclW: vi.fn((_count: unknown, _entries: unknown, _old: unknown, newAcl: NativePtr) => { + koffi.encode(newAcl, PVOID, 9n) + return 0 + }), + setNamedSecurityInfoW: vi.fn(() => 0), + localFree: vi.fn(() => 0n as NativePtr), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + ...overrides, + } as unknown as Win32Bindings +} + +/** One SID allocation: revision@0, subAuthorityCount@1, identifierAuthority@2 (6 bytes), subauthorities@8. */ +function craftSid(revision: number, count: number, authority: number[] = [0, 0, 0, 0, 0, 5]): NativePtr { + const sid = allocBytes(8) + koffi.encode(sid, 'uint8', revision) + koffi.encode(sid, 1, 'uint8', count) + authority.forEach((byte, index) => { + koffi.encode(sid, 2 + index, 'uint8', byte) + }) + return sid +} + +/** + * One in-memory ACL carrying the exact grant ACE the skip checks for: + * header (AclRevision@0, AclSize@2, AceCount@4) then one ACCESS_ALLOWED_ACE + * (AceType@0, AceFlags@1, AceSize@2, Mask@4, inline SID@8). `match` selects + * whether the inline SID bytes equal `sid`. + */ +function craftAclWithGrant(sid: NativePtr, match: boolean): NativePtr { + const acl = allocBytes(32) + koffi.encode(acl, 'uint8', 2) // AclRevision + koffi.encode(acl, 2, 'uint16', 16) // AclSize: header + one 8-byte-SID ACE + koffi.encode(acl, 4, 'uint16', 1) // AceCount + const ace = 8 + koffi.encode(acl, ace + 0, 'uint8', abi.ACCESS_ALLOWED_ACE_TYPE) + koffi.encode(acl, ace + 1, 'uint8', abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT) + koffi.encode(acl, ace + 2, 'uint16', 8) + koffi.encode(acl, ace + 4, 'uint32', abi.GRANT_MASK) + const inlineSid = ace + 8 + for (let offset = 0; offset < 8; offset++) { + koffi.encode(acl, inlineSid + offset, 'uint8', match + ? koffi.decode(sid, offset, 'uint8') as number + : offset === 0 ? 9 : 0) + } + return acl +} + +describe('withPathLock failure paths', () => { + it('fails closed when CreateFileW returns an invalid handle', () => { + const api = aclApi({ createFileW: vi.fn(() => 0n as NativePtr) }) + let caught: unknown + try { + withPathLock(api, 'C:\\locked', () => {}) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('CreateFileW') + }) + + it('closes the handle and reports when LockFileEx fails', () => { + const closeHandle = vi.fn(() => 1) + const api = aclApi({ lockFileEx: vi.fn(() => 0), closeHandle }) + let caught: unknown + try { + withPathLock(api, 'C:\\locked', () => {}) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('LockFileEx') + expect(closeHandle).toHaveBeenCalledWith(7n) + }) + + it('closes the handle and reports when UnlockFileEx fails', () => { + const closeHandle = vi.fn(() => 1) + const api = aclApi({ unlockFileEx: vi.fn(() => 0), closeHandle }) + let caught: unknown + try { + withPathLock(api, 'C:\\locked', () => {}) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('UnlockFileEx') + expect(closeHandle).toHaveBeenCalledWith(7n) + }) + + it('reports a failed CloseHandle after a successful action', () => { + const api = aclApi({ closeHandle: vi.fn(() => 0) }) + let caught: unknown + try { + withPathLock(api, 'C:\\locked', () => {}) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('CloseHandle') + }) +}) + +describe('mergeAndApply failure paths', () => { + it('reports a SetEntriesInAclW failure when the directory carries no descriptor to free', () => { + const api = aclApi({ setEntriesInAclW: vi.fn(() => 5) }) // default descriptor: none + const sid = craftSid(1, 0) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetEntriesInAclW') + }) + + it('reports a NULL merged ACL when there is no descriptor to free', () => { + const api = aclApi({ setEntriesInAclW: vi.fn(() => 0) }) // no out slot write, no descriptor + const sid = craftSid(1, 0) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetEntriesInAclW') + }) + + it('frees the descriptor and reports when SetEntriesInAclW fails', () => { + const localFree = vi.fn(() => 0n as NativePtr) + const api = aclApi({ + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, 0n) + koffi.encode(descriptor, PVOID, 6n) // an existing explicit DACL + return 0 + }), + setEntriesInAclW: vi.fn(() => 5), + localFree, + }) + const sid = craftSid(1, 0) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetEntriesInAclW') + expect(localFree).toHaveBeenCalledWith(6n) + }) + + it('frees the descriptor and reports a NULL merged ACL', () => { + const localFree = vi.fn(() => 0n as NativePtr) + const api = aclApi({ + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, 0n) + koffi.encode(descriptor, PVOID, 6n) + return 0 + }), + setEntriesInAclW: vi.fn(() => 0), // success without writing the out slot + localFree, + }) + const sid = craftSid(1, 0) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetEntriesInAclW') + expect(localFree).toHaveBeenCalledWith(6n) + }) + + it('frees the merged ACL and reports when SetNamedSecurityInfoW fails', () => { + const localFree = vi.fn(() => 0n as NativePtr) + const api = aclApi({ setNamedSecurityInfoW: vi.fn(() => 5), localFree }) + const sid = craftSid(1, 0) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetNamedSecurityInfoW') + expect(localFree).toHaveBeenCalledWith(9n) + }) + + it('reports a failed descriptor LocalFree after a successful apply', () => { + const api = aclApi({ + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, 0n) + koffi.encode(descriptor, PVOID, 6n) + return 0 + }), + localFree: vi.fn(() => 1n as NativePtr), // both frees "fail"; the first is checked + }) + const sid = craftSid(1, 0) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('LocalFree') + }) + + it('reports a failed merged-ACL LocalFree after a successful apply', () => { + // No existing descriptor (the default stub): the merge's only LocalFree + // is the merged ACL's, which "fails" and is checked after the apply. + const api = aclApi({ localFree: vi.fn(() => 1n as NativePtr) }) + const sid = craftSid(1, 0) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('LocalFree') + }) +}) + +describe('the exact-ACE skip and DACL-walk defenses', () => { + it('grantWrite skips the apply when the standing exact ACE matches (descriptor freed, nothing merged)', () => { + const sid = craftSid(1, 0) + const localFree = vi.fn(() => 0n as NativePtr) + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, ptrAddress(craftAclWithGrant(sid, true))) + koffi.encode(descriptor, PVOID, 6n) + return 0 + }), + localFree, + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid) + expect(setNamedSecurityInfoW).not.toHaveBeenCalled() + expect(localFree).toHaveBeenCalledWith(6n) + }) + + it('grantWrite skips the apply without freeing when the exact ACE stands but no descriptor owns it', () => { + const sid = craftSid(1, 0) + const localFree = vi.fn(() => 0n as NativePtr) + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, ptrAddress(craftAclWithGrant(sid, true))) + koffi.encode(descriptor, PVOID, 0n) // the read "returned" a bare ACL with no descriptor + return 0 + }), + localFree, + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid) + expect(setNamedSecurityInfoW).not.toHaveBeenCalled() + expect(localFree).not.toHaveBeenCalled() + }) + + it('grantWrite reports a failed descriptor LocalFree on the exact-ACE skip path', () => { + const sid = craftSid(1, 0) + const api = aclApi({ + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, ptrAddress(craftAclWithGrant(sid, true))) + koffi.encode(descriptor, PVOID, 6n) + return 0 + }), + localFree: vi.fn(() => 1n as NativePtr), + }) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('LocalFree') + }) + + it('falls back to the merge path when the standing ACE names a different SID', () => { + const sid = craftSid(1, 0) + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, ptrAddress(craftAclWithGrant(sid, false))) + koffi.encode(descriptor, PVOID, 6n) + return 0 + }), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) + + it('treats an implausibly small ACL size as no exact grant', () => { + const sid = craftSid(1, 0) + const acl = allocBytes(32) + koffi.encode(acl, 'uint8', 2) + koffi.encode(acl, 2, 'uint16', 4) // smaller than the 8-byte ACL header + koffi.encode(acl, 4, 'uint16', 1) + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, ptrAddress(acl)) + koffi.encode(descriptor, PVOID, 6n) + return 0 + }), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) + + it('treats an ACE that would overrun the ACL as no exact grant', () => { + const sid = craftSid(1, 0) + const acl = allocBytes(32) + koffi.encode(acl, 'uint8', 2) + koffi.encode(acl, 2, 'uint16', 8) // header only: no room for any ACE + koffi.encode(acl, 4, 'uint16', 1) + koffi.encode(acl, 10, 'uint16', 100) // the walk reads a lying ACE size + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, ptrAddress(acl)) + koffi.encode(descriptor, PVOID, 6n) + return 0 + }), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) +}) + +describe('revokeWrite no-DACL path', () => { + it('reports nothing to revoke when the read yields neither DACL nor descriptor', () => { + // The default stub encodes a NULL DACL and a NULL descriptor. + const api = aclApi() + const sid = craftSid(1, 0) + expect(revokeWrite(api, 'C:\\granted', sid)).toBe(false) + }) + + it('frees a descriptor that carries no DACL and reports nothing to revoke', () => { + const localFree = vi.fn(() => 0n as NativePtr) + const api = aclApi({ + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, 0n) + koffi.encode(descriptor, PVOID, 6n) // descriptor WITHOUT a DACL + return 0 + }), + localFree, + }) + const sid = craftSid(1, 0) + expect(revokeWrite(api, 'C:\\granted', sid)).toBe(false) + expect(localFree).toHaveBeenCalledWith(6n) + }) + + it('reports a failed descriptor LocalFree on the no-DACL path', () => { + const api = aclApi({ + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, 0n) + koffi.encode(descriptor, PVOID, 6n) + return 0 + }), + localFree: vi.fn(() => 1n as NativePtr), + }) + const sid = craftSid(1, 0) + let caught: unknown + try { + revokeWrite(api, 'C:\\granted', sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('LocalFree') + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts index 0c1d7f42b3..a6bea87998 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts @@ -11,7 +11,8 @@ import koffi from 'koffi' import { PROCESS_INFORMATION, getTempPath } from '../src/ffi.ts' import type { NativePtr, Win32Bindings } from '../src/ffi.ts' import { Win32Error } from '../src/errors.ts' -import { spawnSandboxed, spawnSandboxedInherited } from '../src/spawn.ts' +import { drainPipe, spawnSandboxed, spawnSandboxedInherited, waitForExit } from '../src/spawn.ts' +import * as abi from '../src/win32-abi.ts' const PVOID = koffi.pointer('void') @@ -136,3 +137,318 @@ describe('getTempPath buffer defense', () => { expect(() => getTempPath(api)).toThrow(/GetTempPathW failed \(Win32 122\): required 300/u) }) }) + +/** The stub the pipe-happy path needs: CreatePipe fills both out slots with fresh handles. */ +function pipeOkApi(overrides: Partial = {}): { + api: Win32Bindings + closed: bigint[] + closeHandle: ReturnType +} { + const closed: bigint[] = [] + let next = 1n + const closeHandle = vi.fn((handle: NativePtr) => { + closed.push(handle) + return 1 + }) + const api = { + createPipe: vi.fn((readSlot: NativePtr, writeSlot: NativePtr) => { + koffi.encode(readSlot, PVOID, next++) + koffi.encode(writeSlot, PVOID, next++) + return 1 + }), + setHandleInformation: vi.fn(() => 1), + createProcessAsUserW: vi.fn(( + _token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown, + _inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr, + ) => { + koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: 200n, hThread: 201n, dwProcessId: 1234, dwThreadId: 5678 }) + return 1 + }), + getLastError: vi.fn(() => 5), + closeHandle, + formatMessageW: vi.fn(() => 0), + ...overrides, + } as unknown as Win32Bindings + return { api, closed, closeHandle } +} + +describe('spawn pipe failures close their handles', () => { + const token = 1n as NativePtr + + it('spawnSandboxed reports a CreatePipe failure', () => { + const api = { createPipe: vi.fn(() => 0), getLastError: vi.fn(() => 5), formatMessageW: vi.fn(() => 0) } as unknown as Win32Bindings + let caught: unknown + try { + spawnSandboxed(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('CreatePipe') + }) + + it('spawnSandboxed reports a NULL pipe handle after CreatePipe succeeds', () => { + const api = { createPipe: vi.fn(() => 1), getLastError: vi.fn(() => 5), formatMessageW: vi.fn(() => 0) } as unknown as Win32Bindings + let caught: unknown + try { + spawnSandboxed(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('CreatePipe') + }) + + it('spawnSandboxed reports a SetHandleInformation failure', () => { + const { api } = pipeOkApi({ setHandleInformation: vi.fn(() => 0) }) + let caught: unknown + try { + spawnSandboxed(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetHandleInformation') + }) + + it('spawnSandboxed rejects NULL process/thread handles after a successful spawn', () => { + const { api } = pipeOkApi({ + createProcessAsUserW: vi.fn(( + _token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown, + _inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr, + ) => { + koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: null, hThread: null, dwProcessId: 1234, dwThreadId: 5678 }) + return 1 + }), + }) + expect(() => spawnSandboxed(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' })) + .toThrow(/null process\/thread handles/u) + }) +}) + +describe('spawnSandboxedInherited failure paths', () => { + const token = 1n as NativePtr + + /** The stub the inherited-happy path needs; overrides flip one call per test. */ + function inheritedApi(overrides: Partial = {}): { + api: Win32Bindings + closed: bigint[] + closeHandle: ReturnType + } { + const closed: bigint[] = [] + let std = 50n + const closeHandle = vi.fn((handle: NativePtr) => { + closed.push(handle) + return 1 + }) + const api = { + createJobObjectW: vi.fn(() => 100n), + setInformationJobObject: vi.fn(() => 1), + getStdHandle: vi.fn(() => std++), + setHandleInformation: vi.fn(() => 1), + createProcessAsUserW: vi.fn(( + _token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown, + _inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr, + ) => { + koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: 200n, hThread: 201n, dwProcessId: 1234, dwThreadId: 5678 }) + return 1 + }), + assignProcessToJobObject: vi.fn(() => 1), + resumeThread: vi.fn(() => 0), + getLastError: vi.fn(() => 5), + closeHandle, + formatMessageW: vi.fn(() => 0), + ...overrides, + } as unknown as Win32Bindings + return { api, closed, closeHandle } + } + + it('closes the job and reports when GetStdHandle yields a NULL handle', () => { + const { api, closeHandle } = inheritedApi({ getStdHandle: vi.fn(() => 0n as NativePtr) }) + let caught: unknown + try { + spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('GetStdHandle') + expect(closeHandle).toHaveBeenCalledWith(100n) + }) + + it('reports a SetHandleInformation failure while enabling stdio inheritance', () => { + const { api } = inheritedApi({ setHandleInformation: vi.fn(() => 0) }) + let caught: unknown + try { + spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetHandleInformation') + }) + + it('closes the job and reports when CreateProcessAsUserW fails', () => { + const { api, closeHandle } = inheritedApi({ createProcessAsUserW: vi.fn(() => 0) }) + let caught: unknown + try { + spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('CreateProcessAsUserW') + expect(closeHandle).toHaveBeenCalledWith(100n) + }) + + it('closes the job and rejects NULL process/thread handles after a successful spawn', () => { + const { api, closeHandle } = inheritedApi({ + createProcessAsUserW: vi.fn(( + _token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown, + _inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr, + ) => { + koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: null, hThread: null, dwProcessId: 1234, dwThreadId: 5678 }) + return 1 + }), + }) + expect(() => spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' })) + .toThrow(/null process\/thread handles/u) + expect(closeHandle).toHaveBeenCalledWith(100n) + }) + + it('closes the job and reports when SetInformationJobObject fails', () => { + const { api, closeHandle } = inheritedApi({ setInformationJobObject: vi.fn(() => 0) }) + let caught: unknown + try { + spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetInformationJobObject') + expect(closeHandle).toHaveBeenCalledWith(100n) + }) + + it('closes the job and reports a NULL job object', () => { + const { api } = inheritedApi({ createJobObjectW: vi.fn(() => 0n as NativePtr) }) + let caught: unknown + try { + spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('CreateJobObjectW') + }) + + it('returns the pid, process handle, and kill-on-close job when every call succeeds', () => { + const { api, closeHandle } = inheritedApi() + const spawned = spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + expect(spawned.pid).toBe(1234) + expect(spawned.process).toBe(200n) + expect(spawned.job).toBe(100n) + // thread handle closed by the spawn; process and job handles stay with the caller. + expect(closeHandle).toHaveBeenCalledWith(201n) + expect(closeHandle).not.toHaveBeenCalledWith(200n) + expect(closeHandle).not.toHaveBeenCalledWith(100n) + }) +}) + +describe('drainPipe', () => { + it('stops at ERROR_NO_DATA and closes the read end', () => { + const closeHandle = vi.fn(() => 1) + const api = { + peekNamedPipe: vi.fn(() => 0), + getLastError: vi.fn(() => abi.ERROR_NO_DATA), + closeHandle, + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + return drainPipe(api, 30n as NativePtr).then((buffer) => { + expect(buffer.length).toBe(0) + expect(closeHandle).toHaveBeenCalledWith(30n) + }) + }) + + it('reports a PeekNamedPipe failure that is not a clean EOF', () => { + const api = { + peekNamedPipe: vi.fn(() => 0), + getLastError: vi.fn(() => 5), + closeHandle: vi.fn(() => 1), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + return expect(drainPipe(api, 30n as NativePtr)).rejects.toMatchObject({ api: 'PeekNamedPipe' }) + }) + + it('reports a ReadFile failure after data was reported available', () => { + const api = { + peekNamedPipe: vi.fn((_pipe: unknown, _buffer: unknown, _size: unknown, _read: unknown, totalAvail: NativePtr) => { + koffi.encode(totalAvail, 'uint32', 4) + return 1 + }), + readFile: vi.fn(() => 0), + getLastError: vi.fn(() => 5), + closeHandle: vi.fn(() => 1), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + return expect(drainPipe(api, 30n as NativePtr)).rejects.toMatchObject({ api: 'ReadFile' }) + }) + + it('drains one chunk and stops at ERROR_BROKEN_PIPE', () => { + let peeks = 0 + const api = { + peekNamedPipe: vi.fn((_pipe: unknown, _buffer: unknown, _size: unknown, _read: unknown, totalAvail: NativePtr) => { + peeks++ + if (peeks > 1) return 0 + koffi.encode(totalAvail, 'uint32', 4) + return 1 + }), + readFile: vi.fn((_file: unknown, chunk: Buffer, _count: unknown, read: NativePtr) => { + chunk.write('ab', 0, 'utf8') + koffi.encode(read, 'uint32', 2) + return 1 + }), + getLastError: vi.fn(() => abi.ERROR_BROKEN_PIPE), + closeHandle: vi.fn(() => 1), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + return drainPipe(api, 30n as NativePtr).then((buffer) => { + expect(buffer.toString('utf8')).toBe('ab') + }) + }) +}) + +describe('waitForExit', () => { + it('reports a WaitForSingleObject failure', () => { + const api = { + waitForSingleObject: vi.fn(() => 0xFFFFFFFF), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + expect(() => waitForExit(api, 200n as NativePtr)).toThrow(Win32Error) + }) + + it('reports a GetExitCodeProcess failure', () => { + const api = { + waitForSingleObject: vi.fn(() => 0), + getExitCodeProcess: vi.fn(() => 0), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + expect(() => waitForExit(api, 200n as NativePtr)).toThrow(Win32Error) + }) + + it('returns the exit code and closes the process handle', () => { + const closeHandle = vi.fn(() => 1) + const api = { + waitForSingleObject: vi.fn(() => 0), + getExitCodeProcess: vi.fn((_process: unknown, slot: NativePtr) => { + koffi.encode(slot, 'uint32', 42) + return 1 + }), + closeHandle, + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + expect(waitForExit(api, 200n as NativePtr)).toBe(42) + expect(closeHandle).toHaveBeenCalledWith(200n) + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts new file mode 100644 index 0000000000..8388911598 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts @@ -0,0 +1,190 @@ +/** + * FFI helper tests with stub binding tables (the failure-paths.spec.ts + * pattern): error formatting and temp-path decoding defenses, the + * last-error throwers' detail fallback, pointer decode NULL handling, and + * the bounded SID comparison's early exits. Pure stubs — no real Win32 + * calls, so these run on every platform; the real-FFI round-trip lives in + * acl.spec.ts and probe.spec.ts (win32 only). + */ + +import { describe, expect, it, vi } from 'vitest' +import koffi from 'koffi' + +import { Win32Error } from '../src/errors.ts' +import { + allocBytes, decodePtr, decodePtrAt, errorText, getTempPath, + isInvalidHandle, isNullPtr, sameSidAt, throwLastError, throwWin32, +} from '../src/ffi.ts' +import type { NativePtr, Win32Bindings } from '../src/ffi.ts' +import * as abi from '../src/win32-abi.ts' + +const PVOID = koffi.pointer('void') + +/** A stub whose formatMessageW writes real UTF-16 text (the errorText round-trip). */ +function formatApi(): { api: Win32Bindings; formatMessageW: ReturnType } { + const formatMessageW = vi.fn((_flags: number, _source: null, _id: number, _lang: number, buffer: Buffer, _size: number, _args: null) => { + const text = 'access denied' + buffer.write(text, 'utf16le') + return text.length + }) + const api = { + formatMessageW, + getLastError: vi.fn(() => 5), + } as unknown as Win32Bindings + return { api, formatMessageW } +} + +/** A minimal SID allocation: revision@0, subAuthorityCount@1, identifierAuthority@2, subauthorities@8. */ +function craftSid(revision: number, count: number, authority: number[] = [0, 0, 0, 0, 0, 0], subs: number[] = []): NativePtr { + const sid = allocBytes(8 + subs.length * 4) + koffi.encode(sid, 'uint8', revision) + koffi.encode(sid, 1, 'uint8', count) + authority.forEach((byte, index) => { + koffi.encode(sid, 2 + index, 'uint8', byte) + }) + subs.forEach((sub, index) => { + koffi.encode(sid, 8 + index * 4, 'uint32', sub) + }) + return sid +} + +describe('errorText', () => { + it('decodes the formatted UTF-16 message and trims it', () => { + const { api } = formatApi() + expect(errorText(api, 5)).toBe('access denied') + }) + + it('returns an empty string when FormatMessageW formats nothing', () => { + const api = { formatMessageW: vi.fn(() => 0) } as unknown as Win32Bindings + expect(errorText(api, 5)).toBe('') + }) +}) + +describe('getTempPath', () => { + it('decodes the NUL-terminated temp path GetTempPathW wrote', () => { + const api = { + getTempPathW: vi.fn((_length: number, buffer: Buffer) => { + buffer.write('C:\\TEMP', 'utf16le') + return 7 + }), + } as unknown as Win32Bindings + expect(getTempPath(api)).toBe('C:\\TEMP') + }) + + it('reports the Win32 failure when GetTempPathW writes nothing', () => { + const { api } = formatApi() + const failing = { ...api, getTempPathW: vi.fn(() => 0) } as Win32Bindings + let caught: unknown + try { + getTempPath(failing) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('GetTempPathW') + }) +}) + +describe('throwLastError and throwWin32', () => { + it('throwLastError formats the system message when no detail is given', () => { + const { api } = formatApi() + let caught: unknown + try { + throwLastError(api, 'Probe') + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).message).toContain('Probe failed (Win32 5): access denied') + }) + + it('throwWin32 formats the system message when no detail is given', () => { + const { api } = formatApi() + let caught: unknown + try { + throwWin32(api, 'Probe', 5) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).message).toContain('Probe failed (Win32 5): access denied') + }) + + it('Win32Error appends the detail when one is given', () => { + const error = new Win32Error('Probe', 5, 'the lock file path') + expect(error.name).toBe('Win32Error') + expect(error.api).toBe('Probe') + expect(error.win32Code).toBe(5) + expect(error.message).toBe('Probe failed (Win32 5): the lock file path') + }) + + it('Win32Error omits the detail suffix when none is given', () => { + const error = new Win32Error('Probe', 5) + expect(error.message).toBe('Probe failed (Win32 5)') + }) +}) + +describe('pointer NULL handling', () => { + it('isNullPtr accepts null, undefined, and the zero pointer', () => { + expect(isNullPtr(null)).toBe(true) + expect(isNullPtr(undefined)).toBe(true) + expect(isNullPtr(0n as NativePtr)).toBe(true) + expect(isNullPtr(42n as NativePtr)).toBe(false) + }) + + it('isInvalidHandle treats NULL as failure', () => { + expect(isInvalidHandle(null)).toBe(true) + expect(isInvalidHandle(undefined)).toBe(true) + expect(isInvalidHandle(0n as NativePtr)).toBe(true) + expect(isInvalidHandle(42n as NativePtr)).toBe(false) + }) + + it('decodePtrAt returns null for a NULL pointer stored in a buffer', () => { + const buffer = Buffer.alloc(8) + buffer.writeBigUInt64LE(0n, 0) + expect(decodePtrAt(buffer, 0)).toBeNull() + }) + + it('decodePtrAt returns the stored pointer value', () => { + const buffer = Buffer.alloc(8) + buffer.writeBigUInt64LE(42n, 0) + expect(decodePtrAt(buffer, 0)).toBe(42n) + }) + + it('decodePtr returns null for an unset out-parameter slot', () => { + const slot = koffi.alloc(PVOID, 1) as unknown as NativePtr + expect(decodePtr(slot)).toBeNull() + }) +}) + +describe('sameSidAt bounded comparison', () => { + it('rejects a revision mismatch before comparing anything else', () => { + const left = craftSid(1, 0) + const right = craftSid(2, 0) + expect(sameSidAt(left, 0, right, 0)).toBe(false) + }) + + it('rejects a subauthority-count mismatch', () => { + const left = craftSid(1, 1, [0, 0, 0, 0, 0, 5], [42]) + const right = craftSid(1, 2, [0, 0, 0, 0, 0, 5], [42, 43]) + expect(sameSidAt(left, 0, right, 0)).toBe(false) + }) + + it('rejects an implausible subauthority count', () => { + const left = craftSid(1, abi.SID_MAX_SUB_AUTHORITIES + 1) + const right = craftSid(1, abi.SID_MAX_SUB_AUTHORITIES + 1) + expect(sameSidAt(left, 0, right, 0)).toBe(false) + }) + + it('rejects a differing identifier authority byte', () => { + const left = craftSid(1, 0, [0, 0, 0, 0, 0, 5]) + const right = craftSid(1, 0, [0, 0, 0, 0, 0, 6]) + expect(sameSidAt(left, 0, right, 0)).toBe(false) + }) + + it('accepts identical SIDs at nonzero offsets', () => { + const left = craftSid(1, 1, [0, 0, 0, 0, 0, 5], [42]) + const right = craftSid(1, 1, [0, 0, 0, 0, 0, 5], [42]) + expect(sameSidAt(left, 4, right, 4)).toBe(true) + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts new file mode 100644 index 0000000000..87fc23ea9f --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -0,0 +1,388 @@ +/** + * AclSandbox orchestration failure-path tests: the win32 resolver is mocked + * to hand each test a stub binding table, so every checked Win32 call in + * init/spawn/dispose has a failing counterpart without opening real token or + * ACL handles. Constructor validation, the fail-closed init cleanup, and the + * dispose aggregation use the same stubs. Pure stubs — no real Win32 calls, + * so these run on every platform; the real-FFI round-trip lives in + * acl.spec.ts and runner.spec.ts (win32 only). + */ + +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import koffi from 'koffi' + +import { PROCESS_INFORMATION } from '../src/ffi.ts' +import type { NativePtr, Win32Bindings } from '../src/ffi.ts' +import { Win32Error } from '../src/errors.ts' +import { AclSandbox } from '../src/index.ts' +import * as abi from '../src/win32-abi.ts' + +const PVOID = koffi.pointer('void') + +type MockFn = ReturnType + +/** The stub binding table plus the mocks the assertions inspect directly. */ +interface HappyStubs { + api: Win32Bindings + setNamedSecurityInfoW: MockFn + convertStringSidToSidW: MockFn + closeHandle: MockFn + localFree: MockFn + createRestrictedToken: MockFn + createJobObjectW: MockFn + getNamedSecurityInfoW: MockFn +} + +const state = vi.hoisted(() => ({ stubs: undefined as HappyStubs | undefined })) + +vi.mock('../src/ffi.ts', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + win32: () => Promise.resolve(state.stubs?.api as Win32Bindings), + win32Sync: () => state.stubs?.api as Win32Bindings, + } +}) + +const scratchDirs: string[] = [] +afterAll(() => { + for (const dir of scratchDirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) + +function scratch(): string { + const dir = mkdtempSync(join(tmpdir(), 'dsh-acl-index-')) + scratchDirs.push(dir) + return dir +} + +/** + * The stub the whole happy pipeline needs: token opening, write-SID parse, + * workspace+temp grants, logon-SID scan, well-known SID, restricted token, + * default-DACL merge, piped/inherited spawns, drains, and exit waits all + * succeed. Every test flips one call per branch. + */ +function happyStubs(): HappyStubs { + let next = 0n + const fresh = () => ++next + + const openProcess = vi.fn(() => fresh()) + const openProcessToken = vi.fn((_process: unknown, _access: unknown, slot: NativePtr) => { + koffi.encode(slot, PVOID, fresh()) + return 1 + }) + const convertStringSidToSidW = vi.fn((_sid: string, slot: NativePtr) => { + koffi.encode(slot, PVOID, fresh()) + return 1 + }) + const getTempPathW = vi.fn((_length: number, buffer: Buffer) => { + const temp = tmpdir().replace(/[\\/]$/u, '') + buffer.write(temp, 'utf16le') + return temp.length + }) + const createFileW = vi.fn(() => fresh()) + const getNamedSecurityInfoW = vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, 0n) + koffi.encode(descriptor, PVOID, 0n) + return 0 + }) + const setEntriesInAclW = vi.fn((_count: unknown, _entries: unknown, _old: unknown, newAcl: NativePtr) => { + koffi.encode(newAcl, PVOID, fresh()) + return 0 + }) + const setNamedSecurityInfoW = vi.fn(() => 0) + const getTokenInformation = vi.fn((_token: unknown, cls: number, info: Buffer | null, _length: number, needed: NativePtr) => { + if (info === null) { + koffi.encode(needed, 'uint32', cls === abi.TokenGroups ? 24 : 8) + return 0 // the size probe is expected to "fail" + } + if (cls === abi.TokenGroups) { + info.writeUInt32LE(1, 0) + info.writeBigUInt64LE(77n, abi.TOKEN_GROUPS_OFFSET) + info.writeUInt32LE(abi.SE_GROUP_LOGON_ID, abi.TOKEN_GROUPS_OFFSET + 8) + } else { + info.writeBigUInt64LE(88n, 0) // the token's current default DACL + } + return 1 + }) + const getLengthSid = vi.fn(() => 12) + const copySid = vi.fn(() => 1) + const createWellKnownSid = vi.fn(() => 1) + const isValidSid = vi.fn(() => 1) + const createRestrictedToken = vi.fn(( + _existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown, + _rc: unknown, _rs: unknown, slot: NativePtr, + ) => { + koffi.encode(slot, PVOID, fresh()) + return 1 + }) + const setTokenInformation = vi.fn(() => 1) + const createPipe = vi.fn((readSlot: NativePtr, writeSlot: NativePtr) => { + koffi.encode(readSlot, PVOID, fresh()) + koffi.encode(writeSlot, PVOID, fresh()) + return 1 + }) + const setHandleInformation = vi.fn(() => 1) + const createProcessAsUserW = vi.fn(( + _token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown, + _inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr, + ) => { + koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: fresh(), hThread: fresh(), dwProcessId: 1234, dwThreadId: 5678 }) + return 1 + }) + const peekNamedPipe = vi.fn(() => 0) + const readFile = vi.fn(() => 1) + const waitForSingleObject = vi.fn(() => 0) + const getExitCodeProcess = vi.fn((_process: unknown, slot: NativePtr) => { + koffi.encode(slot, 'uint32', 42) + return 1 + }) + const createJobObjectW = vi.fn(() => fresh()) + const setInformationJobObject = vi.fn(() => 1) + const assignProcessToJobObject = vi.fn(() => 1) + const resumeThread = vi.fn(() => 0) + const getStdHandle = vi.fn(() => fresh()) + const localFree = vi.fn(() => 0n) + const closeHandle = vi.fn(() => 1) + const getLastError = vi.fn(() => abi.ERROR_BROKEN_PIPE) // the drains' clean EOF + const formatMessageW = vi.fn(() => 0) + + const api = { + openProcess, openProcessToken, convertStringSidToSidW, getTempPathW, createFileW, + lockFileEx: vi.fn(() => 1), unlockFileEx: vi.fn(() => 1), + getNamedSecurityInfoW, setEntriesInAclW, setNamedSecurityInfoW, getTokenInformation, + getLengthSid, copySid, createWellKnownSid, isValidSid, createRestrictedToken, + setTokenInformation, createPipe, setHandleInformation, createProcessAsUserW, + peekNamedPipe, readFile, waitForSingleObject, getExitCodeProcess, createJobObjectW, + setInformationJobObject, assignProcessToJobObject, resumeThread, getStdHandle, + localFree, closeHandle, getLastError, formatMessageW, + } as unknown as Win32Bindings + return { + api, setNamedSecurityInfoW, convertStringSidToSidW, closeHandle, localFree, + createRestrictedToken, createJobObjectW, getNamedSecurityInfoW, + } +} + +beforeEach(() => { + state.stubs = happyStubs() +}) + +describe('AclSandbox constructor validation', () => { + it('rejects a writable directory that does not exist', () => { + const missing = join(scratch(), 'missing') + expect(() => new AclSandbox({ writableDirs: [missing], tempDir: null, mode: 'read-only' })) + .toThrow(/writable dir does not exist/u) + }) + + it('resolves relative writable directories to absolute paths', () => { + const dir = scratch() + const sandbox = new AclSandbox({ writableDirs: [dir], tempDir: null, mode: 'read-only' }) + expect(sandbox.writableDirs).toEqual([resolve(dir)]) + expect(sandbox.mode).toBe('read-only') + expect(sandbox.tempDir).toBeUndefined() + }) +}) + +describe('AclSandbox init', () => { + it('completes the happy workspace-write pipeline: workspace and temp grants, restricted token, resolved temp dir', async () => { + const { setNamedSecurityInfoW } = state.stubs as HappyStubs + const workspace = scratch() + const temp = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: temp, writeSid: 'S-1-4-9000-1', mode: 'workspace-write' }) + await sandbox.init() + expect(sandbox.tempDir).toBe(resolve(temp)) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(2) + }) + + it('defaults the temp dir to GetTempPathW when no tempDir option is given', async () => { + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], writeSid: 'S-1-4-9000-2', mode: 'workspace-write' }) + await sandbox.init() + expect(sandbox.tempDir).toBe(tmpdir().replace(/[\\/]$/u, '')) + }) + + it('applies no grants when the temp dir option is null', async () => { + const { setNamedSecurityInfoW } = state.stubs as HappyStubs + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-3', mode: 'workspace-write' }) + await sandbox.init() + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) // workspace only + }) + + it('rejects a temp dir that does not exist', async () => { + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: join(scratch(), 'missing'), writeSid: 'S-1-4-9000-4', mode: 'workspace-write' }) + await expect(sandbox.init()).rejects.toThrow(/temp dir does not exist/u) + }) + + it('builds a read-only token without parsing a write SID or applying grants', async () => { + const { convertStringSidToSidW, setNamedSecurityInfoW } = state.stubs as HappyStubs + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'read-only' }) + await sandbox.init() + expect(convertStringSidToSidW).not.toHaveBeenCalled() + expect(setNamedSecurityInfoW).not.toHaveBeenCalled() + expect(() => { sandbox.dispose() }).not.toThrow() // no write SID: nothing to revoke or free + }) + + it('applies no grants when the caller owns the DACLs (manageDacls: false)', async () => { + const { setNamedSecurityInfoW } = state.stubs as HappyStubs + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-5', mode: 'workspace-write', manageDacls: false }) + await sandbox.init() + expect(setNamedSecurityInfoW).not.toHaveBeenCalled() + expect(() => { sandbox.dispose() }).not.toThrow() // caller-owned DACLs: nothing to revoke + }) + + it('refuses a second init on the same instance', async () => { + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-6', mode: 'workspace-write' }) + await sandbox.init() + await expect(sandbox.init()).rejects.toThrow(/already initialized/u) + }) + + it('reports a ConvertStringSidToSidW failure before granting anything', async () => { + const { convertStringSidToSidW, setNamedSecurityInfoW } = state.stubs as HappyStubs + convertStringSidToSidW.mockReturnValue(0) + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-7', mode: 'workspace-write' }) + await expect(sandbox.init()).rejects.toMatchObject({ api: 'ConvertStringSidToSidW' }) + expect(setNamedSecurityInfoW).not.toHaveBeenCalled() + }) + + it('rejects a NULL write SID after ConvertStringSidToSidW succeeds', async () => { + const { convertStringSidToSidW } = state.stubs as HappyStubs + convertStringSidToSidW.mockImplementation(() => 1) // no out slot write + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-8', mode: 'workspace-write' }) + await expect(sandbox.init()).rejects.toBeInstanceOf(Win32Error) + }) + + it('reports a failed close of the current process token', async () => { + const { closeHandle } = state.stubs as HappyStubs + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-9', mode: 'workspace-write' }) + // fresh() hands out 1n to OpenProcess and 2n to OpenProcessToken; the + // token-layer close of 1n succeeds and init's close of 2n fails. + closeHandle.mockImplementation((handle: NativePtr) => (handle === 2n ? 0 : 1)) + await expect(sandbox.init()).rejects.toMatchObject({ api: 'CloseHandle' }) + // The failed init never stored a restricted token: dispose skips the + // token close and the already-drained allocations. + expect(() => { sandbox.dispose() }).not.toThrow() + }) + + it('revokes the revocable grants and aggregates cleanup failures when the token pipeline fails', async () => { + const { createRestrictedToken, localFree, getNamedSecurityInfoW } = state.stubs as HappyStubs + const workspace = scratch() + const temp = scratch() + let inCleanup = false + createRestrictedToken.mockImplementation(() => { + inCleanup = true // the grants already landed: every later call is the cleanup's + return 0 + }) + localFree.mockImplementation(() => (inCleanup ? 1n : 0n)) + getNamedSecurityInfoW.mockImplementation(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + if (inCleanup) return 2 // the cleanup's revocation read fails too + koffi.encode(dacl, PVOID, 0n) + koffi.encode(descriptor, PVOID, 0n) + return 0 + }) + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: temp, writeSid: 'S-1-4-9000-10', mode: 'workspace-write' }) + await expect(sandbox.init()).rejects.toThrow(/3 grant revocation\(s\) also failed/u) + }) +}) + +describe('AclSandbox spawn', () => { + it('refuses to spawn before init', () => { + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-11', mode: 'workspace-write' }) + expect(() => sandbox.spawn({ command: 'probe.exe' })).toThrow(/not initialized/u) + }) + + it('pipe spawn drains empty pipes and settles with the child exit code', async () => { + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-12', mode: 'workspace-write' }) + await sandbox.init() + const child = sandbox.spawn({ command: 'probe.exe', args: ['--flag'], cwd: workspace }) + expect(child.pid).toBe(1234) + const expected = { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode: 42 } + await expect(child.wait()).resolves.toEqual(expected) + // The second wait reuses the settled exit-code promise instead of re-waiting. + await expect(child.wait()).resolves.toEqual(expected) + }) + + it('inherit spawn settles with empty stdio and closes the kill-on-close job', async () => { + const { closeHandle } = state.stubs as HappyStubs + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-13', mode: 'workspace-write' }) + await sandbox.init() + const child = sandbox.spawn({ command: 'probe.exe', stdio: 'inherit' }) + await expect(child.wait()).resolves.toEqual({ stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode: 42 }) + expect(closeHandle).toHaveBeenCalled() + }) + + it('inherit spawn reports a failed close of the kill-on-close job', async () => { + const { closeHandle, createJobObjectW } = state.stubs as HappyStubs + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14', mode: 'workspace-write' }) + await sandbox.init() + let jobHandle = 0n + closeHandle.mockImplementation((handle: NativePtr) => (handle === jobHandle ? 0 : 1)) + const child = sandbox.spawn({ command: 'probe.exe', stdio: 'inherit' }) + jobHandle = createJobObjectW.mock.results.at(-1)?.value as NativePtr + await expect(child.wait()).rejects.toMatchObject({ api: 'CloseHandle' }) + }) +}) + +describe('AclSandbox dispose', () => { + it('is a no-op before init', () => { + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-15', mode: 'workspace-write' }) + expect(() => { sandbox.dispose() }).not.toThrow() + }) + + it('aggregates a failing temp revocation into an AggregateError', async () => { + const { getNamedSecurityInfoW } = state.stubs as HappyStubs + const workspace = scratch() + const temp = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: temp, writeSid: 'S-1-4-9000-16', mode: 'workspace-write' }) + await sandbox.init() + getNamedSecurityInfoW.mockReturnValue(2) + expect(() => { sandbox.dispose() }).toThrow(/1 cleanup failure/u) + }) + + it('aggregates SID and token cleanup failures into an AggregateError', async () => { + const { localFree } = state.stubs as HappyStubs + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-17', mode: 'workspace-write' }) + await sandbox.init() + localFree.mockReturnValue(1n) + expect(() => { sandbox.dispose() }).toThrow(AggregateError) + }) + + it('reports a failed close of the restricted token', async () => { + const { createRestrictedToken, closeHandle } = state.stubs as HappyStubs + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-18', mode: 'workspace-write' }) + let restrictedToken = 0n + createRestrictedToken.mockImplementation(( + _existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown, + _rc: unknown, _rs: unknown, slot: NativePtr, + ) => { + restrictedToken = 99n + koffi.encode(slot, PVOID, restrictedToken) + return 1 + }) + closeHandle.mockImplementation((handle: NativePtr) => (handle === restrictedToken ? 0 : 1)) + await sandbox.init() + expect(() => { sandbox.dispose() }).toThrow(AggregateError) + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts new file mode 100644 index 0000000000..046a87664a --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts @@ -0,0 +1,436 @@ +/** + * Restricted-token failure-path tests with stub binding tables (the + * failure-paths.spec.ts pattern): every checked Win32 call in the token + * pipeline — open, logon-SID scan, well-known SID creation, default-DACL + * merge, restricted-token creation — has a failing counterpart, and each + * failure closes or frees what it created before throwing. Pure stubs — no + * real Win32 calls, so these run on every platform; the real-FFI round-trip + * lives in acl.spec.ts (win32 only). + */ + +import { describe, expect, it, vi } from 'vitest' +import koffi from 'koffi' + +import { allocBytes, isNullPtr } from '../src/ffi.ts' +import type { NativePtr, Win32Bindings } from '../src/ffi.ts' +import { Win32Error } from '../src/errors.ts' +import { + createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken, setTokenDefaultDaclGrant, +} from '../src/token.ts' +import * as abi from '../src/win32-abi.ts' + +const PVOID = koffi.pointer('void') + +describe('openCurrentProcessToken failure paths', () => { + it('reports when OpenProcess yields no handle', () => { + const api = { + openProcess: vi.fn(() => 0n), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + let caught: unknown + try { + openCurrentProcessToken(api) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('OpenProcess') + }) + + it('closes the process handle and reports when OpenProcessToken fails', () => { + const closeHandle = vi.fn(() => 1) + const api = { + openProcess: vi.fn(() => 7n), + openProcessToken: vi.fn(() => 0), + closeHandle, + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + let caught: unknown + try { + openCurrentProcessToken(api) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('OpenProcessToken') + expect(closeHandle).toHaveBeenCalledWith(7n) + }) + + it('reports a failed CloseHandle of the process handle', () => { + const api = { + openProcess: vi.fn(() => 7n), + openProcessToken: vi.fn((_process: unknown, _access: unknown, slot: NativePtr) => { + koffi.encode(slot, PVOID, 9n) + return 1 + }), + closeHandle: vi.fn(() => 0), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + let caught: unknown + try { + openCurrentProcessToken(api) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('CloseHandle') + }) + + it('rejects a NULL token handle after a successful OpenProcessToken', () => { + const api = { + openProcess: vi.fn(() => 7n), + openProcessToken: vi.fn(() => 1), // succeeds without writing the out slot + closeHandle: vi.fn(() => 1), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + let caught: unknown + try { + openCurrentProcessToken(api) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('OpenProcessToken') + }) +}) + +/** + * The stub the logon-SID scan needs: the size probe writes `needed`, the + * second call fills a TOKEN_GROUPS buffer (GroupCount@0, SID pointer@8, + * attributes@16) with the state's one group. The CopySid mock comes back + * beside the table for the one test that asserts on its arguments. + */ +function logonApi(state: { + needed: number + groupCount: number + sidPtr: bigint + logon: boolean + secondOk?: boolean + sidLength?: number + copyOk?: boolean +}): { api: Win32Bindings; copySid: ReturnType } { + const copySid = vi.fn(() => (state.copyOk === false ? 0 : 1)) + const api = { + getTokenInformation: vi.fn((_token: unknown, cls: number, info: Buffer | null, _length: number, needed: NativePtr) => { + if (cls !== abi.TokenGroups) throw new Error(`unexpected token information class ${cls}`) + if (info === null) { + koffi.encode(needed, 'uint32', state.needed) + return 0 // the size probe is expected to "fail" + } + if (state.secondOk === false) return 0 + info.writeUInt32LE(state.groupCount, 0) + if (state.groupCount > 0) { + info.writeBigUInt64LE(state.sidPtr, abi.TOKEN_GROUPS_OFFSET) + info.writeUInt32LE(state.logon ? abi.SE_GROUP_LOGON_ID : 0, abi.TOKEN_GROUPS_OFFSET + 8) + } + return 1 + }), + getLengthSid: vi.fn(() => state.sidLength ?? 12), + copySid, + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + return { api, copySid } +} + +describe('findLogonSid failure paths', () => { + const token = 9n as NativePtr + + it('reports a size probe that wrote nothing', () => { + const { api } = logonApi({ needed: 0, groupCount: 0, sidPtr: 0n, logon: false }) + let caught: unknown + try { + findLogonSid(api, token) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('GetTokenInformation') + }) + + it('rejects an implausibly small TokenGroups size', () => { + const { api } = logonApi({ needed: 4, groupCount: 0, sidPtr: 0n, logon: false }) + let caught: unknown + try { + findLogonSid(api, token) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('GetTokenInformation') + }) + + it('reports a failed TokenGroups read', () => { + const { api } = logonApi({ needed: 24, groupCount: 1, sidPtr: 77n, logon: true, secondOk: false }) + let caught: unknown + try { + findLogonSid(api, token) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('GetTokenInformation') + }) + + it('skips a NULL group SID pointer and throws when no logon SID remains', () => { + const { api } = logonApi({ needed: 24, groupCount: 1, sidPtr: 0n, logon: true }) + expect(() => findLogonSid(api, token)).toThrow(/no logon SID found/u) + }) + + it('skips a non-logon group and throws when no logon SID remains', () => { + const { api } = logonApi({ needed: 24, groupCount: 1, sidPtr: 77n, logon: false }) + expect(() => findLogonSid(api, token)).toThrow(/no logon SID found/u) + }) + + it('reports a zero logon-SID length', () => { + const { api } = logonApi({ needed: 24, groupCount: 1, sidPtr: 77n, logon: true, sidLength: 0 }) + let caught: unknown + try { + findLogonSid(api, token) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('GetLengthSid') + }) + + it('reports a failed CopySid of the logon SID', () => { + const { api } = logonApi({ needed: 24, groupCount: 1, sidPtr: 77n, logon: true, copyOk: false }) + let caught: unknown + try { + findLogonSid(api, token) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('CopySid') + }) + + it('copies the logon SID and returns the new allocation', () => { + const { api, copySid } = logonApi({ needed: 24, groupCount: 1, sidPtr: 77n, logon: true }) + const copy = findLogonSid(api, token) + expect(isNullPtr(copy)).toBe(false) + expect(copySid).toHaveBeenCalledWith(12, copy, 77n) + }) +}) + +describe('makeWellKnownSid failure paths', () => { + it('reports when CreateWellKnownSid fails', () => { + const api = { + createWellKnownSid: vi.fn(() => 0), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + let caught: unknown + try { + makeWellKnownSid(api, abi.WinWorldSid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('CreateWellKnownSid') + }) + + it('reports when the created well-known SID is invalid', () => { + const api = { + createWellKnownSid: vi.fn(() => 1), + isValidSid: vi.fn(() => 0), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + let caught: unknown + try { + makeWellKnownSid(api, abi.WinWorldSid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('IsValidSid') + }) +}) + +/** + * The stub the default-DACL merge needs: the size probe writes `needed`, the + * second call fills the DACL pointer slot, and the merge/apply calls follow + * the state's results. + */ +function daclApi(state: { + needed: number + currentDacl: bigint + secondOk?: boolean + mergeResult?: number + newDacl: bigint + setTokenInfo?: number +}): Win32Bindings { + const api = { + getTokenInformation: vi.fn((_token: unknown, cls: number, info: Buffer | null, _length: number, needed: NativePtr) => { + if (cls !== abi.TokenDefaultDacl) throw new Error(`unexpected token information class ${cls}`) + if (info === null) { + koffi.encode(needed, 'uint32', state.needed) + return 0 // the size probe is expected to "fail" + } + if (state.secondOk === false) return 0 + info.writeBigUInt64LE(state.currentDacl, 0) + return 1 + }), + setEntriesInAclW: vi.fn((_count: unknown, _entries: unknown, _old: unknown, newAcl: NativePtr) => { + if (state.mergeResult !== undefined && state.mergeResult !== 0) return state.mergeResult + koffi.encode(newAcl, PVOID, state.newDacl) + return 0 + }), + setTokenInformation: vi.fn(() => state.setTokenInfo ?? 1), + localFree: vi.fn(() => 0n), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + return api +} + +describe('setTokenDefaultDaclGrant failure paths', () => { + const token = 9n as NativePtr + const sid = 77n as NativePtr + + it('reports a size probe that wrote nothing', () => { + const api = daclApi({ needed: 0, currentDacl: 0n, newDacl: 0n }) + let caught: unknown + try { + setTokenDefaultDaclGrant(api, token, sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('GetTokenInformation') + }) + + it('reports a failed default-DACL read', () => { + const api = daclApi({ needed: 8, currentDacl: 88n, secondOk: false, newDacl: 0n }) + let caught: unknown + try { + setTokenDefaultDaclGrant(api, token, sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('GetTokenInformation') + }) + + it('rejects a token that carries no default DACL', () => { + const api = daclApi({ needed: 8, currentDacl: 0n, newDacl: 0n }) + expect(() => { setTokenDefaultDaclGrant(api, token, sid) }).toThrow(/no default DACL/u) + }) + + it('reports a failed SetEntriesInAclW merge', () => { + const api = daclApi({ needed: 8, currentDacl: 88n, mergeResult: 5, newDacl: 0n }) + let caught: unknown + try { + setTokenDefaultDaclGrant(api, token, sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetEntriesInAclW') + }) + + it('rejects a NULL merged default DACL', () => { + const api = daclApi({ needed: 8, currentDacl: 88n, newDacl: 0n }) + let caught: unknown + try { + setTokenDefaultDaclGrant(api, token, sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetEntriesInAclW') + }) + + it('frees the merged DACL and reports when SetTokenInformation fails', () => { + const localFree = vi.fn(() => 0n) + const api = daclApi({ needed: 8, currentDacl: 88n, newDacl: 99n, setTokenInfo: 0 }) + ;(api.localFree as unknown as ReturnType).mockImplementation(localFree) + let caught: unknown + try { + setTokenDefaultDaclGrant(api, token, sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetTokenInformation') + expect(localFree).toHaveBeenCalledWith(99n) + }) + + it('frees the merged DACL after a successful apply', () => { + const localFree = vi.fn(() => 0n) + const api = daclApi({ needed: 8, currentDacl: 88n, newDacl: 99n }) + ;(api.localFree as unknown as ReturnType).mockImplementation(localFree) + setTokenDefaultDaclGrant(api, token, sid) + expect(localFree).toHaveBeenCalledWith(99n) + }) +}) + +describe('createRestrictedToken failure paths', () => { + it('builds the read-only restricting list without a write SID', () => { + const create = vi.fn(( + _existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown, + count: number, _sids: unknown, slot: NativePtr, + ) => { + koffi.encode(slot, PVOID, 9n) + expect(count).toBe(2) + return 1 + }) + const api = { createRestrictedToken: create } as unknown as Win32Bindings + const logon = allocBytes(12) + expect(createRestrictedToken(api, 1n as NativePtr, logon, undefined, { world: 2n as NativePtr }, 'read-only')).toBe(9n) + }) + + it('builds the workspace-write restricting list with the write SID', () => { + const create = vi.fn(( + _existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown, + count: number, _sids: unknown, slot: NativePtr, + ) => { + koffi.encode(slot, PVOID, 9n) + expect(count).toBe(3) + return 1 + }) + const api = { createRestrictedToken: create } as unknown as Win32Bindings + const logon = allocBytes(12) + expect(createRestrictedToken(api, 1n as NativePtr, logon, 3n as NativePtr, { world: 2n as NativePtr }, 'workspace-write')).toBe(9n) + }) + + it('reports when CreateRestrictedToken fails', () => { + const api = { + createRestrictedToken: vi.fn(() => 0), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + const logon = allocBytes(12) + let caught: unknown + try { + createRestrictedToken(api, 1n as NativePtr, logon, undefined, { world: 2n as NativePtr }, 'read-only') + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('CreateRestrictedToken') + }) + + it('rejects a NULL token handle after a successful CreateRestrictedToken', () => { + const api = { + createRestrictedToken: vi.fn(() => 1), // succeeds without writing the out slot + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + const logon = allocBytes(12) + let caught: unknown + try { + createRestrictedToken(api, 1n as NativePtr, logon, undefined, { world: 2n as NativePtr }, 'read-only') + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('CreateRestrictedToken') + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index 68bfafec8d..246f0e9a4d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -47,6 +47,15 @@ const windowsOnlyCoverageExclusions = process.platform !== 'win32' ] : [] +// The confinement runner entry executes exclusively as a spawned child +// process (the sandbox seam's argv-prefix wrapper): its module-level main() +// would run the confinement in-process if imported, and vitest's v8 coverage +// never measures child processes. Its behavior is pinned end-to-end by +// tests/runner.spec.ts, which spawns the real entry through tsx. +const windowsRunnerCoverageExclusions = process.platform === 'win32' + ? ['packages/sandbox/sandbox-windows-acl/src/runner.ts'] + : [] + // pwsh-local's run/start/lifecycle suites self-skip without a real pwsh // (executor.spec.ts hasPwsh), leaving this file // far below per-file 100% on pwsh-less hosts; the exemption keeps those hosts @@ -229,6 +238,7 @@ export default defineConfig({ 'packages/session/session-projection/src/index.ts', ...windowsUnsupportedPackages.map(path => `${path}/src/**/*.ts`), ...windowsOnlyCoverageExclusions, + ...windowsRunnerCoverageExclusions, ...pwshCoverageExclusions, ], // 100% or it doesn't merge (docs/testing.md: excessive tests are welcome). From 08b85654f437698f393d2a8e4c466f53440374c5 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 19:28:02 +0800 Subject: [PATCH 14/25] feat(web): localize shipped agent presets --- .../client/ui-agent-preset/README.i18n.yaml | 4 +- packages/client/ui-agent-preset/README.md | 2 + packages/client/ui-agent-preset/README.zh.md | 2 + .../src/client/AgentPresetLabel.tsx | 6 +- .../src/client/AgentPresetRow.tsx | 12 +-- .../src/client/AgentPresetSeat.tsx | 29 +++++--- .../src/client/AgentPresetSection.tsx | 33 ++++++--- .../ui-agent-preset/src/client/PresetMenu.tsx | 25 ++++--- .../ui-agent-preset/src/client/locales.ts | 73 +++++++++++++++++++ .../ui-agent-preset/tests/components.spec.tsx | 22 ++++-- .../ui-agent-preset/tests/locales.spec.ts | 33 +++++++++ .../ui-agent-preset/tests/section.spec.tsx | 32 ++++---- 12 files changed, 209 insertions(+), 64 deletions(-) create mode 100644 packages/client/ui-agent-preset/tests/locales.spec.ts diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index b1314b349e..a7377027a2 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/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-agent-preset/README.md -README.md: 0f1daeaa6014d4c3c88e6a69ff90cf1ecacdbaf7 -README.zh.md: 08e25d9e98b83a94a434248bb3dff60da1cc31ba +README.md: c4c7df4e6fbe0479cac4767247c1b10fd65aad77 +README.zh.md: 84c02977ec18f89c06311b570428f92c2b459fb3 diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index 0f1daeaa60..c4c7df4e6f 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -26,6 +26,8 @@ Options and the current default both come from one `agentPreset.list` call. The A locally authored preset is exactly as privileged as the plugins it names, so the list marks `user` rows rather than presenting every preset as shipped and vetted. +Preset files publish one unlocalized `name` and `description`, which Web uses for every `user` row and unknown `system` row. For the four shipped ids (`standard`, `code`, `minimal`, and `cordis`), Web resolves both fields from its active locale only when the roster marks the row `system`; an identically named `user` preset keeps its file metadata. + The row re-reads on `settings/changed` for its own namespace and on `connection/reset`: the roster is a live directory and the default is a settings field, so an external edit or a reconnect can both move it. ## The management section diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index 08e25d9e98..84c02977ec 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -26,6 +26,8 @@ chip 以部署默认值打开,其选择是**暂存**的——该界面先于 本地创作的 preset 的权限恰好等于它所引用的插件,因此列表会标注 `user` 行,而不是把每个 preset 都呈现为随附且已审核的。 +preset 文件提供一套未国际化的 `name` 与 `description`,Web 将其用于所有 `user` 行和未知的 `system` 行。对于四个随附 id(`standard`、`code`、`minimal` 与 `cordis`),只有名单将该行标记为 `system` 时,Web 才会从当前 locale 解析这两个字段;同名的 `user` preset 仍使用其文件元数据。 + 本行在自身命名空间的 `settings/changed` 以及 `connection/reset` 时重新读取:名单是一个活动目录,默认值是一项设置,外部编辑与重新连接都可能改变它。 ## 管理分区 diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx index 82688dd7c2..517a856e9a 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx @@ -15,6 +15,7 @@ import { IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: pulls the ui-conversation SlotMap merge (the header actions). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { AgentPresetSettingsState } from './settings-store.ts' +import { presetDisplayText } from './locales.ts' import css from './AgentPresetLabel.module.css' /** Registration-side business face for the header label. */ @@ -53,10 +54,11 @@ export function AgentPresetLabel({ if (preset === undefined) return null const option = options.find(entry => entry.id === preset) + const text = option === undefined ? undefined : presetDisplayText(option, t) return ( - + - {option?.name ?? preset} + {text?.name ?? preset} ) } diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx index ba875b0b95..eab363122c 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx @@ -8,7 +8,7 @@ import { useEffect, useState } from 'react' import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { AgentPresetSettingsState } from './settings-store.ts' -import type { AgentPresetSettingsKey } from './locales.ts' +import { presetDisplayText, type AgentPresetSettingsKey } from './locales.ts' import { PresetMenu } from './PresetMenu.tsx' import css from './AgentPresetRow.module.css' @@ -52,11 +52,11 @@ export function AgentPresetRow({ load, select, useAgentPreset, t }: AgentPresetR // every session shares the host composition — the row simply does not exist. if (state.status === 'unavailable') return null const busy = state.status === 'loading' || state.status === 'saving' - // The metadata name is what every other surface shows — the id is the - // addressing, not the label. A preset that names itself nothing falls back - // to its id, which is then all there is to say about it. + // Every preset surface applies the same display-copy rule. The id remains + // addressing rather than a label, except where no display name exists. const chosen = state.options.find(option => option.id === state.currentValue) - const label = state.currentValue === '' ? t('loading') : (chosen?.name ?? state.currentValue) + const chosenText = chosen === undefined ? undefined : presetDisplayText(chosen, t) + const label = state.currentValue === '' ? t('loading') : (chosenText?.name ?? state.currentValue) const description: string = state.error ?? t('description') return ( @@ -69,7 +69,7 @@ export function AgentPresetRow({ load, select, useAgentPreset, t }: AgentPresetR options={state.options} selectedId={state.currentValue} label={label} - userTrustLabel={t('userTrust')} + t={t} buttonClassName={css.selector} chevronClassName={css.chevron} disabled={busy || !state.writable || state.options.length === 0} diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx index 8e18471fbc..f4357870bb 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx @@ -19,6 +19,7 @@ import { IconChevronDownOutline14, IconThinkOutline16, Menu } from '@deepseek-ai // Type-only: pulls the ui-conversation SlotMap merge (the hero seat). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { AgentPresetSeatState } from './seat-store.ts' +import { presetDisplayText } from './locales.ts' import css from './AgentPresetSeat.module.css' /** Registration-side business face for the hero chip. */ @@ -57,22 +58,26 @@ export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPr if (state.options.length === 0 || state.current === '') return null const chosen = state.options.find(option => option.id === state.current) + const chosenText = chosen === undefined ? undefined : presetDisplayText(chosen, t) return ( { setOpen(false) }} - items={state.options.map(option => ({ - id: option.id, - // Name and description together: the id alone never said what a - // preset does, which is the whole reason the metadata exists. - label: ( - - {option.name ?? option.id} - {option.description ?? t('noDescription')} - - ), - }))} + items={state.options.map((option) => { + const text = presetDisplayText(option, t) + return { + id: option.id, + // Name and description together: the id alone never says what a + // preset does, which is why the roster carries display copy. + label: ( + + {text.name} + {text.description ?? t('noDescription')} + + ), + } + })} selectedId={state.current} onSelect={(id) => { setOpen(false) @@ -91,7 +96,7 @@ export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPr onClick={() => { setOpen(value => !value) }} > - {chosen?.name ?? state.current} + {chosenText?.name ?? state.current} )} diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx index 3a9d0b960a..f5a31fcdf8 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -18,7 +18,7 @@ import { import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { draftBlocker, type AgentPresetSectionState } from './section-store.ts' -import type { AgentPresetSettingsKey } from './locales.ts' +import { presetDisplayText, type AgentPresetSettingsKey } from './locales.ts' import css from './AgentPresetSection.module.css' /** Registration-side business face for the management section. */ @@ -77,11 +77,13 @@ function CopyDialog({ state, t, actions }: CopyDialogProps): ReactNode { const draft = state.copy const blocker = draft === null ? undefined : draftBlocker(draft, state.rows) const message = draft === null ? null : draft.error ?? (blocker === undefined ? null : t(blocker)) + const source = draft === null ? undefined : state.rows.find(row => row.id === draft.from) + const sourceTitle = source === undefined ? draft?.fromTitle : presetDisplayText(source, t).name return ( { actions.cancelCopy() }} - title={draft === null ? t('copyTitle') : `${t('copyTitle')} · ${t('copyOf')} ${draft.fromTitle}`} + title={draft === null ? t('copyTitle') : `${t('copyTitle')} · ${t('copyOf')} ${sourceTitle}`} closeLabel={t('close')} description={t('copyIntro')} className={css.dialog as string} @@ -143,6 +145,11 @@ function CopyDialog({ state, t, actions }: CopyDialogProps): ReactNode { export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { const { useAgentPresetSection, t, load } = props const state = useAgentPresetSection(snapshot => snapshot) + const viewedId = state.view?.id + const viewedRow = viewedId === undefined ? undefined : state.rows.find(row => row.id === viewedId) + const viewedTitle = state.view === null + ? '' + : viewedRow === undefined ? state.view.title : presetDisplayText(viewedRow, t).name useEffect(() => { void load() @@ -170,13 +177,15 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {

{t('sectionIntro')}

{state.error === null ? null :

{state.error}

} {([['system', t('builtInGroup')], ['user', t('customGroup')]] as const).map(([trust, heading]) => { - const group = state.rows.filter(row => row.trust === trust) + const group = state.rows + .filter(row => row.trust === trust) + .map(row => ({ row, text: presetDisplayText(row, t) })) if (group.length === 0) return null return (

{heading}

    - {group.map(row => ( + {group.map(({ row, text }) => (
  • { void props.makeDefault(row.id) }} > - {row.name ?? row.id} + {text.name} {row.broken !== undefined ? {t('brokenBadge')} : null} @@ -210,7 +219,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { {row.isDefault ? {t('inUse')} : null} - {row.description ?? t('noDescription')} + {text.description ?? t('noDescription')} {row.broken === undefined ? null : {row.broken}} @@ -231,7 +240,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { type="button" className={css.iconButton} data-tip={t('view')} - aria-label={`${t('view')}: ${row.name ?? row.id}`} + aria-label={`${t('view')}: ${text.name}`} onClick={() => { void props.view(row.id) }} > @@ -243,7 +252,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { type="button" className={css.iconButton} data-tip={state.hasDocument ? t('openLocation') : t('showLocation')} - aria-label={`${state.hasDocument ? t('openLocation') : t('showLocation')}: ${row.name ?? row.id}`} + aria-label={`${state.hasDocument ? t('openLocation') : t('showLocation')}: ${text.name}`} onClick={() => { void props.openLocation(row.id) }} > @@ -256,7 +265,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { data-tip={row.broken !== undefined ? t('brokenNoCopy') : state.authorable ? t('duplicate') : t('duplicateUnavailable')} - aria-label={`${t('duplicate')}: ${row.name ?? row.id}`} + aria-label={`${t('duplicate')}: ${text.name}`} onClick={() => { props.beginCopy(row.id) }} > @@ -267,7 +276,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { type="button" className={`${css.iconButton} ${css.iconDanger}`} data-tip={t('delete')} - aria-label={`${t('delete')}: ${row.name ?? row.id}`} + aria-label={`${t('delete')}: ${text.name}`} onClick={() => { props.confirmDelete(row.id) }} > @@ -325,7 +334,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { { props.closeView() }} - title={state.view === null ? '' : `${t('view')} · ${state.view.title}`} + title={state.view === null ? '' : `${t('view')} · ${viewedTitle}`} closeLabel={t('close')} description={t('composition')} className={css.dialog as string} diff --git a/packages/client/ui-agent-preset/src/client/PresetMenu.tsx b/packages/client/ui-agent-preset/src/client/PresetMenu.tsx index 2a6bc6ea28..4b78d8ce6e 100644 --- a/packages/client/ui-agent-preset/src/client/PresetMenu.tsx +++ b/packages/client/ui-agent-preset/src/client/PresetMenu.tsx @@ -11,6 +11,7 @@ import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' import type { AgentPresetOption } from './settings-store.ts' +import { presetDisplayText, type AgentPresetSettingsKey } from './locales.ts' /** What one surface passes to the shared picker. */ export interface PresetMenuProps { @@ -20,8 +21,8 @@ export interface PresetMenuProps { selectedId: string /** Text on the button; the surfaces word a pending roster differently. */ label: string - /** Suffix marking a locally authored preset in the menu. */ - userTrustLabel: string + /** Active Web locale lookup. */ + t: (key: AgentPresetSettingsKey) => string /** Class for the trigger button, owned by the calling surface. */ buttonClassName: string | undefined /** Class for the chevron, owned by the calling surface. */ @@ -42,22 +43,22 @@ export interface PresetMenuProps { * @returns the menu and its trigger. */ export function PresetMenu({ - options, selectedId, label, userTrustLabel, buttonClassName, chevronClassName, + options, selectedId, label, t, buttonClassName, chevronClassName, disabled, open, onOpenChange, onSelect, }: PresetMenuProps) { return ( { onOpenChange(false) }} - items={options.map(option => ({ - id: option.id, - // The metadata name is what every surface shows; the id is addressing, - // not a label. A preset that names itself nothing falls back to its id, - // which is then all there is to say about it. - label: option.trust === 'user' - ? `${option.name ?? option.id} · ${userTrustLabel}` - : option.name ?? option.id, - }))} + items={options.map((option) => { + const name = presetDisplayText(option, t).name + return { + id: option.id, + // All preset surfaces resolve copy the same way; the id is addressing, + // not a label, except where no display name exists. + label: option.trust === 'user' ? `${name} · ${t('userTrust')}` : name, + } + })} selectedId={selectedId} onSelect={(id) => { onOpenChange(false) diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts index 50a4e36138..7d7453837f 100644 --- a/packages/client/ui-agent-preset/src/client/locales.ts +++ b/packages/client/ui-agent-preset/src/client/locales.ts @@ -4,6 +4,10 @@ export type AgentPresetSettingsKey = | 'title' | 'description' | 'loading' | 'error' | 'userTrust' | 'seatHint' | 'headerHint' | 'nav' | 'sectionIntro' | 'builtIn' | 'setDefault' | 'view' + | 'presetStandardName' | 'presetStandardDescription' + | 'presetCodeName' | 'presetCodeDescription' + | 'presetMinimalName' | 'presetMinimalDescription' + | 'presetCordisName' | 'presetCordisDescription' | 'duplicate' | 'duplicateUnavailable' | 'delete' | 'presetId' | 'presetIdPlaceholder' | 'copyOf' | 'displayName' | 'displayNamePlaceholder' | 'inUse' | 'noDescription' | 'builtInGroup' | 'customGroup' @@ -30,6 +34,17 @@ export const en: Record = { builtIn: 'Built-in', setDefault: 'Set as default', view: 'View', + presetStandardName: 'Standard mode', + presetStandardDescription: 'Full coding agent with file editing, shell, search, planning, delegation, and workflows.', + presetCodeName: 'Code mode', + presetCodeDescription: + 'Presents Standard mode\'s tools through Code Mode: the model writes TypeScript against an SDK and runs it once instead of making multiple tool calls.', + presetMinimalName: 'Minimal mode', + presetMinimalDescription: + 'Exposes only bash and str_replace_editor to the model, for benchmarks and minimal reproductions.', + presetCordisName: 'Creator mode', + presetCordisDescription: + 'Adds self-inspection tools to Standard mode, so it can read and modify its own running composition and create new presets from it.', duplicate: 'Duplicate', duplicateUnavailable: 'This deployment has no writable preset directory', delete: 'Delete', @@ -82,6 +97,14 @@ export const zh: Record = { builtIn: '内置', setDefault: '设为默认', view: '查看', + presetStandardName: '标准模式', + presetStandardDescription: '完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。', + presetCodeName: '代码模式', + presetCodeDescription: '标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。', + presetMinimalName: '极简模式', + presetMinimalDescription: '只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。', + presetCordisName: '创造模式', + presetCordisDescription: '标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。', duplicate: '复制', duplicateUnavailable: '此部署未配置可写的预设目录', delete: '删除', @@ -116,3 +139,53 @@ export const zh: Record = { deleteConfirm: '删除', deleting: '正在删除…', } + +/** Preset roster fields needed to resolve Web display copy. */ +export interface PresetDisplaySource { + /** Stable preset id. */ + readonly id: string + /** Whether the deployment ships the preset or the user owns it. */ + readonly trust: 'system' | 'user' + /** Unlocalized name published by the preset. */ + readonly name?: string + /** Unlocalized description published by the preset. */ + readonly description?: string +} + +/** Display copy resolved for the active Web locale. */ +export interface PresetDisplayText { + /** Localized built-in name or the preset's own fallback name. */ + readonly name: string + /** Localized built-in description or the preset's own description. */ + readonly description?: string +} + +interface PresetLocaleKeys { + readonly name: AgentPresetSettingsKey + readonly description: AgentPresetSettingsKey +} + +const BUILT_IN_PRESET_KEYS: Readonly>> = { + standard: { name: 'presetStandardName', description: 'presetStandardDescription' }, + code: { name: 'presetCodeName', description: 'presetCodeDescription' }, + minimal: { name: 'presetMinimalName', description: 'presetMinimalDescription' }, + cordis: { name: 'presetCordisName', description: 'presetCordisDescription' }, +} + +/** + * Resolve preset display copy without making user-authored metadata translatable. + * @param preset - roster row whose copy is being rendered. + * @param t - active Web locale lookup. + * @returns localized copy for a known shipped preset, otherwise file metadata. + */ +export function presetDisplayText( + preset: PresetDisplaySource, + t: (key: AgentPresetSettingsKey) => string, +): PresetDisplayText { + const keys = preset.trust === 'system' ? BUILT_IN_PRESET_KEYS[preset.id] : undefined + if (keys !== undefined) return { name: t(keys.name), description: t(keys.description) } + return { + name: preset.name ?? preset.id, + ...preset.description === undefined ? {} : { description: preset.description }, + } +} diff --git a/packages/client/ui-agent-preset/tests/components.spec.tsx b/packages/client/ui-agent-preset/tests/components.spec.tsx index 7bc3d59e04..8a37a7af43 100644 --- a/packages/client/ui-agent-preset/tests/components.spec.tsx +++ b/packages/client/ui-agent-preset/tests/components.spec.tsx @@ -90,7 +90,7 @@ describe('the General-settings row', () => { const actions = renderRow() await waitFor(() => { expect(actions.load).toHaveBeenCalledTimes(1) }) - expect(screen.getByRole('button').textContent).toContain('标准模式') + expect(screen.getByRole('button').textContent).toContain(en.presetStandardName) }) it('marks a locally authored option as local', () => { @@ -102,7 +102,7 @@ describe('the General-settings row', () => { // list says which rows are local rather than presenting all as vetted. expect(screen.getByText(`mine · ${en.userTrust}`)).toBeTruthy() // The shipped one carries no marker; only local rows are called out. - expect(screen.getAllByText('标准模式')).toHaveLength(2) + expect(screen.getAllByText(en.presetStandardName)).toHaveLength(2) }) it('falls back to the id for a preset that published no name', () => { @@ -128,6 +128,12 @@ describe('the General-settings row', () => { expect(screen.getByText('bare')).toBeTruthy() }) + it('shows the selected id until a stale roster contains it', () => { + renderRow({ currentValue: 'arriving', options: [] }) + + expect(screen.getByRole('button').textContent).toContain('arriving') + }) + it('writes the picked preset and closes the menu', () => { const actions = renderRow() fireEvent.click(screen.getByRole('button')) @@ -194,7 +200,7 @@ describe('the new-session chip', () => { const actions = renderSeat() await waitFor(() => { expect(actions.load).toHaveBeenCalledTimes(1) }) - expect(screen.getByRole('button').textContent).toContain('标准模式') + expect(screen.getByRole('button').textContent).toContain(en.presetStandardName) expect(screen.getByRole('button').getAttribute('title')).toBe(en.seatHint) }) @@ -205,7 +211,7 @@ describe('the new-session chip', () => { // The id alone never said what a preset does; the description is the // whole reason a preset can publish metadata at all. - expect(screen.getByText('完整的编码 agent。')).toBeTruthy() + expect(screen.getByText(en.presetStandardDescription)).toBeTruthy() // A preset that published none still reads as a row, with its id standing // in for the name. expect(screen.getByText(en.noDescription)).toBeTruthy() @@ -218,6 +224,12 @@ describe('the new-session chip', () => { expect(screen.getByRole('button').textContent).toContain('mine') }) + it('shows the staged id until a stale roster contains it', () => { + renderSeat({ current: 'arriving' }) + + expect(screen.getByRole('button').textContent).toContain('arriving') + }) + it('stages the picked preset and closes the menu', () => { const actions = renderSeat() fireEvent.click(screen.getByRole('button')) @@ -267,7 +279,7 @@ describe('the session-header label', () => { await waitFor(() => { expect(load).toHaveBeenCalledTimes(1) }) // A control here would promise a switch the host refuses outright. expect(screen.queryByRole('button')).toBeNull() - expect(screen.getByTitle('完整的编码 agent。').textContent).toBe('标准模式') + expect(screen.getByTitle(en.presetStandardDescription).textContent).toBe(en.presetStandardName) }) it('falls back to the id, and to the generic hint, when metadata is absent', () => { diff --git a/packages/client/ui-agent-preset/tests/locales.spec.ts b/packages/client/ui-agent-preset/tests/locales.spec.ts new file mode 100644 index 0000000000..02623e7d7b --- /dev/null +++ b/packages/client/ui-agent-preset/tests/locales.spec.ts @@ -0,0 +1,33 @@ +/** Web-localized copy for the four shipped presets and file copy for every other row. */ + +import { describe, expect, it } from 'vitest' +import { en, presetDisplayText, zh } from '../src/client/locales.ts' + +const translate = (bundle: typeof en) => (key: keyof typeof en): string => bundle[key] + +describe('preset display copy', () => { + it.each([ + ['standard', 'presetStandardName', 'presetStandardDescription'], + ['code', 'presetCodeName', 'presetCodeDescription'], + ['minimal', 'presetMinimalName', 'presetMinimalDescription'], + ['cordis', 'presetCordisName', 'presetCordisDescription'], + ] as const)('localizes the shipped %s preset in English and Chinese', (id, nameKey, descriptionKey) => { + const preset = { id, trust: 'system' as const, name: 'file name', description: 'file description' } + + expect(presetDisplayText(preset, translate(en))) + .toEqual({ name: en[nameKey], description: en[descriptionKey] }) + expect(presetDisplayText(preset, translate(zh))) + .toEqual({ name: zh[nameKey], description: zh[descriptionKey] }) + }) + + it('keeps file metadata for user and unknown system presets', () => { + const fileCopy = { name: '我的标准', description: '团队自己的 preset。' } + + expect(presetDisplayText({ id: 'standard', trust: 'user', ...fileCopy }, translate(en))) + .toEqual(fileCopy) + expect(presetDisplayText({ id: 'deployment-extra', trust: 'system', ...fileCopy }, translate(en))) + .toEqual(fileCopy) + expect(presetDisplayText({ id: 'bare', trust: 'user' }, translate(en))) + .toEqual({ name: 'bare' }) + }) +}) diff --git a/packages/client/ui-agent-preset/tests/section.spec.tsx b/packages/client/ui-agent-preset/tests/section.spec.tsx index 36e34067b3..05c2b28d67 100644 --- a/packages/client/ui-agent-preset/tests/section.spec.tsx +++ b/packages/client/ui-agent-preset/tests/section.spec.tsx @@ -85,13 +85,13 @@ describe('the preset list', () => { await waitFor(() => { expect(actions.load).toHaveBeenCalledTimes(1) }) }) - it('shows the published name and description, falling back to the id', () => { + it('shows resolved copy for built-ins and falls back to custom ids', () => { renderSection() - // The name is what a picker reads; the id stays visible as the key the + // Display copy is what a picker reads; the id stays visible as the key the // composition and the session header actually carry. - expect(screen.getByText('标准模式')).toBeTruthy() - expect(screen.getByText('完整的编码 agent。')).toBeTruthy() + expect(screen.getByText(en.presetStandardName)).toBeTruthy() + expect(screen.getByText(en.presetStandardDescription)).toBeTruthy() const mine = rowFor('mine') expect(within(mine).getAllByText('mine').length).toBeGreaterThan(0) expect(within(mine).getByText(en.noDescription)).toBeTruthy() @@ -134,7 +134,7 @@ describe('the preset list', () => { it('picks a preset by clicking its card, and the one in use is inert', () => { const actions = renderSection() - const inUse = within(rowFor('standard')).getByRole('button', { name: `${en.inUse}: 标准模式` }) + const inUse = within(rowFor('standard')).getByRole('button', { name: `${en.inUse}: ${en.presetStandardName}` }) expect(inUse).toHaveProperty('disabled', true) fireEvent.click(inUse) @@ -150,8 +150,8 @@ describe('the preset list', () => { // the point. A custom preset is edited in its files, so its row leads // there instead; there is no editor for either. const standard = rowFor('standard') - expect(within(standard).getByRole('button', { name: `${en.view}: 标准模式` })).toBeTruthy() - expect(within(standard).queryByRole('button', { name: `${en.openLocation}: 标准模式` })).toBeNull() + expect(within(standard).getByRole('button', { name: `${en.view}: ${en.presetStandardName}` })).toBeTruthy() + expect(within(standard).queryByRole('button', { name: `${en.openLocation}: ${en.presetStandardName}` })).toBeNull() const mine = rowFor('mine') expect(within(mine).getByRole('button', { name: `${en.openLocation}: mine` })).toBeTruthy() expect(within(mine).queryByRole('button', { name: `${en.view}: mine` })).toBeNull() @@ -161,13 +161,13 @@ describe('the preset list', () => { renderSection() expect(within(rowFor('mine')).getByRole('button', { name: `${en.delete}: mine` })).toBeTruthy() - expect(within(rowFor('standard')).queryByRole('button', { name: `${en.delete}: 标准模式` })).toBeNull() + expect(within(rowFor('standard')).queryByRole('button', { name: `${en.delete}: ${en.presetStandardName}` })).toBeNull() }) it('disables duplication when nothing is writable, and says why', () => { renderSection({ authorable: false }) - const duplicate = within(rowFor('standard')).getByRole('button', { name: `${en.duplicate}: 标准模式` }) + const duplicate = within(rowFor('standard')).getByRole('button', { name: `${en.duplicate}: ${en.presetStandardName}` }) expect(duplicate).toHaveProperty('disabled', true) expect(duplicate.getAttribute('data-tip')).toBe(en.duplicateUnavailable) }) @@ -205,7 +205,7 @@ describe('the preset list', () => { // There is no readable composition to offer; the reason on the card is // the whole story a shipped row can tell. const standard = rowFor('standard') - expect(within(standard).queryByRole('button', { name: `${en.view}: 标准模式` })).toBeNull() + expect(within(standard).queryByRole('button', { name: `${en.view}: ${en.presetStandardName}` })).toBeNull() expect(within(standard).getByRole('alert').textContent).toContain('not valid YAML') }) @@ -232,7 +232,7 @@ describe('the preset list', () => { fireEvent.click(within(rowFor('mine')).getByRole('button', { name: `${en.setDefault}: mine` })) fireEvent.click(within(rowFor('mine')).getByRole('button', { name: `${en.openLocation}: mine` })) fireEvent.click(within(rowFor('mine')).getByRole('button', { name: `${en.duplicate}: mine` })) - fireEvent.click(within(rowFor('standard')).getByRole('button', { name: `${en.view}: 标准模式` })) + fireEvent.click(within(rowFor('standard')).getByRole('button', { name: `${en.view}: ${en.presetStandardName}` })) expect(actions.makeDefault).toHaveBeenCalledWith('mine') expect(actions.openLocation).toHaveBeenCalledWith('mine') @@ -311,7 +311,7 @@ describe('the copy dialog', () => { const actions = renderSection({ copy: draft }) const dialog = screen.getByRole('dialog') - expect(dialog.getAttribute('aria-label')).toBe(`${en.copyTitle} · ${en.copyOf} 标准模式`) + expect(dialog.getAttribute('aria-label')).toBe(`${en.copyTitle} · ${en.copyOf} ${en.presetStandardName}`) expect(within(dialog).getByText(en.copyIntro)).toBeTruthy() fireEvent.change(within(dialog).getByPlaceholderText(en.presetIdPlaceholder), { target: { value: 'my-agent' } }) fireEvent.change(within(dialog).getByPlaceholderText(en.displayNamePlaceholder), { target: { value: '我的模式' } }) @@ -374,11 +374,17 @@ describe('the read-only viewer', () => { renderSection({ view: { id: 'standard', title: '标准模式', content: '- id: tool-bash\n' } }) const dialog = screen.getByRole('dialog') - expect(dialog.getAttribute('aria-label')).toBe(`${en.view} · 标准模式`) + expect(dialog.getAttribute('aria-label')).toBe(`${en.view} · ${en.presetStandardName}`) expect(within(dialog).getByText(en.composition)).toBeTruthy() expect(within(dialog).getByText(/tool-bash/).textContent).toBe('- id: tool-bash\n') }) + it('keeps the loaded title when the viewed row leaves the roster', () => { + renderSection({ view: { id: 'retired', title: 'Retired mode', content: '- id: tool-bash\n' } }) + + expect(screen.getByRole('dialog').getAttribute('aria-label')).toBe(`${en.view} · Retired mode`) + }) + it('closes through the controller', () => { const actions = renderSection({ view: { id: 'standard', title: '标准模式', content: '- id: x\n' } }) From 2f481fa352dd6c776781f46f1c4bdc0579dc2098 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 19:28:40 +0800 Subject: [PATCH 15/25] fix(apiproxy): echo the preset a created session runs, not its header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `session.create` also adopts an already-live session, and the preceding commit newly allows adopting one under the preset it switched to while blank. Its response still echoed `header.agentPreset`, so that adoption answered with the preset the session had just left — contradicting the request it had accepted and the row `session.list` serves for the same session from `resolveSessionPreset()`. The echo now resolves the same way. The `assertPresetUnchanged` parameter doc said `existing` was the preset the session was created under; both callers now pass what it runs. `composeFrom()` was documented as "infallible" and "cannot fail" beside two `@throws`. It has no composition failure mode — no roster read, no mount, no file — but it does reject a caller error, and the wording now says which. The package-level "switched preset" test re-linked to the same preset id, so it could not tell reading the parent's live scope chain from reading its creation header. A second fixture preset makes the switch real. The Web browser lane's subagent goldens gain the preset badge a child now shows, which is the visible consequence of recording its composition. That lane runs only under DSH_EXAMPLE_MODE=lib and was missed before. The Agent Note records two limits found in review: a cold-resumed continuable child joins its parent's current composition rather than the one its header names, and `toolFilter` does not constrain a joined child. The latter is a regression from the agent-plane move rather than anything this change introduces — with the same tools in the global layer the filter applies normally — and is tracked in #2185. Refs #2185 --- ...d-agents-join-their-parent-preset.i18n.yaml | 4 ++-- ...10-child-agents-join-their-parent-preset.md | 10 ++++++++-- ...child-agents-join-their-parent-preset.zh.md | 10 ++++++++-- .../subagent-conversation/ui.expected.md | 2 ++ .../offline-composer.expected.md | 2 ++ docs/subsystems/core.i18n.yaml | 4 ++-- docs/subsystems/core.md | 6 ++++-- docs/subsystems/core.zh.md | 6 ++++-- packages/host/apiproxy/src/api-proxy.ts | 18 ++++++++++++------ .../tests/api-proxy-agent-preset.spec.ts | 5 +++++ packages/preset/agent-presets/README.i18n.yaml | 4 ++-- packages/preset/agent-presets/README.md | 2 +- packages/preset/agent-presets/README.zh.md | 2 +- packages/preset/agent-presets/src/index.ts | 6 ++++-- .../tool-cordis/src/api-catalog.ts | 2 +- .../presets/reviewing/agent.cordis.yml | 6 ++++++ .../tests/preset-inheritance.spec.ts | 8 ++++++-- 17 files changed, 70 insertions(+), 27 deletions(-) create mode 100644 packages/subagent/subagent-inprocess/tests/fixtures/presets/reviewing/agent.cordis.yml diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml index 9afec2a879..34697cd123 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.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 .agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md -2026-08-10-child-agents-join-their-parent-preset.md: c9917c48d10c2b2515284405ea52aed8b476f8b1 -2026-08-10-child-agents-join-their-parent-preset.zh.md: 09e4de5292b65e50bb3973704fd803c819be9f1c +2026-08-10-child-agents-join-their-parent-preset.md: d9aa0dc43c1338d3198f5335da6ad238730d58a1 +2026-08-10-child-agents-join-their-parent-preset.zh.md: dd85c642ff7e6e2934e805c2efaccdc6dda63f15 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md index c9917c48d1..d9aa0dc43c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md @@ -38,10 +38,16 @@ This is a bind, not a mount, and both differences are load-bearing. The child ge `packages/preset/agent-presets/tests/mount.spec.ts` covers the join against real fixture compositions: the child sees its parent's tools and prompt sections, no second generation is mounted, the join survives the parent's disposal (a background child outliving its parent), the reported id matches, a parent without a preset joins nothing, and an unscoped context is refused. -`packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts` asserts the model-visible result through `startInProcessRun()` on a host composition carrying no model-facing rows: the schemas in the child's own request, its parent's prompt section, the recorded header preset, and a parent that switched preset while blank. +`packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts` asserts the model-visible result through `startInProcessRun()` on a host composition carrying no model-facing rows: the schemas in the child's own request, its parent's prompt section, the recorded header preset, and a parent that switched preset while blank — to a DIFFERENT preset, so the assertion distinguishes reading the parent's live scope chain from reading its creation header. + +The assembled-transcript layer is the shipped Web composition's e2e rather than a keyless snapshot. Every runnable example this repo ships composes no preset roster, so the defect is not observable in the snapshot harness at all: a snapshot scenario would first need an example that mounts a roster AND delegates. The Web e2e boots the real `base` + `web-app` patch layers with both shipped presets, which is the assembled evidence the testing policy asks for; the Web browser lane's subagent goldens carry the visible consequence, since a child that records its preset now shows the preset badge its parent shows. ## Consequences -Delegation now costs a scope-parent bind per child and nothing else — no extra plugin instances, no roster read, no failure mode. A child's capabilities are exactly its parent's, minus whatever its own `toolFilter` removes; a per-subagent preset ("agent types") remains unbuilt and would be a new request field rather than a change to this join. +Delegation now costs a scope-parent bind per child and nothing else — no extra plugin instances, no roster read, no failure mode. A child's capabilities are exactly its parent's — the per-child `toolFilter` does not narrow them, for the separately tracked reason below; a per-subagent preset ("agent types") remains unbuilt and would be a new request field rather than a change to this join. `applyChildComposition()` changed shape, so any future out-of-tree in-process driver must supply the parent. That is the intended cost: the previous signature let a caller compose a capability-less child and get no error. + +A cold-resumed continuable child joins its parent's CURRENT composition rather than the one its own header records. The window is narrow — the parent must create the child, stay blank, switch preset, and only then wake it, since a resident child never re-joins and a one-shot child never resumes — and the alternative is worse: resolving the child's own recorded id would re-read the roster and hand back the preset-deleted failure mode this join exists to avoid. The child's header still records what it started under, so the divergence is observable rather than silent. + +`toolFilter` does not constrain a joined child, because `ToolRegistry` compiles restrictions against global-layer names only and overlays chain-layer tools unfiltered. That is not new here — with the roster composed, `tools.restrict()` already rejected every name as an unknown global tool, so a child carrying a filter failed to start both before and after this change — but it is a regression from the agent-plane move rather than a standing limitation: with the same tools registered in the global layer, the filter admits and applies normally. It matters more now that the child has its parent's full tool set to be restricted from. It is tracked separately; this change neither introduces nor repairs it. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md index 09e4de5292..dd85c642ff 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md @@ -38,10 +38,16 @@ Status: implemented `packages/preset/agent-presets/tests/mount.spec.ts` 用真实 fixture 组装覆盖该加入:子 agent 看到父方的工具与提示段、不会挂载出第二个代际、加入在父方 dispose 后依然成立(活得比父方久的后台子 agent)、上报的 id 一致、没有 preset 的父方不产生加入、以及无 scope 的上下文被拒绝。 -`packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts` 在一个不含任何面向模型行的宿主组装上,通过 `startInProcessRun()` 断言模型可见的结果:子 agent 自身请求中的 schema、父方的提示段、记录下来的 header preset,以及在空白期切换过 preset 的父方。 +`packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts` 在一个不含任何面向模型行的宿主组装上,通过 `startInProcessRun()` 断言模型可见的结果:子 agent 自身请求中的 schema、父方的提示段、记录下来的 header preset,以及在空白期切换过 preset 的父方——切换到**另一个** preset,这样断言才能区分"读父方活 scope 链"与"读父方创建 header"。 + +组装记录这一层用的是真实 shipped Web 组装的 e2e,而不是无密钥快照。本仓库所有可运行 example 都不组装 preset roster,因此该缺陷在快照 harness 里根本不可观察:要做快照场景,得先有一个既挂载 roster 又发起委派的 example。Web e2e 启动的是真实的 `base` + `web-app` 补丁层与两个 shipped preset,这正是测试政策要求的组装证据;Web 浏览器 lane 的 subagent golden 承载了可见后果——记录了 preset 的子 agent 现在会显示与其父方相同的 preset 徽标。 ## Consequences -委派现在的成本是每个子 agent 一次 scope 认父,再无其他——没有额外的插件实例、没有 roster 读取、没有新的失败模式。子 agent 的能力恰好等于父方的能力,减去它自己的 `toolFilter` 所移除的部分;逐 subagent 的 preset("agent 类型")仍未构建,那会是一个新的请求字段,而不是对这次加入的改动。 +委派现在的成本是每个子 agent 一次 scope 认父,再无其他——没有额外的插件实例、没有 roster 读取、没有新的失败模式。子 agent 的能力恰好等于父方的能力——逐子 agent 的 `toolFilter` 并不能收窄它,原因见下方另行跟踪的那条;逐 subagent 的 preset("agent 类型")仍未构建,那会是一个新的请求字段,而不是对这次加入的改动。 `applyChildComposition()` 的形态变了,因此将来任何仓库外的进程内驱动都必须提供父方。这是刻意付出的代价:此前的签名允许调用方组装出一个毫无能力的子 agent 而不报任何错。 + +冷恢复的可继续子 agent 加入的是父方**当前**的组装,而不是它自己 header 所记录的那份。窗口很窄——父方必须先建子、保持空白、切换 preset,之后才唤醒它;驻留中的子 agent 不会重新加入,一次性子 agent 也不会恢复——而替代方案更糟:按子 agent 自己记录的 id 解析会重读 roster,把这次认父刻意规避掉的"preset 已删除"失败模式又请回来。子 agent 的 header 仍记录它启动时的那份,因此这处分歧是可观察的而非静默的。 + +`toolFilter` 约束不住已加入组装的子 agent,因为 `ToolRegistry` 只按全局层的名字编译限制,随后把 scope 链上的工具无过滤地叠加进来。这不是本次改动带来的——在组装了 roster 的部署里,`tools.restrict()` 本就把每个名字都判为未知全局工具,因此带过滤器的子 agent 在本次改动前后同样起不来——但它是搬到 agent 平面所引入的回归,而非长期存在的限制:同样这批工具注册在全局层时,过滤器能正常校验并生效。现在子 agent 有了父方的全套工具需要被限制,它变得更要紧。该问题另行跟踪;本次改动既未引入也未修复它。 diff --git a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md index 27c7ec092e..4b71dfdc9c 100644 --- a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md @@ -6,6 +6,8 @@ - button "1 subagent": - text: 1 subagent - img + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md b/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md index a977afbbea..fbec36baea 100644 --- a/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md +++ b/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md @@ -3,6 +3,8 @@ - button "Ask a research subagent to" - text: / - button "event-sourcing researcher" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 947df5c3ae..a7d26cee1b 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.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 docs/subsystems/core.md -core.md: 59c66fdacb369dac1968c2e4fbd2ad70f907d3e9 -core.zh.md: 3b6fc13d0fb54b4e18d7c1bf9849509b1947208b +core.md: ad00c4da7d77b0e1ab4728173b202ebc17fb56a0 +core.zh.md: 9c606023c85369643e7148f829526b1f75ea3631 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 59c66fdacb..ad00c4da7d 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -431,9 +431,11 @@ async mount(agentCtx: Context, id?: string): Promise * parent's history was produced under (and a preset deleted since would fail * the child outright while its parent keeps running). * - * Synchronous and infallible for that reason, which is what lets a child + * Synchronous, and with no composition failure mode of its own — it reads no + * roster, mounts nothing, and touches no file — which is what lets a child * creation window use it: the two in-process subagent drivers compose their - * children inside a synchronous `setup`. + * children inside a synchronous `setup`. It still rejects a caller error, as + * the `@throws` below record. * * A parent that joined no preset — a rosterless deployment — yields no join * and no error: there, the model-facing rows sit in the host composition and diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 3b6fc13d0f..9c606023c8 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -439,9 +439,11 @@ async mount(agentCtx: Context, id?: string): Promise * parent's history was produced under (and a preset deleted since would fail * the child outright while its parent keeps running). * - * Synchronous and infallible for that reason, which is what lets a child + * Synchronous, and with no composition failure mode of its own — it reads no + * roster, mounts nothing, and touches no file — which is what lets a child * creation window use it: the two in-process subagent drivers compose their - * children inside a synchronous `setup`. + * children inside a synchronous `setup`. It still rejects a caller error, as + * the `@throws` below record. * * A parent that joined no preset — a rosterless deployment — yields no join * and no error: there, the model-facing rows sit in the host composition and diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index a1f98d4f28..51a2ca8b34 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1038,7 +1038,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro * common paths — reconnecting, resuming, retrying a create — are unaffected. * @param sessionId - the identity being adopted. * @param requested - the preset the request named, if any. - * @param existing - the preset the session was created under, if any. + * @param existing - the preset the session RUNS, if any; both callers resolve + * it from the log, which differs from the creation header once a blank + * session has switched. * @throws when both are present and differ. */ function assertPresetUnchanged( @@ -1989,12 +1991,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }) } } - // Echo the RESOLVED composition so a client can label the session it - // just created without waiting for the next list refresh — the create - // is the commit point that knows it (a caller that named none gets - // the default the header recorded). + // Echo the composition the session RUNS so a client can label it + // without waiting for the next list refresh — the create is the commit + // point that knows it (a caller that named none gets the default). + // Resolved from the log for the same reason `sessionListFields()` is: + // this handler also adopts an already-live session, and one that + // switched while blank runs a preset its header no longer names, so + // echoing the header would contradict both the adoption this call just + // allowed and the row `session.list` serves for the same session. const created = ctx.agents.get(sessionId) - const createdPreset = created?.session.header.agentPreset + const createdPreset = created === undefined ? undefined : resolveSessionPreset(created.session) return ok(request, { sessionId, ...createdPreset === undefined ? {} : { agentPreset: createdPreset } }) }, diff --git a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts index 996af59986..106cb213da 100644 --- a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts @@ -199,6 +199,11 @@ describe('session.create with an agent preset', () => { // Comparing against the header would invert both answers: the preset the // session actually runs would be refused, and the one it left would pass. expect(adopted.result.ok).toBe(true) + // The echo has to name the same preset the adoption just accepted, or the + // client labels the session with one it has already left — and disagrees + // with the row `session.list` serves for it. + if (!adopted.result.ok) throw new Error('unreachable') + expect(adopted.result.value).toMatchObject({ agentPreset: 'minimal' }) expect(stale.result.ok).toBe(false) if (stale.result.ok) throw new Error('unreachable') expect(stale.result.error.details).toMatchObject({ existingPreset: 'minimal' }) diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index 9c7f2c54ad..8751d2a8f6 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/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/preset/agent-presets/README.md -README.md: 5ccf1d7b224d0e3a67b3aeb9dc6679d6e802063f -README.zh.md: ed79cf48b96ed927feec8860b6211cedc369cdda +README.md: 250d2a6560e680aee5d3088834d5db220854d1d4 +README.zh.md: bd02327a3cca01bc794d63c8933b61bfa5c1008b diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md index 5ccf1d7b22..250d2a6560 100644 --- a/packages/preset/agent-presets/README.md +++ b/packages/preset/agent-presets/README.md @@ -14,7 +14,7 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal - `ctx.agentPresets.list(): Promise` Every preset the configured roots currently supply, earlier root winning a duplicate id; broken presets included, each carrying its reason. - `ctx.agentPresets.resolve(id?): Promise` One preset by id, defaulting to `defaultId`. Throws naming the available ids when no root supplies it. A broken preset resolves — deleting, reading, and reporting one all need the row. - `ctx.agentPresets.mount(agentCtx, id?): Promise` Compose one agent from a preset — ensure its standing mount (single-flight) and parent the agent's scope key to it — returning the preset for the caller to record. Refuses a broken preset up front with its discovery-reported reason, so every unloadable shape fails the same way before the loader is involved. -- `ctx.agentPresets.composeFrom(agentCtx, parentCtx): string | undefined` Join one agent to the standing composition another already runs on, returning the preset id joined — `undefined` when the parent joined none, which is the rosterless deployment and not an error. A bind rather than a mount, so it is synchronous and cannot fail. +- `ctx.agentPresets.composeFrom(agentCtx, parentCtx): string | undefined` Join one agent to the standing composition another already runs on, returning the preset id joined — `undefined` when the parent joined none, which is the rosterless deployment and not an error. A bind rather than a mount, so it is synchronous and has no composition failure mode; it still rejects a caller error (an unscoped context, or an agent that already joined). - `ctx.agentPresets.composedPreset(agentCtx): string | undefined` The preset one LIVE agent runs on, read from its scope chain rather than from its session — the only answer available for an agent whose durable header is still being built. - `ctx.agentPresets.recompose(agentCtx, id): Promise` Re-link one agent to a different preset's standing composition. Valid only while the agent has produced nothing — **the caller owns that check**; the new mount is ensured before the link moves, so a failure leaves the agent as it was. Refuses a broken preset like `mount()`. - `ctx.agentPresets.standingKeyFor(id?): Promise` The standing scope key a host reader with no agent (a cold transcript read) resolves preset registrations in; ensures the mount without starting an agent, session, or turn. Refuses a broken preset like `mount()`. diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index ed79cf48b9..bd02327a3c 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -14,7 +14,7 @@ - `ctx.agentPresets.list(): Promise` 当前各根目录提供的全部 preset;id 重复时靠前的根目录胜出;损坏的 preset 也在其中,各自携带原因。 - `ctx.agentPresets.resolve(id?): Promise` 按 id 取一个 preset,缺省取 `defaultId`。没有任何根目录提供该 id 时抛错,并列出可用 id。损坏的 preset 照样解析——删除、读取与上报都需要这一行。 - `ctx.agentPresets.mount(agentCtx, id?): Promise` 用一个 preset 组装一个 agent——确保其常驻挂载(并发去重)并把 agent 的 scope key 认父到它——返回该 preset 供调用方记录。对损坏的 preset 直接以发现时记下的原因拒绝,所以每种不可加载的形态都在加载器介入之前以同一方式失败。 -- `ctx.agentPresets.composeFrom(agentCtx, parentCtx): string | undefined` 让一个 agent 加入另一个 agent 已在运行的常驻组装,返回所加入的 preset id——父方未加入任何 preset 时返回 `undefined`,那是无 roster 的部署,不是错误。这是认父而非挂载,因此同步且不会失败。 +- `ctx.agentPresets.composeFrom(agentCtx, parentCtx): string | undefined` 让一个 agent 加入另一个 agent 已在运行的常驻组装,返回所加入的 preset id——父方未加入任何 preset 时返回 `undefined`,那是无 roster 的部署,不是错误。这是认父而非挂载,因此同步、且自身没有组装失败模式;调用方用错(上下文无 scope、agent 已加入过)仍会拒绝。 - `ctx.agentPresets.composedPreset(agentCtx): string | undefined` 某个**活着的** agent 正在运行的 preset,从其 scope 链读取而不是从其会话读取——对于持久化 header 尚在构建中的 agent,这是唯一能拿到的答案。 - `ctx.agentPresets.recompose(agentCtx, id): Promise` 把一个 agent 重链到另一个 preset 的常驻组装。仅在该 agent 尚无任何产出时合法——**由调用方负责该检查**;新挂载在链移动之前确保完成,失败时 agent 原封不动。与 `mount()` 一样拒绝损坏的 preset。 - `ctx.agentPresets.standingKeyFor(id?): Promise` 没有 agent 的宿主读取方(冷读记录)解析 preset 注册所用的常驻 scope key;确保挂载而不启动任何 agent、会话或轮次。与 `mount()` 一样拒绝损坏的 preset。 diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 0fb428c425..1dde902234 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -250,9 +250,11 @@ export class AgentPresets extends Service { * parent's history was produced under (and a preset deleted since would fail * the child outright while its parent keeps running). * - * Synchronous and infallible for that reason, which is what lets a child + * Synchronous, and with no composition failure mode of its own — it reads no + * roster, mounts nothing, and touches no file — which is what lets a child * creation window use it: the two in-process subagent drivers compose their - * children inside a synchronous `setup`. + * children inside a synchronous `setup`. It still rejects a caller error, as + * the `@throws` below record. * * A parent that joined no preset — a rosterless deployment — yields no join * and no error: there, the model-facing rows sit in the host composition and diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 4cc39f662b..69fac20609 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -112,7 +112,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'composeFrom(agentCtx: Context, parentCtx: Context): string | undefined', - jsDoc: '/**\n * Join one agent to the SAME standing composition another already runs on.\n *\n * This is how a child agent inherits its parent\'s capabilities. It is a bind,\n * not a mount: the parent\'s generation is already composed, so the child gets\n * that exact instance — the same plugin objects, the same tool registrations,\n * the same prompt sections. Re-resolving the parent\'s preset by id instead\n * would re-read the roster, and a composition file edited since the parent\n * started would hand the child a DIFFERENT generation than the one its\n * parent\'s history was produced under (and a preset deleted since would fail\n * the child outright while its parent keeps running).\n *\n * Synchronous and infallible for that reason, which is what lets a child\n * creation window use it: the two in-process subagent drivers compose their\n * children inside a synchronous `setup`.\n *\n * A parent that joined no preset — a rosterless deployment — yields no join\n * and no error: there, the model-facing rows sit in the host composition and\n * the child already sees them through the global layer.\n * @param agentCtx - the joining agent\'s scope context.\n * @param parentCtx - the scope context of the agent whose composition to join.\n * @returns the preset id joined, or undefined when the parent joined none.\n * @throws when `agentCtx` carries no scope, or has already joined a preset.\n */', + jsDoc: '/**\n * Join one agent to the SAME standing composition another already runs on.\n *\n * This is how a child agent inherits its parent\'s capabilities. It is a bind,\n * not a mount: the parent\'s generation is already composed, so the child gets\n * that exact instance — the same plugin objects, the same tool registrations,\n * the same prompt sections. Re-resolving the parent\'s preset by id instead\n * would re-read the roster, and a composition file edited since the parent\n * started would hand the child a DIFFERENT generation than the one its\n * parent\'s history was produced under (and a preset deleted since would fail\n * the child outright while its parent keeps running).\n *\n * Synchronous, and with no composition failure mode of its own — it reads no\n * roster, mounts nothing, and touches no file — which is what lets a child\n * creation window use it: the two in-process subagent drivers compose their\n * children inside a synchronous `setup`. It still rejects a caller error, as\n * the `@throws` below record.\n *\n * A parent that joined no preset — a rosterless deployment — yields no join\n * and no error: there, the model-facing rows sit in the host composition and\n * the child already sees them through the global layer.\n * @param agentCtx - the joining agent\'s scope context.\n * @param parentCtx - the scope context of the agent whose composition to join.\n * @returns the preset id joined, or undefined when the parent joined none.\n * @throws when `agentCtx` carries no scope, or has already joined a preset.\n */', }, { signature: 'composedPreset(agentCtx: Context): string | undefined', diff --git a/packages/subagent/subagent-inprocess/tests/fixtures/presets/reviewing/agent.cordis.yml b/packages/subagent/subagent-inprocess/tests/fixtures/presets/reviewing/agent.cordis.yml new file mode 100644 index 0000000000..9971526c12 --- /dev/null +++ b/packages/subagent/subagent-inprocess/tests/fixtures/presets/reviewing/agent.cordis.yml @@ -0,0 +1,6 @@ +# A second agent-plane composition, so a switch is a real switch: the tool a +# joined child sees has to change with it. +- id: only + name: ../../plugins/preset-tool.js + config: + tool: reviewing_only diff --git a/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts b/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts index 01c190a833..43061d46db 100644 --- a/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts @@ -105,12 +105,16 @@ describe('a child agent composed in-process', () => { it('follows a parent that switched preset while blank', async () => { const { ctx, parent } = await setupPresetHost() - await ctx.agentPresets.recompose(parent.ctx, 'coding') + // A DIFFERENT preset, so the assertion below distinguishes reading the + // parent's live scope chain from reading its creation header — re-linking + // to the same id would pass either way. + await ctx.agentPresets.recompose(parent.ctx, 'reviewing') const run = await startInProcessRun(spawnRequest(parent), {}) await run.result - expect(ctx.tools.schemas(run.localAgent).map(schema => schema.name)).toEqual(['preset_only']) + expect(ctx.tools.schemas(run.localAgent).map(schema => schema.name)).toEqual(['reviewing_only']) + expect(run.localAgent?.session.header.agentPreset).toBe('reviewing') await run.dispose() }) }) From 0a17575040b2830248d68f6bea07c56bec3517bf Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Mon, 10 Aug 2026 19:34:01 +0800 Subject: [PATCH 16/25] fix(sandbox): address review: leak FIXME, legal ACL fixture, stronger offset test, prose --- ...08-native-windows-pull-request-ci.i18n.yaml | 4 ++-- ...026-08-08-native-windows-pull-request-ci.md | 2 +- ...-08-08-native-windows-pull-request-ci.zh.md | 2 +- .../sandbox/sandbox-windows-acl/src/index.ts | 10 +++++++--- .../tests/acl-failure-paths.spec.ts | 4 ++-- .../sandbox-windows-acl/tests/ffi.spec.ts | 18 +++++++++++++++--- .../tests/index-failure-paths.spec.ts | 7 ++++--- 7 files changed, 32 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index d6e9a87840..dcdbff1208 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.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 .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 1c6a1c4dcf50ac6fc5d30ea5904fb81249e55dfe -2026-08-08-native-windows-pull-request-ci.zh.md: 4342362815ecf738a4730dc1417c1be0eaddf3af +2026-08-08-native-windows-pull-request-ci.md: 33fbf1ae378112b4fd82633a77afa52056d93d98 +2026-08-08-native-windows-pull-request-ci.zh.md: 552e5cd3129011198fe442ba747cf2fdb7d97365 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 1c6a1c4dcf..33fbf1ae37 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -18,7 +18,7 @@ Every pull request also starts an ordinary independent `windows-native` job name The native job is deliberately absent from `all-checks-passed.needs` and does not use `continue-on-error`: the aggregate neither waits for it nor changes conclusion because of it, while the job retains its own unmasked result. Workspace build, production-site, and 100%-per-file coverage failures make the native job fail. The broader static, documentation, package, and built-artifact portability inventory remains observational. Linux remains the owner of duplicate lint and snapshot enforcement, while native Windows independently enforces supported-source coverage. -The 16-core lane gives coverage a two-worker budget, split into one instrumented worker and one exempt-heavy worker, runs two top-level gates concurrently, and allows eight publint workers. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX; the two-gate schedule prevents the exempt-heavy Oxlint probe from racing the workspace build over its temporary contract files. Asynchronous fixtures whose real process, Git, SQLite, watcher, or lazy grammar startup can exceed Vitest's default polling window use explicit bounded waits without changing their asserted outcomes. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform. +The 16-core lane gives coverage a two-worker budget, split into one instrumented worker and one exempt-heavy worker, runs two top-level gates concurrently, and allows eight publint workers. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX; the two-gate schedule prevents the exempt-heavy Oxlint probe from racing the workspace build over its temporary contract files. Asynchronous fixtures whose real process, Git, SQLite, watcher, or lazy grammar startup can exceed Vitest's default polling window use explicit bounded waits without changing their asserted outcomes. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform. The 16-core allocation is the measured capacity point for this inventory. Relative to the previous two-core serial job, six coverage workers produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, but later exact-head repeats exposed unreliable fixtures and worker exits under four, three, and two concurrent instrumented workers. The selected budget therefore reduces that fan-out to one while retaining the exempt-heavy suite as a second concurrent coverage worker and preserving two-way top-level overlap. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 4342362815..552e5cd312 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -18,7 +18,7 @@ Status: implemented 原生作业被刻意排除在 `all-checks-passed.needs` 之外,且不使用 `continue-on-error`:聚合流程既不等待它,也不会因它改变结论;该作业则保留自身未被掩盖的结果。工作区构建、生产网站和逐文件 100% 覆盖率检查失败会使原生作业失败。更广泛的静态检查、文档、包和构建产物可移植性清单仍作为观测项报告。重复的 lint 与快照强制检查仍由 Linux 负责,原生 Windows 则独立强制执行受支持源码覆盖率。 -16 核通道为覆盖率分配 2 个工作线程,其中 1 个用于插桩套件,1 个用于免覆盖率项较多的套件;同时运行 2 项顶层门禁,并允许 8 个 publint 工作线程。每个 Vitest 项目都使用 fork 工作线程,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享工作线程中复现;双门禁调度可避免免覆盖率项较多的 Oxlint 探测与工作区构建在临时约定文件上发生竞态。对于真实进程、Git、SQLite、watcher 或延迟语法启动可能超过 Vitest 的默认轮询窗口的异步 fixture,系统会使用显式的有界等待,而不改变其断言结果。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。 +16 核通道为覆盖率分配 2 个工作线程,其中 1 个用于插桩套件,1 个用于免覆盖率项较多的套件;同时运行 2 项顶层门禁,并允许 8 个 publint 工作线程。每个 Vitest 项目都使用 fork 工作线程,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享工作线程中复现;双门禁调度可避免免覆盖率项较多的 Oxlint 探测与工作区构建在临时约定文件上发生竞态。对于真实进程、Git、SQLite、watcher 或延迟语法启动可能超过 Vitest 的默认轮询窗口的异步 fixture,系统会使用显式的有界等待,而不改变其断言结果。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。 16 核配置是这项清单经实测选定的容量规格。与此前的双核串行作业相比,6 个覆盖率工作线程曾分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,但后续的分支头精确复跑先后在 4 个、3 个和 2 个插桩工作线程并发时暴露出不稳定的 fixture 与工作线程退出。因此,所选预算将这一扇出降至 1,同时保留免覆盖率项较多的套件作为第二个并发覆盖率工作线程,并继续让两项顶层门禁重叠执行。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork 工作线程内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。 diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 4878d2a183..9a166fd85d 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -249,8 +249,12 @@ export class AclSandbox { } catch (error) { // Best-effort close on the failure path (last error already captured in `error`). api.closeHandle(currentToken) - // Fail-closed cleanup: never leave a revocable (temp) grant or SID - // allocation behind a failed init. Standing workspace ACEs are NOT + // FIXME(windows-acl): a failure after createRestrictedToken leaks the restricted + // token handle and the parsed write SID — this.api stays undefined, so dispose() + // early-returns and cannot clean them up. Close the token and free the write SID + // here (the hardening-followup rework already does both). + // Fail-closed cleanup: revoke the revocable (temp) grants and free the init SID + // allocations a failed init left behind. Standing workspace ACEs are NOT // revoked — they are the intended end state (the reuse cache), not an // error artifact. const cleanupFailures: unknown[] = [] @@ -361,7 +365,7 @@ export class AclSandbox { } const token = this.token /* v8 ignore next -- init assigns this.api only after this.token, so an initialized instance always - has its token; the guard mirrors the write-SID guard's defensive shape. */ + has its token; the guard mirrors the write-SID guard. */ if (token !== undefined) { try { if (api.closeHandle(token) === 0) throwLastError(api, 'CloseHandle', 'restricted token') diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts index e6d5914bd9..005f522fda 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts @@ -72,12 +72,12 @@ function craftSid(revision: number, count: number, authority: number[] = [0, 0, function craftAclWithGrant(sid: NativePtr, match: boolean): NativePtr { const acl = allocBytes(32) koffi.encode(acl, 'uint8', 2) // AclRevision - koffi.encode(acl, 2, 'uint16', 16) // AclSize: header + one 8-byte-SID ACE + koffi.encode(acl, 2, 'uint16', 24) // AclSize: 8-byte header + one 16-byte ACE koffi.encode(acl, 4, 'uint16', 1) // AceCount const ace = 8 koffi.encode(acl, ace + 0, 'uint8', abi.ACCESS_ALLOWED_ACE_TYPE) koffi.encode(acl, ace + 1, 'uint8', abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT) - koffi.encode(acl, ace + 2, 'uint16', 8) + koffi.encode(acl, ace + 2, 'uint16', 16) // AceSize: header + mask + inline 8-byte SID koffi.encode(acl, ace + 4, 'uint32', abi.GRANT_MASK) const inlineSid = ace + 8 for (let offset = 0; offset < 8; offset++) { diff --git a/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts index 8388911598..903f56afc3 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts @@ -182,9 +182,21 @@ describe('sameSidAt bounded comparison', () => { expect(sameSidAt(left, 0, right, 0)).toBe(false) }) - it('accepts identical SIDs at nonzero offsets', () => { - const left = craftSid(1, 1, [0, 0, 0, 0, 0, 5], [42]) - const right = craftSid(1, 1, [0, 0, 0, 0, 0, 5], [42]) + it('accepts identical SIDs at nonzero offsets over differing leading bytes', () => { + const sid = craftSid(1, 1, [0, 0, 0, 0, 0, 5], [42]) + // Embed the same SID bytes at offset 4 of two buffers whose first four + // bytes differ: an offset-ignoring comparison reads the differing + // prefixes and must reject. + const left = allocBytes(4 + 12) + const right = allocBytes(4 + 12) + koffi.encode(left, 0, 'uint32', 0x11111111) + koffi.encode(right, 0, 'uint32', 0x22222222) + for (let offset = 0; offset < 12; offset++) { + const byte = koffi.decode(sid, offset, 'uint8') as number + koffi.encode(left, 4 + offset, 'uint8', byte) + koffi.encode(right, 4 + offset, 'uint8', byte) + } expect(sameSidAt(left, 4, right, 4)).toBe(true) + expect(sameSidAt(left, 0, right, 0)).toBe(false) // the differing prefixes are not a matching SID }) }) diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts index 87fc23ea9f..77e931499d 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -270,10 +270,11 @@ describe('AclSandbox init', () => { // fresh() hands out 1n to OpenProcess and 2n to OpenProcessToken; the // token-layer close of 1n succeeds and init's close of 2n fails. closeHandle.mockImplementation((handle: NativePtr) => (handle === 2n ? 0 : 1)) + // The failure lands after this.token is stored but before this.api is + // assigned; the catch drains the SID allocations and rethrows the + // original error. (The stored restricted token and parsed write SID leak + // until process exit — see the FIXME in init's catch.) await expect(sandbox.init()).rejects.toMatchObject({ api: 'CloseHandle' }) - // The failed init never stored a restricted token: dispose skips the - // token close and the already-drained allocations. - expect(() => { sandbox.dispose() }).not.toThrow() }) it('revokes the revocable grants and aggregates cleanup failures when the token pipeline fails', async () => { From 5e3dd2fd34265946b3c780d552f4c160286f3f78 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 19:40:19 +0800 Subject: [PATCH 17/25] fix(web): clarify shipped preset capabilities --- apps/cli/config/agent-presets/code/preset.yml | 2 +- .../config/agent-presets/cordis/preset.yml | 2 +- .../config/agent-presets/minimal/preset.yml | 2 +- .../config/agent-presets/standard/preset.yml | 2 +- apps/web/tests/agent-preset-authoring.e2e.ts | 2 +- apps/web/tests/agent-preset-selection.e2e.ts | 20 +++++++++---------- .../created.expected.md | 10 +++++----- .../damaged.expected.md | 8 ++++---- .../section.expected.md | 8 ++++---- .../agent-preset-selection/header.expected.md | 2 +- .../agent-preset-selection/hero.expected.md | 4 ++-- .../agent-preset-selection/menu.expected.md | 10 +++++----- .../ui-agent-preset/src/client/locales.ts | 17 ++++++++-------- .../preset/agent-presets/README.i18n.yaml | 4 ++-- packages/preset/agent-presets/README.md | 2 +- packages/preset/agent-presets/README.zh.md | 2 +- 16 files changed, 49 insertions(+), 48 deletions(-) diff --git a/apps/cli/config/agent-presets/code/preset.yml b/apps/cli/config/agent-presets/code/preset.yml index f3426e52f4..17eaccb871 100644 --- a/apps/cli/config/agent-presets/code/preset.yml +++ b/apps/cli/config/agent-presets/code/preset.yml @@ -1,3 +1,3 @@ name: 代码模式 -description: 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。 +description: 具备标准模式的全部能力,并通过 Code Mode SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。 order: 2 diff --git a/apps/cli/config/agent-presets/cordis/preset.yml b/apps/cli/config/agent-presets/cordis/preset.yml index 49cb3c6d44..5f72051346 100644 --- a/apps/cli/config/agent-presets/cordis/preset.yml +++ b/apps/cli/config/agent-presets/cordis/preset.yml @@ -1,3 +1,3 @@ name: 创造模式 -description: 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。 +description: 在标准模式上增加 Cordis 运行时检查、临时插件实验和 preset 创作指导,用于创建新的 Agent preset;临时实验仅影响当前运行时,不会修改内置 preset。 order: 4 diff --git a/apps/cli/config/agent-presets/minimal/preset.yml b/apps/cli/config/agent-presets/minimal/preset.yml index 5521dda140..7160b51c43 100644 --- a/apps/cli/config/agent-presets/minimal/preset.yml +++ b/apps/cli/config/agent-presets/minimal/preset.yml @@ -1,3 +1,3 @@ name: 极简模式 -description: 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。 +description: 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。 order: 3 diff --git a/apps/cli/config/agent-presets/standard/preset.yml b/apps/cli/config/agent-presets/standard/preset.yml index 8eddfbde48..caeacd1c1e 100644 --- a/apps/cli/config/agent-presets/standard/preset.yml +++ b/apps/cli/config/agent-presets/standard/preset.yml @@ -1,3 +1,3 @@ name: 标准模式 -description: 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。 +description: 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。 order: 1 diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts index 0b82d6d808..e8ff5aa538 100644 --- a/apps/web/tests/agent-preset-authoring.e2e.ts +++ b/apps/web/tests/agent-preset-authoring.e2e.ts @@ -161,7 +161,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { expect(composition).toBe(await readFile(join(SHIPPED_PRESETS, 'minimal', 'agent.cordis.yml'), 'utf8')) const metadata = await readFile(join(userRoot, 'my-agent', 'preset.yml'), 'utf8') expect(metadata).toContain('name: 我的模式') - expect(metadata).toContain('description: 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。') + expect(metadata).toContain('description: 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。') expect(metadata).not.toContain('order:') }, 60_000) diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 2bcaa5e628..553a21bb6f 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -143,12 +143,12 @@ describe('web e2e: agent-preset selection', () => { await compareOrRefreshGolden(HERO_EXPECTED, snapshot, MODE) // The chip opens on the deployment default, by the name that preset // publishes rather than its directory name. - expect(snapshot).toContain('标准模式') + expect(snapshot).toContain('Standard mode') }) it('names every preset and what it is for', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-menu')) - await page.getByRole('button', { name: '标准模式' }).click() + await page.getByRole('button', { name: 'Standard mode' }).click() const menu = page.getByRole('menu') await menu.waitFor({ timeout: 10_000 }) @@ -157,15 +157,15 @@ describe('web e2e: agent-preset selection', () => { await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE) // Every shipped preset, each with the sentence saying what it composes — // the id alone never said what a preset does. - expect(snapshot).toContain('极简模式') - expect(snapshot).toContain('创造模式') + expect(snapshot).toContain('Minimal mode') + expect(snapshot).toContain('Creator mode') await page.keyboard.press('Escape') }) it('applies the staged pick to the blank session, and the host honors it', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-stage')) - await page.getByRole('button', { name: '标准模式' }).click() - await page.getByRole('menuitem', { name: /极简模式/ }).click() + await page.getByRole('button', { name: 'Standard mode' }).click() + await page.getByRole('menuitem', { name: /Minimal mode/ }).click() // The chip stages; the blank session the workspace connect produced is // what the stage lands on. The host's own answer is what comes back. @@ -197,8 +197,8 @@ describe('web e2e: agent-preset selection', () => { // against its list row, so a row that never reprojected the first switch // answers "already standard" and sends nothing — and restores the catalog // instead of leaving the session reading the narrower composition. - await page.getByRole('button', { name: '极简模式' }).click() - await page.getByRole('menuitem', { name: /^标准模式/ }).first().click() + await page.getByRole('button', { name: 'Minimal mode' }).click() + await page.getByRole('menuitem', { name: /^Standard mode/ }).first().click() await expect.poll(() => livePreset(scaffold.baseUrl), { timeout: 15_000 }).toBe('standard') await composer.fill('/') @@ -221,10 +221,10 @@ describe('web e2e: agent-preset selection', () => { const snapshot = await captureStableAria(page, '[class*="titleRow"]', scaffold.workspaceCwd) await compareOrRefreshGolden(HEADER_EXPECTED, snapshot, MODE) - expect(snapshot).toContain('极简模式') + expect(snapshot).toContain('Minimal mode') // Static chrome, not a control: the header can only report a composition // the host would refuse to change. - expect(snapshot).not.toContain('button "极简模式"') + expect(snapshot).not.toContain('button "Minimal mode"') }) it('drove every surface without a page error or a stream warning', () => { diff --git a/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md index e5cefe28ef..3f2b155919 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md @@ -20,7 +20,7 @@ - list: - listitem: - 'button "当前使用: 标准模式" [disabled] [pressed]': - - text: 标准模式 内置 当前使用 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。 + - text: 标准模式 内置 当前使用 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。 - code: standard - 'button "查看: 标准模式"': - img @@ -30,7 +30,7 @@ - text: 复制 - listitem: - 'button "设为默认: 代码模式"': - - text: 代码模式 内置 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。 + - text: 代码模式 内置 具备标准模式的全部能力,并通过 Code Mode SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。 - code: code - 'button "查看: 代码模式"': - img @@ -40,7 +40,7 @@ - text: 复制 - listitem: - 'button "设为默认: 极简模式"': - - text: 极简模式 内置 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。 + - text: 极简模式 内置 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。 - code: minimal - 'button "查看: 极简模式"': - img @@ -50,7 +50,7 @@ - text: 复制 - listitem: - 'button "设为默认: 创造模式"': - - text: 创造模式 内置 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。 + - text: 创造模式 内置 在标准模式上增加 Cordis 运行时检查、临时插件实验和 preset 创作指导,用于创建新的 Agent preset;实验不会写回当前内置 preset。 - code: cordis - 'button "查看: 创造模式"': - img @@ -62,7 +62,7 @@ - list: - listitem: - 'button "设为默认: 我的模式"': - - text: 我的模式 自定义 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。 + - text: 我的模式 自定义 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。 - code: my-agent - 'button "查看路径: 我的模式"': - img diff --git a/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md index 8269dc2993..b4133b2459 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md @@ -20,7 +20,7 @@ - list: - listitem: - 'button "当前使用: 标准模式" [disabled] [pressed]': - - text: 标准模式 内置 当前使用 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。 + - text: 标准模式 内置 当前使用 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。 - code: standard - 'button "查看: 标准模式"': - img @@ -30,7 +30,7 @@ - text: 复制 - listitem: - 'button "设为默认: 代码模式"': - - text: 代码模式 内置 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。 + - text: 代码模式 内置 具备标准模式的全部能力,并通过 Code Mode SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。 - code: code - 'button "查看: 代码模式"': - img @@ -40,7 +40,7 @@ - text: 复制 - listitem: - 'button "设为默认: 极简模式"': - - text: 极简模式 内置 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。 + - text: 极简模式 内置 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。 - code: minimal - 'button "查看: 极简模式"': - img @@ -50,7 +50,7 @@ - text: 复制 - listitem: - 'button "设为默认: 创造模式"': - - text: 创造模式 内置 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。 + - text: 创造模式 内置 在标准模式上增加 Cordis 运行时检查、临时插件实验和 preset 创作指导,用于创建新的 Agent preset;实验不会写回当前内置 preset。 - code: cordis - 'button "查看: 创造模式"': - img diff --git a/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md index ac5d6f6736..56b8190662 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md @@ -20,7 +20,7 @@ - list: - listitem: - 'button "当前使用: 标准模式" [disabled] [pressed]': - - text: 标准模式 内置 当前使用 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。 + - text: 标准模式 内置 当前使用 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。 - code: standard - 'button "查看: 标准模式"': - img @@ -30,7 +30,7 @@ - text: 复制 - listitem: - 'button "设为默认: 代码模式"': - - text: 代码模式 内置 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。 + - text: 代码模式 内置 具备标准模式的全部能力,并通过 Code Mode SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。 - code: code - 'button "查看: 代码模式"': - img @@ -40,7 +40,7 @@ - text: 复制 - listitem: - 'button "设为默认: 极简模式"': - - text: 极简模式 内置 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。 + - text: 极简模式 内置 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。 - code: minimal - 'button "查看: 极简模式"': - img @@ -50,7 +50,7 @@ - text: 复制 - listitem: - 'button "设为默认: 创造模式"': - - text: 创造模式 内置 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。 + - text: 创造模式 内置 在标准模式上增加 Cordis 运行时检查、临时插件实验和 preset 创作指导,用于创建新的 Agent preset;实验不会写回当前内置 preset。 - code: cordis - 'button "查看: 创造模式"': - img diff --git a/apps/web/tests/snapshots/agent-preset-selection/header.expected.md b/apps/web/tests/snapshots/agent-preset-selection/header.expected.md index ef2ad4ef57..d627a21945 100644 --- a/apps/web/tests/snapshots/agent-preset-selection/header.expected.md +++ b/apps/web/tests/snapshots/agent-preset-selection/header.expected.md @@ -1,4 +1,4 @@ - navigation "Session hierarchy": - button "Seeded turn" [disabled] - img -- text: 极简模式 +- text: Minimal mode diff --git a/apps/web/tests/snapshots/agent-preset-selection/hero.expected.md b/apps/web/tests/snapshots/agent-preset-selection/hero.expected.md index f2d54eb579..a320fa3e2f 100644 --- a/apps/web/tests/snapshots/agent-preset-selection/hero.expected.md +++ b/apps/web/tests/snapshots/agent-preset-selection/hero.expected.md @@ -2,7 +2,7 @@ - img - text: workspace - img -- button "标准模式": +- button "Standard mode": - img - - text: 标准模式 + - text: Standard mode - img diff --git a/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md b/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md index fd92ab8b5a..586014bd99 100644 --- a/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md +++ b/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md @@ -1,7 +1,7 @@ - menu: - - menuitem "标准模式 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。": - - text: 标准模式 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。 + - menuitem "Standard mode Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows.": + - text: Standard mode Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows. - img - - menuitem "代码模式 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。" - - menuitem "极简模式 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。" - - menuitem "创造模式 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。" + - menuitem "Code mode All Standard mode capabilities, with tools exposed through the Code Mode SDK so the model can combine multi-step operations in one TypeScript program." + - menuitem "Minimal mode Two-tool coding agent with only bash and str_replace_editor, for benchmarks and minimal reproductions." + - menuitem "Creator mode Adds Cordis runtime inspection, temporary-plugin experiments, and preset-authoring guidance to Standard mode for creating new agent presets; experiments are not written back to the active built-in preset." diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts index 7d7453837f..a106394a61 100644 --- a/packages/client/ui-agent-preset/src/client/locales.ts +++ b/packages/client/ui-agent-preset/src/client/locales.ts @@ -35,16 +35,17 @@ export const en: Record = { setDefault: 'Set as default', view: 'View', presetStandardName: 'Standard mode', - presetStandardDescription: 'Full coding agent with file editing, shell, search, planning, delegation, and workflows.', + presetStandardDescription: + 'Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows.', presetCodeName: 'Code mode', presetCodeDescription: - 'Presents Standard mode\'s tools through Code Mode: the model writes TypeScript against an SDK and runs it once instead of making multiple tool calls.', + 'All Standard mode capabilities, with tools exposed through the Code Mode SDK so the model can combine multi-step operations in one TypeScript program.', presetMinimalName: 'Minimal mode', presetMinimalDescription: - 'Exposes only bash and str_replace_editor to the model, for benchmarks and minimal reproductions.', + 'Two-tool coding agent with only bash and str_replace_editor, for benchmarks and minimal reproductions.', presetCordisName: 'Creator mode', presetCordisDescription: - 'Adds self-inspection tools to Standard mode, so it can read and modify its own running composition and create new presets from it.', + 'Adds Cordis runtime inspection, temporary-plugin experiments, and preset-authoring guidance to Standard mode for creating new agent presets; experiments affect only the current runtime and do not modify the built-in preset.', duplicate: 'Duplicate', duplicateUnavailable: 'This deployment has no writable preset directory', delete: 'Delete', @@ -98,13 +99,13 @@ export const zh: Record = { setDefault: '设为默认', view: '查看', presetStandardName: '标准模式', - presetStandardDescription: '完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。', + presetStandardDescription: '功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。', presetCodeName: '代码模式', - presetCodeDescription: '标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。', + presetCodeDescription: '具备标准模式的全部能力,并通过 Code Mode SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。', presetMinimalName: '极简模式', - presetMinimalDescription: '只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。', + presetMinimalDescription: '仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。', presetCordisName: '创造模式', - presetCordisDescription: '标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。', + presetCordisDescription: '在标准模式上增加 Cordis 运行时检查、临时插件实验和 preset 创作指导,用于创建新的 Agent preset;临时实验仅影响当前运行时,不会修改内置 preset。', duplicate: '复制', duplicateUnavailable: '此部署未配置可写的预设目录', delete: '删除', diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index 65af853308..42e9961b36 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/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/preset/agent-presets/README.md -README.md: b6d469b26a0254adc654e5cc49d3df2d10817b2d -README.zh.md: 60c7bc695c27bf2c0169a0e405aa84aa711b9b21 +README.md: 4f41361bc2f22ea87ecbd3e5699e119d8019cc16 +README.zh.md: 148bb06cfcc1c469db7b590e5facb45c35b5fb6d diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md index b6d469b26a..4f41361bc2 100644 --- a/packages/preset/agent-presets/README.md +++ b/packages/preset/agent-presets/README.md @@ -63,7 +63,7 @@ A preset may publish display text in an optional `preset.yml` beside its composi ```yaml name: 极简模式 -description: 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。 +description: 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。 ``` It carries display text ONLY. `id` is the directory name and `trust` comes from the root the preset was discovered under, so neither is writable here — otherwise a locally authored preset could name itself into the shipped set. It is a separate file because the composition is a top-level list of plugin rows: YAML cannot carry sibling keys beside it, and a fake metadata row would hand the Loader something to load. diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index 60c7bc695c..148bb06cfc 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -63,7 +63,7 @@ preset 可以在组装文件旁的可选 `preset.yml` 里发布展示文本: ```yaml name: 极简模式 -description: 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。 +description: 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。 ``` 它**只**承载展示文本。`id` 是目录名,`trust` 取自 preset 被发现时所在的根目录,两者都不可写在这里——否则本地创作的 preset 就能把自己命名进随附集合。之所以是独立文件:组装是插件行的顶层列表,YAML 无法在其旁携带同级键,而伪造一个元信息行等于递给 Loader 一个要加载的东西。 From 13d4d4b3f3e9b1e419b046829eb3a67aa9ffbe30 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 19:44:42 +0800 Subject: [PATCH 18/25] fix(web): focus creator preset description --- apps/cli/config/agent-presets/cordis/preset.yml | 2 +- .../snapshots/agent-preset-authoring/created.expected.md | 2 +- .../snapshots/agent-preset-authoring/damaged.expected.md | 2 +- .../snapshots/agent-preset-authoring/section.expected.md | 2 +- .../tests/snapshots/agent-preset-selection/menu.expected.md | 2 +- packages/client/ui-agent-preset/src/client/locales.ts | 4 ++-- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/cli/config/agent-presets/cordis/preset.yml b/apps/cli/config/agent-presets/cordis/preset.yml index 5f72051346..62475b6454 100644 --- a/apps/cli/config/agent-presets/cordis/preset.yml +++ b/apps/cli/config/agent-presets/cordis/preset.yml @@ -1,3 +1,3 @@ name: 创造模式 -description: 在标准模式上增加 Cordis 运行时检查、临时插件实验和 preset 创作指导,用于创建新的 Agent preset;临时实验仅影响当前运行时,不会修改内置 preset。 +description: 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。 order: 4 diff --git a/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md index 3f2b155919..395e517aa1 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md @@ -50,7 +50,7 @@ - text: 复制 - listitem: - 'button "设为默认: 创造模式"': - - text: 创造模式 内置 在标准模式上增加 Cordis 运行时检查、临时插件实验和 preset 创作指导,用于创建新的 Agent preset;实验不会写回当前内置 preset。 + - text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。 - code: cordis - 'button "查看: 创造模式"': - img diff --git a/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md index b4133b2459..b40b848b5e 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md @@ -50,7 +50,7 @@ - text: 复制 - listitem: - 'button "设为默认: 创造模式"': - - text: 创造模式 内置 在标准模式上增加 Cordis 运行时检查、临时插件实验和 preset 创作指导,用于创建新的 Agent preset;实验不会写回当前内置 preset。 + - text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。 - code: cordis - 'button "查看: 创造模式"': - img diff --git a/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md index 56b8190662..dcbe72641c 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md @@ -50,7 +50,7 @@ - text: 复制 - listitem: - 'button "设为默认: 创造模式"': - - text: 创造模式 内置 在标准模式上增加 Cordis 运行时检查、临时插件实验和 preset 创作指导,用于创建新的 Agent preset;实验不会写回当前内置 preset。 + - text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。 - code: cordis - 'button "查看: 创造模式"': - img diff --git a/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md b/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md index 586014bd99..78ec056f56 100644 --- a/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md +++ b/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md @@ -4,4 +4,4 @@ - img - menuitem "Code mode All Standard mode capabilities, with tools exposed through the Code Mode SDK so the model can combine multi-step operations in one TypeScript program." - menuitem "Minimal mode Two-tool coding agent with only bash and str_replace_editor, for benchmarks and minimal reproductions." - - menuitem "Creator mode Adds Cordis runtime inspection, temporary-plugin experiments, and preset-authoring guidance to Standard mode for creating new agent presets; experiments are not written back to the active built-in preset." + - menuitem "Creator mode Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance." diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts index a106394a61..0fab8db94b 100644 --- a/packages/client/ui-agent-preset/src/client/locales.ts +++ b/packages/client/ui-agent-preset/src/client/locales.ts @@ -45,7 +45,7 @@ export const en: Record = { 'Two-tool coding agent with only bash and str_replace_editor, for benchmarks and minimal reproductions.', presetCordisName: 'Creator mode', presetCordisDescription: - 'Adds Cordis runtime inspection, temporary-plugin experiments, and preset-authoring guidance to Standard mode for creating new agent presets; experiments affect only the current runtime and do not modify the built-in preset.', + 'Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance.', duplicate: 'Duplicate', duplicateUnavailable: 'This deployment has no writable preset directory', delete: 'Delete', @@ -105,7 +105,7 @@ export const zh: Record = { presetMinimalName: '极简模式', presetMinimalDescription: '仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。', presetCordisName: '创造模式', - presetCordisDescription: '在标准模式上增加 Cordis 运行时检查、临时插件实验和 preset 创作指导,用于创建新的 Agent preset;临时实验仅影响当前运行时,不会修改内置 preset。', + presetCordisDescription: '用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。', duplicate: '复制', duplicateUnavailable: '此部署未配置可写的预设目录', delete: '删除', From bf39cef48fd225c336e6026026ab6b8f0a227d55 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Mon, 10 Aug 2026 04:52:07 -0700 Subject: [PATCH 19/25] fix(ci): tolerate platform-specific coverage and curl retries --- packages/preset/agent-presets/src/authoring.ts | 2 ++ scripts/prepare-ci-bubblewrap.sh | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/preset/agent-presets/src/authoring.ts b/packages/preset/agent-presets/src/authoring.ts index 5ac4e55874..8a40879a24 100644 --- a/packages/preset/agent-presets/src/authoring.ts +++ b/packages/preset/agent-presets/src/authoring.ts @@ -105,6 +105,8 @@ async function tightenModes(dir: string): Promise { if (entry.isDirectory()) { await tightenModes(target) } else { + /* v8 ignore next -- Windows mode bits cannot represent POSIX owner-execute state; + * the Windows native gate preserves the DACL while the POSIX suite covers this branch. */ await chmod(target, ((await stat(target)).mode & 0o100) === 0 ? 0o600 : 0o700) } } diff --git a/scripts/prepare-ci-bubblewrap.sh b/scripts/prepare-ci-bubblewrap.sh index 00a513db8f..e5f0902750 100755 --- a/scripts/prepare-ci-bubblewrap.sh +++ b/scripts/prepare-ci-bubblewrap.sh @@ -19,7 +19,7 @@ fi archive="${RUNNER_TEMP}/bubblewrap_${BUBBLEWRAP_VERSION}_amd64.deb" root="${RUNNER_TEMP}/dsh-bubblewrap" -curl --fail --silent --show-error --location --retry 3 --output "$archive" "$BUBBLEWRAP_URL" +curl --fail --silent --show-error --location --retry 3 --retry-all-errors --output "$archive" "$BUBBLEWRAP_URL" printf '%s %s\n' "$BUBBLEWRAP_SHA256" "$archive" | sha256sum --check --status mkdir -p "$root" dpkg-deb --extract "$archive" "$root" From 9ce8340dd9721b0986e00404c5264dcd1e3ccef9 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 19:53:58 +0800 Subject: [PATCH 20/25] fix(web): pin preset before subagent header action --- apps/web/tests/agent-preset-selection.e2e.ts | 63 ++++++++++++++++++- .../agent-preset-selection/header.expected.md | 3 + .../client/ui-agent-preset/README.i18n.yaml | 4 +- packages/client/ui-agent-preset/README.md | 2 +- packages/client/ui-agent-preset/README.zh.md | 2 +- .../ui-agent-preset/src/client/index.ts | 3 +- .../ui-agent-preset/tests/apply.spec.ts | 2 +- .../src/client/contract/slots.ts | 6 +- 8 files changed, 77 insertions(+), 8 deletions(-) diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 553a21bb6f..4a7f6eb819 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -16,6 +16,10 @@ import { join } from 'node:path' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + SESSION_FORMAT_VERSION, SessionId as sessionId, type SessionEvent, type SessionId, +} from '@deepseek-ai/dsh-session' +import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, @@ -78,6 +82,60 @@ function seedLog(): string { ].join('\n') } +/** + * Persist one child so the assembled header snapshot exercises both action + * contributors whose relative order is the product contract under test. + * @param scaffold - the booted Web scaffold. + * @param parentId - the seeded session whose header the browser opens. + */ +async function seedSubagent(scaffold: WebScaffold, parentId: SessionId): Promise { + const childId = sessionId('agent-preset-selection-child') + const createdAt = 1784974100100 + await scaffold.ctx.sessionPersistence.create({ + version: SESSION_FORMAT_VERSION, + id: childId, + createdAt, + cwd: scaffold.workspaceCwd, + parentSession: parentId, + origin: 'subagent', + delegationDepth: 1, + agentPreset: 'minimal', + }) + await scaffold.ctx.sessionPersistence.append(childId, [ + { + type: 'turn/start', + seq: 0, + time: createdAt, + data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + }, + { + type: 'user/message', + seq: 1, + time: createdAt + 1, + data: { + content: [{ type: 'text', text: 'Check the session-header action order.' }], + source: { kind: 'user' }, + }, + surfaceOp: 'append', + }, + { + type: 'subagent/descriptor', + seq: 2, + time: createdAt + 2, + data: snapshotSubagentDescriptor({ + mode: 'one-shot', provider: 'spawn', label: 'header order probe', + }), + }, + { + type: 'turn/end', + seq: 3, + time: createdAt + 3, + data: { turn: 1, reason: { kind: 'completed' } }, + }, + ] as SessionEvent[]) + await scaffold.ctx.sessionProjectionCache.coldSnapshot(childId) +} + /** * The preset the host reports for the blank session the workspace connect * produced. Addressed by id rather than by scanning the serialized list: the @@ -120,7 +178,8 @@ describe('web e2e: agent-preset selection', () => { // A resumed session runs what it was created with; seeding one that // records `minimal` is what makes the header label a claim about the // session rather than an echo of the current default. - await seedSession(scaffold, seedLog(), SEED_ID, 'minimal') + const seededId = await seedSession(scaffold, seedLog(), SEED_ID, 'minimal') + await seedSubagent(scaffold, seededId) await seedWorkspaceSkill(scaffold.workspaceCwd) browser = await chromium.launch() page = await newEnglishPage(browser) @@ -222,6 +281,8 @@ describe('web e2e: agent-preset selection', () => { await compareOrRefreshGolden(HEADER_EXPECTED, snapshot, MODE) expect(snapshot).toContain('Minimal mode') + expect(snapshot).toContain('button "1 subagent"') + expect(snapshot.indexOf('Minimal mode')).toBeLessThan(snapshot.indexOf('button "1 subagent"')) // Static chrome, not a control: the header can only report a composition // the host would refuse to change. expect(snapshot).not.toContain('button "Minimal mode"') diff --git a/apps/web/tests/snapshots/agent-preset-selection/header.expected.md b/apps/web/tests/snapshots/agent-preset-selection/header.expected.md index d627a21945..5a78c6461e 100644 --- a/apps/web/tests/snapshots/agent-preset-selection/header.expected.md +++ b/apps/web/tests/snapshots/agent-preset-selection/header.expected.md @@ -2,3 +2,6 @@ - button "Seeded turn" [disabled] - img - text: Minimal mode +- button "1 subagent": + - text: 1 subagent + - img diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index a7377027a2..ca91d9f97e 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/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-agent-preset/README.md -README.md: c4c7df4e6fbe0479cac4767247c1b10fd65aad77 -README.zh.md: 84c02977ec18f89c06311b570428f92c2b459fb3 +README.md: 008066114e9c49e5c74299979e24c27a4c9621c9 +README.zh.md: e07d5994ae196cd03be7818fe4ade1aafda9aa55 diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index c4c7df4e6f..008066114e 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -18,7 +18,7 @@ A session that has started is refused rather than queued: the host answers `agen ## The session-header label -A third surface, beside the session title: the preset THIS session runs, as static chrome. It precedes the subagent catalog in the header action row. A control there would promise a switch the host refuses outright. It reads the preset from the session's own summary — a resumed session runs what it was created with, not today's default — and resolves the display name against the same roster the General row reads. +A third surface, beside the session title: the preset THIS session runs, as static chrome. A control there would promise a switch the host refuses outright. It reads the preset from the session's own summary — a resumed session runs what it was created with, not today's default — and resolves the display name against the same roster the General row reads. ## What it reads and writes diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index 84c02977ec..e07d5994ae 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -18,7 +18,7 @@ chip 以部署默认值打开,其选择是**暂存**的——该界面先于 ## 会话标题旁的标签 -第三个表层,位于会话标题旁:**本会话**所运行的 preset,作为静态装饰呈现。它在头部操作行中排在 subagent 列表之前。在那里放一个控件,等于承诺一次宿主会断然拒绝的切换。它从会话自身的摘要读取 preset——被恢复的会话运行的是它创建时的那一份,而非今天的默认值——并在 General 行所读的同一份名单上解析显示名称。 +第三个表层,位于会话标题旁:**本会话**所运行的 preset,作为静态装饰呈现。在那里放一个控件,等于承诺一次宿主会断然拒绝的切换。它从会话自身的摘要读取 preset——被恢复的会话运行的是它创建时的那一份,而非今天的默认值——并在 General 行所读的同一份名单上解析显示名称。 ## 它读什么、写什么 diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index 0737992337..913639e9ab 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -157,7 +157,8 @@ export function apply(ctx: ClientContext): void { const label = scope.slots.register({ name: 'conversation.session.header.actions', id: 'agent-preset', - order: 0, + // Static session context occupies the header's leading negative-order band. + order: -10, locale: 'settings.agentPreset', inject: labelInjected, }, AgentPresetLabel) diff --git a/packages/client/ui-agent-preset/tests/apply.spec.ts b/packages/client/ui-agent-preset/tests/apply.spec.ts index 4a6f58075f..23e1944948 100644 --- a/packages/client/ui-agent-preset/tests/apply.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.spec.ts @@ -304,7 +304,7 @@ describe('ui-agent-preset apply', () => { expect(chip.component).toBe(AgentPresetSeat) const label = slots.entries('conversation.session.header.actions')[0]! expect(label.component).toBe(AgentPresetLabel) - expect(label.options).toMatchObject({ id: 'agent-preset', order: 0 }) + expect(label.options).toMatchObject({ id: 'agent-preset', order: -10 }) await fiber.dispose() expect(slots.entries('conversation.hero.agentPreset')).toHaveLength(0) expect(slots.entries('conversation.session.header.actions')).toHaveLength(0) diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index c0749fcb46..bbca9254bc 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -38,7 +38,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { 'conversation.session': { kind: 'single'; scope: 'session' } /** Strict-session header above the resident conversation scrollport. */ 'conversation.session.header': { kind: 'single'; scope: 'session' } - /** Session-header actions contributed by feature plugins. */ + /** + * Session-header actions contributed by feature plugins. Entries render + * by ascending `order`; negative values are reserved for static session + * context that precedes interactive actions. + */ 'conversation.session.header.actions': { kind: 'list'; scope: 'session'; owner: ConversationHeaderActionOwnerProps } /** * The conversation view ring: one list entry per view tab (chat here; From 7ca87515847acceff2a3f4ef9eb12e0f9e556a94 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 20:20:31 +0800 Subject: [PATCH 21/25] test(web): refresh localized preset snapshots --- apps/web/tests/snapshots/code-mode-round/ui.expected.md | 2 +- apps/web/tests/snapshots/cordis-tool-round/ui.expected.md | 2 +- apps/web/tests/snapshots/fresh-round-trip/ui.expected.md | 2 +- .../tests/snapshots/goal-multi-turn-actions/ui.expected.md | 2 +- apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md | 4 ++-- .../tests/snapshots/lifecycle-chrome/plan-active.expected.md | 4 ++-- .../web/tests/snapshots/lifecycle-chrome/reloaded.expected.md | 2 +- apps/web/tests/snapshots/live-interactions/cancel.expected.md | 2 +- .../tests/snapshots/live-interactions/error-auth.expected.md | 2 +- .../web/tests/snapshots/live-interactions/loading.expected.md | 2 +- apps/web/tests/snapshots/live-interactions/retry.expected.md | 2 +- apps/web/tests/snapshots/plan-review/approved.expected.md | 2 +- .../tests/snapshots/question-composer/answered.expected.md | 2 +- apps/web/tests/snapshots/queue-actions/collapsed.expected.md | 2 +- apps/web/tests/snapshots/queue-actions/editing.expected.md | 2 +- apps/web/tests/snapshots/queue-actions/layout.expected.md | 2 +- apps/web/tests/snapshots/queue-actions/preserved.expected.md | 2 +- apps/web/tests/snapshots/queue-actions/ui.expected.md | 2 +- apps/web/tests/snapshots/skill-user-invoke/ui.expected.md | 2 +- apps/web/tests/snapshots/steer-all/mid-steer.expected.md | 2 +- apps/web/tests/snapshots/steer-all/settled.expected.md | 2 +- apps/web/tests/snapshots/steering/mid-steer.expected.md | 2 +- apps/web/tests/snapshots/steering/settled.expected.md | 2 +- .../web/tests/snapshots/turn-tail-actions/running.expected.md | 2 +- .../web/tests/snapshots/turn-tail-actions/settled.expected.md | 2 +- apps/web/tests/snapshots/web-search-round/ui.expected.md | 2 +- apps/web/tests/steering.e2e.ts | 2 +- 27 files changed, 29 insertions(+), 29 deletions(-) diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index f426539e87..9d9ea8ab0e 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - 'button "Using ONE run_code program: run" [disabled]' - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index 2bc6f76a93..42197364ae 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Use only Cordis tools. First" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index 0529e000b7..d360f936bd 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Use the bash tool to" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md b/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md index fa178e30d8..c0ece71be8 100644 --- a/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md +++ b/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "workspace" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index 223006d59d..c666681b0b 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -25,9 +25,9 @@ - img - text: workspace - img -- button "标准模式": +- button "Standard mode": - img - - text: 标准模式 + - text: Standard mode - img - textbox "Describe what you want to build" - button "Commands": diff --git a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md index a234028a16..2f9c701936 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md @@ -25,9 +25,9 @@ - img - text: workspace - img -- button "标准模式": +- button "Standard mode": - img - - text: 标准模式 + - text: Standard mode - img - textbox "Describe what you want to build" - button "Commands": diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index 149d8ce3f9..19283ae51d 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Reply with the single word" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index 1d87f01525..4fa0394693 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Reply with a one-sentence description" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index 94d739baf1..83be4dc961 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Reply with a one-sentence description" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/live-interactions/loading.expected.md b/apps/web/tests/snapshots/live-interactions/loading.expected.md index c461dd985a..c475c05e05 100644 --- a/apps/web/tests/snapshots/live-interactions/loading.expected.md +++ b/apps/web/tests/snapshots/live-interactions/loading.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Reply with a one-sentence description" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index 70a9e69e95..7bb61c5b27 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Reply with a one-sentence description" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md index cdd844062a..23664b4a1d 100644 --- a/apps/web/tests/snapshots/plan-review/approved.expected.md +++ b/apps/web/tests/snapshots/plan-review/approved.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - 'button "Plan a small change: add" [disabled]' - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index 32e3e4bc75..d15b2af3a1 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md index d039c3d6d6..5438f084ae 100644 --- a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md +++ b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Reply with a one-sentence description" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index 453b83e20e..186f0b0169 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Reply with a one-sentence description" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/queue-actions/layout.expected.md b/apps/web/tests/snapshots/queue-actions/layout.expected.md index 64451ab4ce..9996bdcd0b 100644 --- a/apps/web/tests/snapshots/queue-actions/layout.expected.md +++ b/apps/web/tests/snapshots/queue-actions/layout.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "workspace" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/queue-actions/preserved.expected.md b/apps/web/tests/snapshots/queue-actions/preserved.expected.md index a845590873..54410743dc 100644 --- a/apps/web/tests/snapshots/queue-actions/preserved.expected.md +++ b/apps/web/tests/snapshots/queue-actions/preserved.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Reply with a one-sentence description" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index 386b3e9889..1467c50d97 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Reply with a one-sentence description" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md b/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md index 63bd2ef401..1f1cd0ea18 100644 --- a/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md +++ b/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "/user-invoke-demo and confirm the fixtur" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md index 8b77a77a0c..ce2d4a66e6 100644 --- a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/steer-all/settled.expected.md b/apps/web/tests/snapshots/steer-all/settled.expected.md index 0899529a09..b20e590686 100644 --- a/apps/web/tests/snapshots/steer-all/settled.expected.md +++ b/apps/web/tests/snapshots/steer-all/settled.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index 5fd1fccaaf..30d4d7ac4b 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index cba73282f3..93c311cce4 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/turn-tail-actions/running.expected.md b/apps/web/tests/snapshots/turn-tail-actions/running.expected.md index b1a32406de..8e5d4c5858 100644 --- a/apps/web/tests/snapshots/turn-tail-actions/running.expected.md +++ b/apps/web/tests/snapshots/turn-tail-actions/running.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Begin your reply with the" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/turn-tail-actions/settled.expected.md b/apps/web/tests/snapshots/turn-tail-actions/settled.expected.md index 32ea9a9b1e..47203f70eb 100644 --- a/apps/web/tests/snapshots/turn-tail-actions/settled.expected.md +++ b/apps/web/tests/snapshots/turn-tail-actions/settled.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Begin your reply with the" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/web-search-round/ui.expected.md b/apps/web/tests/snapshots/web-search-round/ui.expected.md index b24385d48f..9e86cdcf2c 100644 --- a/apps/web/tests/snapshots/web-search-round/ui.expected.md +++ b/apps/web/tests/snapshots/web-search-round/ui.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Use web_search to search exactly" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index a527081fbe..01b3a85b56 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -314,7 +314,7 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => { await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) - await page.getByText('标准模式', { exact: true }).waitFor({ timeout: 10_000 }) + await page.getByText('Standard mode', { exact: true }).waitFor({ timeout: 10_000 }) }, 120_000) afterAll(async () => { From 43f3324a7beaa7ef3de8e7fa86fdb3ff3841febc Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 20:34:45 +0800 Subject: [PATCH 22/25] fix(tools): restrict what a scope inherits, not just the global layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A restriction was compiled against the global tool layer alone: only global-layer tools were tested against `admits()`, and every chain-layer tool was overlaid unfiltered afterward. That read the exempt set as "the global layer" when what it means is "what this scope registers itself" — two descriptions of the same set only while every model-facing tool sat in the host composition. Moving those rows onto the agent plane separated them. A preset's tools are an ANCESTOR contribution to a joined agent, so a subagent's `toolFilter` stopped constraining anything it was given; and with the global layer empty `restrict()` rejected every name it received as unknown, failing the child outright. With the same tools in the global layer the filter still admits and applies normally, which is what makes this a regression of the move rather than a standing limitation. `view()` now filters everything a scope inherits — the global layer and every ancestor layer on its chain — and exempts only the layer the scope owns. That exemption is load-bearing rather than incidental: the delegation runtime registers a child's `report` and structured-output tools into the child's own layer, and a filter naming the capabilities the child may use must not strip the machinery it answers through. Tool order, and with it prefix-cache reuse, is unchanged: inherited names keep their global-then- ancestor position and own-layer names still come last. The diagnostic said "unknown global tool" while listing what is really the inherited surface; it now names the surface it checks and says why an own-layer name is not restrictable. Fixes #2185 --- ...-agents-join-their-parent-preset.i18n.yaml | 4 +- ...0-child-agents-join-their-parent-preset.md | 12 ++- ...hild-agents-join-their-parent-preset.zh.md | 12 ++- docs/subsystems/tools.i18n.yaml | 4 +- docs/subsystems/tools.md | 15 ++-- docs/subsystems/tools.zh.md | 15 ++-- packages/core/tools/README.i18n.yaml | 4 +- packages/core/tools/README.md | 2 +- packages/core/tools/README.zh.md | 2 +- packages/core/tools/src/index.ts | 59 ++++++++++----- packages/core/tools/tests/scoped.spec.ts | 75 +++++++++++++++++-- .../tests/preset-inheritance.spec.ts | 15 ++++ .../tests/subagent-inprocess.spec.ts | 2 +- .../tests/subagent-spawn.spec.ts | 2 +- 14 files changed, 170 insertions(+), 53 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml index 34697cd123..351632fcc5 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.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 .agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md -2026-08-10-child-agents-join-their-parent-preset.md: d9aa0dc43c1338d3198f5335da6ad238730d58a1 -2026-08-10-child-agents-join-their-parent-preset.zh.md: dd85c642ff7e6e2934e805c2efaccdc6dda63f15 +2026-08-10-child-agents-join-their-parent-preset.md: 4534004ad54df69822872b9595a29443fc3a990b +2026-08-10-child-agents-join-their-parent-preset.zh.md: bdf9928bea4b75e2915c8adf5c15f8a01c6583e4 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md index d9aa0dc43c..4534004ad5 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md @@ -22,6 +22,8 @@ This is a bind, not a mount, and both differences are load-bearing. The child ge `dsh-subagent` reaches the roster through `ctx.get('agentPresets')` with a type-only import and an optional peer dependency — the documented opportunistic-consumption pattern it already uses for `sandboxPolicy` and `approval`. +Giving the child its parent's tools exposed a second defect the same agent-plane move introduced: `ToolRegistry` exempted SCOPED registrations from a restriction and filtered only the global layer, so once every model-facing row became an ancestor contribution, a child's `toolFilter` stopped constraining anything — and, with the global layer empty, `restrict()` rejected every name it was given as unknown, failing the child outright. The exempt set is the tools a scope registers ITSELF, not the tools that happen to live in the global layer; reading it the second way held only while those two sets coincided. `view()` now filters everything a scope inherits — the global layer and every ancestor layer — and exempts only its own. The own-layer exemption is load-bearing rather than incidental: the delegation runtime registers a child's `report` and structured-output tools into the child's own layer, and a filter naming the capabilities the child may use must not strip the machinery it answers through. + ## Alternatives considered **Re-mount the parent's preset by id in the child's setup.** Rejected on both semantics and mechanics. It re-reads the roster and re-stats the composition file, so an edit since the parent started forks the child onto a different generation, and a preset deleted since fails the child while its parent runs on. `mount()` is also asynchronous, which the synchronous creation windows cannot accept without restructuring both drivers. @@ -32,22 +34,26 @@ This is a bind, not a mount, and both differences are load-bearing. The child ge **Let `dsh-subagent` import `resolveSessionPreset` and mount by the resolved id.** Rejected because it makes the preset roster a hard module edge for a package that must work without one, and it lands back on the remount semantics above. +**Filter every layer on the chain, including the scope's own.** Rejected because it makes a per-child capability filter delete that child's reporting and structured-output tools, which the delegation runtime registers into the child's own layer — an `allow` naming the capabilities a child may use would leave it unable to answer at all. + **Leave the durable header alone and fix only the live join.** Rejected because the live child and the same child read cold would then disagree about which composition produced its history — the same class of defect, moved rather than fixed. ## Testing `packages/preset/agent-presets/tests/mount.spec.ts` covers the join against real fixture compositions: the child sees its parent's tools and prompt sections, no second generation is mounted, the join survives the parent's disposal (a background child outliving its parent), the reported id matches, a parent without a preset joins nothing, and an unscoped context is refused. -`packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts` asserts the model-visible result through `startInProcessRun()` on a host composition carrying no model-facing rows: the schemas in the child's own request, its parent's prompt section, the recorded header preset, and a parent that switched preset while blank — to a DIFFERENT preset, so the assertion distinguishes reading the parent's live scope chain from reading its creation header. +`packages/core/tools/tests/scoped.spec.ts` covers the restriction rule directly: a child's filter removes a tool it inherited from an ancestor scope, the child's own registrations survive its own filter, and an ancestor's restriction still reaches every scope nested inside it. + +`packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts` asserts the model-visible result through `startInProcessRun()` on a host composition carrying no model-facing rows: the schemas in the child's own request, its parent's prompt section, the recorded header preset, a `toolFilter` applied over the inherited preset tools, and a parent that switched preset while blank — to a DIFFERENT preset, so the assertion distinguishes reading the parent's live scope chain from reading its creation header. The assembled-transcript layer is the shipped Web composition's e2e rather than a keyless snapshot. Every runnable example this repo ships composes no preset roster, so the defect is not observable in the snapshot harness at all: a snapshot scenario would first need an example that mounts a roster AND delegates. The Web e2e boots the real `base` + `web-app` patch layers with both shipped presets, which is the assembled evidence the testing policy asks for; the Web browser lane's subagent goldens carry the visible consequence, since a child that records its preset now shows the preset badge its parent shows. ## Consequences -Delegation now costs a scope-parent bind per child and nothing else — no extra plugin instances, no roster read, no failure mode. A child's capabilities are exactly its parent's — the per-child `toolFilter` does not narrow them, for the separately tracked reason below; a per-subagent preset ("agent types") remains unbuilt and would be a new request field rather than a change to this join. +Delegation now costs a scope-parent bind per child and nothing else — no extra plugin instances, no roster read, no failure mode. A child's capabilities are exactly its parent's, minus whatever its own `toolFilter` removes; a per-subagent preset ("agent types") remains unbuilt and would be a new request field rather than a change to this join. `applyChildComposition()` changed shape, so any future out-of-tree in-process driver must supply the parent. That is the intended cost: the previous signature let a caller compose a capability-less child and get no error. A cold-resumed continuable child joins its parent's CURRENT composition rather than the one its own header records. The window is narrow — the parent must create the child, stay blank, switch preset, and only then wake it, since a resident child never re-joins and a one-shot child never resumes — and the alternative is worse: resolving the child's own recorded id would re-read the roster and hand back the preset-deleted failure mode this join exists to avoid. The child's header still records what it started under, so the divergence is observable rather than silent. -`toolFilter` does not constrain a joined child, because `ToolRegistry` compiles restrictions against global-layer names only and overlays chain-layer tools unfiltered. That is not new here — with the roster composed, `tools.restrict()` already rejected every name as an unknown global tool, so a child carrying a filter failed to start both before and after this change — but it is a regression from the agent-plane move rather than a standing limitation: with the same tools registered in the global layer, the filter admits and applies normally. It matters more now that the child has its parent's full tool set to be restricted from. It is tracked separately; this change neither introduces nor repairs it. +`ToolRegistry` now reads a restriction's exempt set as "what this scope registers itself" rather than "the global layer", which changes one documented behavior beyond delegation: a tool an ANCESTOR scope contributes is now subject to a descendant's filter, where before only global-layer tools were. Nothing else on the chain loses its exemption — a scope's own registrations stay outside its own filter, which is the property the delegation runtime depends on. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md index dd85c642ff..bdf9928bea 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md @@ -22,6 +22,8 @@ Status: implemented `dsh-subagent` 以类型级导入加可选 peer 依赖的方式,通过 `ctx.get('agentPresets')` 触达 roster——这正是它对 `sandboxPolicy` 与 `approval` 已在使用的、有明确文档的机会性消费模式。 +把父方的工具交给子 agent 之后,暴露出同一次 agent 平面搬迁引入的第二个缺陷:`ToolRegistry` 把**作用域级**注册排除在限制之外、只过滤全局层,因此当所有面向模型的行都变成祖先贡献之后,子 agent 的 `toolFilter` 就不再约束任何东西——而且全局层为空时,`restrict()` 会把收到的每个名字都判为未知并直接让子 agent 创建失败。豁免集合应当是作用域**自己注册**的工具,而不是恰好位于全局层的工具;后一种读法只在这两个集合重合时才成立。`view()` 现在过滤作用域继承来的一切——全局层与每个祖先层——只豁免它自己那层。这条自身层豁免是承重的而非顺带的:委派运行时把子 agent 的 `report` 与结构化输出工具注册进子 agent 自己那层,而一个只点名子 agent 可用能力的过滤器绝不能把它回报所依赖的机制一并剥掉。 + ## Alternatives considered **在子 agent 的 setup 里按 id 重新挂载父方的 preset。** 语义与机制两方面都不成立而被否决。它会重读 roster 并重新 stat 组装文件,因此父方启动后的一次编辑就会把子 agent 分叉到另一个代际,而此后被删除的 preset 会让子 agent 失败、父方却照常运行。`mount()` 还是异步的,同步的创建窗口无法在不重构两个驱动的前提下接受它。 @@ -32,22 +34,26 @@ Status: implemented **让 `dsh-subagent` 导入 `resolveSessionPreset` 并按解析出的 id 挂载。** 否决,因为这会给一个必须在没有 roster 时也能工作的包引入硬模块边,而且最终仍落回上述的重新挂载语义。 +**过滤链上的每一层,包括作用域自身那层。** 否决,因为那会让逐子 agent 的能力过滤器把该子 agent 的回报与结构化输出工具一并删掉——它们由委派运行时注册进子 agent 自己那层——于是一个点名"子 agent 可用哪些能力"的 `allow` 会让它彻底无法回报。 + **只修活着的加入,不动持久化 header。** 否决,因为那样活着的子 agent 与冷读同一个子 agent 会对"哪份组装产出了这段历史"给出不同答案——同一类缺陷,只是被搬了个地方而不是被修掉。 ## Testing `packages/preset/agent-presets/tests/mount.spec.ts` 用真实 fixture 组装覆盖该加入:子 agent 看到父方的工具与提示段、不会挂载出第二个代际、加入在父方 dispose 后依然成立(活得比父方久的后台子 agent)、上报的 id 一致、没有 preset 的父方不产生加入、以及无 scope 的上下文被拒绝。 -`packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts` 在一个不含任何面向模型行的宿主组装上,通过 `startInProcessRun()` 断言模型可见的结果:子 agent 自身请求中的 schema、父方的提示段、记录下来的 header preset,以及在空白期切换过 preset 的父方——切换到**另一个** preset,这样断言才能区分"读父方活 scope 链"与"读父方创建 header"。 +`packages/core/tools/tests/scoped.spec.ts` 直接覆盖该限制规则:子 agent 的过滤器能移除它从祖先作用域继承来的工具、子 agent 自身的注册在自己的过滤器下存活、祖先的限制仍作用于其内嵌套的每个作用域。 + +`packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts` 在一个不含任何面向模型行的宿主组装上,通过 `startInProcessRun()` 断言模型可见的结果:子 agent 自身请求中的 schema、父方的提示段、记录下来的 header preset、施加在继承来的 preset 工具之上的 `toolFilter`,以及在空白期切换过 preset 的父方——切换到**另一个** preset,这样断言才能区分"读父方活 scope 链"与"读父方创建 header"。 组装记录这一层用的是真实 shipped Web 组装的 e2e,而不是无密钥快照。本仓库所有可运行 example 都不组装 preset roster,因此该缺陷在快照 harness 里根本不可观察:要做快照场景,得先有一个既挂载 roster 又发起委派的 example。Web e2e 启动的是真实的 `base` + `web-app` 补丁层与两个 shipped preset,这正是测试政策要求的组装证据;Web 浏览器 lane 的 subagent golden 承载了可见后果——记录了 preset 的子 agent 现在会显示与其父方相同的 preset 徽标。 ## Consequences -委派现在的成本是每个子 agent 一次 scope 认父,再无其他——没有额外的插件实例、没有 roster 读取、没有新的失败模式。子 agent 的能力恰好等于父方的能力——逐子 agent 的 `toolFilter` 并不能收窄它,原因见下方另行跟踪的那条;逐 subagent 的 preset("agent 类型")仍未构建,那会是一个新的请求字段,而不是对这次加入的改动。 +委派现在的成本是每个子 agent 一次 scope 认父,再无其他——没有额外的插件实例、没有 roster 读取、没有新的失败模式。子 agent 的能力恰好等于父方的能力,减去它自己的 `toolFilter` 所移除的部分;逐 subagent 的 preset("agent 类型")仍未构建,那会是一个新的请求字段,而不是对这次加入的改动。 `applyChildComposition()` 的形态变了,因此将来任何仓库外的进程内驱动都必须提供父方。这是刻意付出的代价:此前的签名允许调用方组装出一个毫无能力的子 agent 而不报任何错。 冷恢复的可继续子 agent 加入的是父方**当前**的组装,而不是它自己 header 所记录的那份。窗口很窄——父方必须先建子、保持空白、切换 preset,之后才唤醒它;驻留中的子 agent 不会重新加入,一次性子 agent 也不会恢复——而替代方案更糟:按子 agent 自己记录的 id 解析会重读 roster,把这次认父刻意规避掉的"preset 已删除"失败模式又请回来。子 agent 的 header 仍记录它启动时的那份,因此这处分歧是可观察的而非静默的。 -`toolFilter` 约束不住已加入组装的子 agent,因为 `ToolRegistry` 只按全局层的名字编译限制,随后把 scope 链上的工具无过滤地叠加进来。这不是本次改动带来的——在组装了 roster 的部署里,`tools.restrict()` 本就把每个名字都判为未知全局工具,因此带过滤器的子 agent 在本次改动前后同样起不来——但它是搬到 agent 平面所引入的回归,而非长期存在的限制:同样这批工具注册在全局层时,过滤器能正常校验并生效。现在子 agent 有了父方的全套工具需要被限制,它变得更要紧。该问题另行跟踪;本次改动既未引入也未修复它。 +`ToolRegistry` 现在把限制的豁免集合读作"该作用域自己注册的东西"而不是"全局层",这在委派之外改变了一处既有行为:**祖先**作用域贡献的工具现在会受后代过滤器约束,而此前只有全局层的工具会。链上其余部分的豁免不变——作用域自身的注册仍在自己的过滤器之外,这正是委派运行时所依赖的性质。 diff --git a/docs/subsystems/tools.i18n.yaml b/docs/subsystems/tools.i18n.yaml index 3f7e33a795..fbf617f20a 100644 --- a/docs/subsystems/tools.i18n.yaml +++ b/docs/subsystems/tools.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 docs/subsystems/tools.md -tools.md: f8d86704a2237219530c8c23b46a68383458e1cf -tools.zh.md: 87269e5532b0cdfb0a38501d7b98c9986665df1d +tools.md: 6ff2d967c5631d096dd78236ffd0383ddb2b0493 +tools.zh.md: 82ade5d8d4117387138296cf54fbc8e88ad335e7 diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index f8d86704a2..6ff2d967c5 100644 --- a/docs/subsystems/tools.md +++ b/docs/subsystems/tools.md @@ -150,19 +150,20 @@ type InferArgs = InferProperties Registration is a trusted same-process contract. The registry borrows the typed definition as readonly input, requires `output`, validates its raw schema, and checks semantic requirements such as a positive finite `timeoutMs`; `schemas()` constructs the model-facing projection when building a request, so execution and presentation share one resolved definition without leaking callbacks onto the wire. -## `ToolRestriction` — one scope's live global filter +## `ToolRestriction` — one scope's live filter over what it inherits -`ToolRestriction` applies only to the live deployment-global tool layer. The registry compiles readonly names into private sets, intersects multiple restrictions, then overlays scope-local tools. A deny-only filter admits later unlisted globals, while an allow-list excludes them. +`ToolRestriction` applies to the tools a scope inherits: the deployment-global layer plus every ancestor scope on its chain. The registry compiles readonly names into private sets, intersects multiple restrictions, then overlays the scope's OWN registrations, which stay exempt so a delegated child keeps the tools it answers through. A deny-only filter admits later unlisted inherited tools, while an allow-list excludes them. ```ts type-equiv /** - * Per-scope filter over global tools. Restrictions intersect and do not affect - * scoped registrations or the reserved Code Mode transport. + * Per-scope filter over the tools a scope INHERITS — the global layer and + * every ancestor layer on its chain. Restrictions intersect, and do not affect + * the scope's own registrations or the reserved Code Mode transport. */ interface ToolRestriction { - /** Global tool names that stay visible; everything else is removed. */ + /** Inherited tool names that stay visible; every other inherited one is removed. */ readonly allow?: readonly string[] - /** Global tool names removed from visibility. */ + /** Inherited tool names removed from visibility. */ readonly deny?: readonly string[] } ``` @@ -565,7 +566,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](scope.md) -Source: [`packages/core/tools/src/index.ts:760`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:761`](../../packages/core/tools/src/index.ts) diff --git a/docs/subsystems/tools.zh.md b/docs/subsystems/tools.zh.md index 87269e5532..82ade5d8d4 100644 --- a/docs/subsystems/tools.zh.md +++ b/docs/subsystems/tools.zh.md @@ -150,19 +150,20 @@ type InferArgs = InferProperties 注册是一项受信任的同进程约定。注册表以 readonly 输入借用已类型化定义,要求它声明 `output`,校验其原始 schema,并检查 `timeoutMs` 必须为正有限值等语义要求;`schemas()` 在构建请求时生成面向模型的投影,使执行和展示共享同一份已解析定义,而不会将回调泄漏到协议上。 -## `ToolRestriction` — 单个作用域的实时全局过滤器 +## `ToolRestriction` — 单个作用域对其继承内容的实时过滤器 -`ToolRestriction` 仅作用于实时的部署全局工具层。注册表将 readonly 名称编译为私有集合,对多个限制取交集,再叠加作用域本地工具。仅 deny 的过滤器允许后续未列出的全局工具通过,而 allow 列表则排除它们。 +`ToolRestriction` 作用于该作用域继承来的工具:部署全局层,加上其链上的每个祖先作用域。注册表将 readonly 名称编译为私有集合,对多个限制取交集,再叠加该作用域**自身**的注册——后者不受约束,因此被委派的子 agent 会保留其回报所依赖的工具。仅 deny 的过滤器允许后续未列出的继承工具通过,而 allow 列表则排除它们。 ```ts type-equiv /** - * Per-scope filter over global tools. Restrictions intersect and do not affect - * scoped registrations or the reserved Code Mode transport. + * Per-scope filter over the tools a scope INHERITS — the global layer and + * every ancestor layer on its chain. Restrictions intersect, and do not affect + * the scope's own registrations or the reserved Code Mode transport. */ interface ToolRestriction { - /** Global tool names that stay visible; everything else is removed. */ + /** Inherited tool names that stay visible; every other inherited one is removed. */ readonly allow?: readonly string[] - /** Global tool names removed from visibility. */ + /** Inherited tool names removed from visibility. */ readonly deny?: readonly string[] } ``` @@ -565,7 +566,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](scope.md) -Source: [`packages/core/tools/src/index.ts:760`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:761`](../../packages/core/tools/src/index.ts) diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index 0c5e1fee44..e3a9b36a95 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/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/core/tools/README.md -README.md: 2c9833c3505c765283559590c8bc28b3c2077e2e -README.zh.md: d7766b432c5a319d214da80e3df438489519be92 +README.md: 21851ca887147364c76612bae2e6a00ebdccec39 +README.zh.md: aec3b434e52f473001505bbea5212d5e247eb46f diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 2c9833c350..21851ca887 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -19,7 +19,7 @@ tools: - `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition with a mandatory canonical `output` declaration. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Missing or unsupported output declarations and a non-positive or non-finite `timeoutMs` fail at registration. The optional synchronous `finalizeContent` callback is snapshotted when a call starts and may replace only final model-facing content after every pipeline outcome is normalized, including an error discovered while materializing another result field. Disposed with the calling fiber. - `ctx.tools.presentAs(mode: ToolPresentationMode): () => void` selects this agent's model-facing presentation, shadowing the `mode` config for that agent alone; it throws from a plain context (a process-wide presentation is the config field) and from a second declaration in the same scope. A code mode also registers that agent's own `tools:sdk` section. The catalog is unchanged — `schemas(agent)` still reports the agent's capabilities; only the assembly's tools collapse. Disposed with the calling fiber. -- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to global tools and throws from a plain context. The filter is snapshotted at registration; multiple masks intersect and scope-local tools merge afterwards. Deny masks admit later unnamed globals, while allow masks exclude later names. Unknown, local, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals). +- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to the tools that scope INHERITS — the global layer and every ancestor scope on its chain — and throws from a plain context. The scope's OWN registrations are exempt and merge afterwards, which is what keeps a delegated child's reporting and structured-output tools alive under a filter naming only the capabilities it may use. The filter is snapshotted at registration; multiple masks intersect, and a mask on an ancestor reaches every scope nested inside it. Deny masks admit later unnamed inherited tools, while allow masks exclude later names. Unknown, own-layer, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals). - `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed. - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md)). - `ctx.tools.guard(guard: ToolGuard): () => void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber. diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index d7766b432c..aec3b434e5 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -19,7 +19,7 @@ tools: - `ctx.tools.register(definition: ToolDefinition): () => void`:注册一个受信任、带类型的同进程定义,其中必须包含规范的 `output` 声明。所在层由调用上下文的作用域决定:普通插件上下文会全局注册;agent 的 `agent.ctx` 只为该 agent 注册,并在此处遮蔽同名全局工具。同一层内名称重复会抛出;非原生模式还会拒绝保留的 `run_code` 传输名称。缺失或不受支持的输出声明,以及非正数或非有限的 `timeoutMs`,都会使注册失败。可选的同步 `finalizeContent` 回调会在调用开始时创建快照;在所有流水线结果规范化之后,它只能替换最终面向模型的内容,包括实体化其他结果字段时发现的错误。随调用 fiber dispose(资源释放)。 - `ctx.tools.presentAs(mode: ToolPresentationMode): () => void`:为本 agent 选择面向模型的呈现方式,仅对该 agent 遮蔽 `mode` 配置;从普通上下文调用会抛出(进程级呈现方式是那个配置字段),同一 scope 内第二次声明也会抛出。code 类模式还会为该 agent 注册它自己的 `tools:sdk` 段。清单本身不变——`schemas(agent)` 报告的仍是该 agent 的能力,坍缩的只是 assembly 里的工具。随调用方 fiber 一同释放。 -- `ctx.tools.restrict(filter)`:对全局工具应用 agent 作用域的允许/拒绝掩码;从普通上下文调用会抛出。筛选器在注册时创建快照;多个掩码取交集,随后再合并作用域本地工具。拒绝掩码会接纳后来出现且未点名的全局工具,而允许掩码会排除后来出现的名称。未知、本地或保留名称以及空筛选器都会被拒绝。这是实时可见性组合,不是权限边界;参见[作用域安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。 +- `ctx.tools.restrict(filter)`:对该作用域**继承来的**工具——全局层以及其链上的每个祖先作用域——应用 agent 作用域的允许/拒绝掩码;从普通上下文调用会抛出。作用域**自身**的注册不受掩码约束,并在其后合并进来,这正是让被委派子 agent 的回报与结构化输出工具能在只点名其可用能力的筛选器下存活的机制。筛选器在注册时创建快照;多个掩码取交集,祖先上的掩码作用于其内嵌套的每个作用域。拒绝掩码会接纳后来出现且未点名的继承工具,而允许掩码会排除后来出现的名称。未知、自身层或保留名称以及空筛选器都会被拒绝。这是实时可见性组合,不是权限边界;参见[作用域安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。 - `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined`:按某个作用域所见的结果解析(应用遮蔽;被限制掉的全局工具视为不存在)。呈现器会传入发起调用的 agent,使卡片与实际执行内容一致。 - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]`:返回该作用域可见的所有 schema(不含 `execute` 函数)。已交付工具的 schema 收录在 [docs/tool-catalog.md](../../../docs/tool-catalog.md) 中;该目录通过启动每个工具插件并采集此方法的结果生成(参见[工具 schema 目录 Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md))。 - `ctx.tools.guard(guard: ToolGuard): () => void`:在 `tools/pre-execute` 之后注册单调同步执行守卫:返回理由会拒绝调用,返回 `undefined` 则保持原决定。普通上下文守卫全局生效;`agent.ctx` 守卫只对该 agent 生效。后续 waterfall(瀑布式事件)监听器无法将守卫的拒绝重新变为允许。随调用 fiber dispose。 diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index a1653e7d16..86c69d9307 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -647,13 +647,14 @@ export interface Config { } /** - * Per-scope filter over global tools. Restrictions intersect and do not affect - * scoped registrations or the reserved Code Mode transport. + * Per-scope filter over the tools a scope INHERITS — the global layer and + * every ancestor layer on its chain. Restrictions intersect, and do not affect + * the scope's own registrations or the reserved Code Mode transport. */ export interface ToolRestriction { - /** Global tool names that stay visible; everything else is removed. */ + /** Inherited tool names that stay visible; every other inherited one is removed. */ readonly allow?: readonly string[] - /** Global tool names removed from visibility. */ + /** Inherited tool names removed from visibility. */ readonly deny?: readonly string[] } @@ -669,7 +670,7 @@ interface ToolView { readonly visible: ReadonlyMap /** Pre-restriction capability names used by prompt-order validation. */ readonly knownNames: ReadonlySet - /** Current global names that a scoped restriction may name. */ + /** Current inherited names a scoped restriction may name; its own are exempt. */ readonly restrictableNames: ReadonlySet } @@ -707,7 +708,7 @@ class ToolLayer implements ScopeLayer { && this.mode === undefined } - /** Whether every compiled restriction in this layer admits a global tool name. */ + /** Whether every compiled restriction in this layer admits an inherited tool name. */ admits(name: string): boolean { for (const filter of this.restrictions.values()) { if ((filter.allow !== undefined && !filter.allow.has(name)) @@ -1029,7 +1030,7 @@ export class ToolRegistry extends Service { const known = this.view(scope).restrictableNames const unknown = [...allow ?? [], ...deny ?? []].filter(name => !known.has(name)) if (unknown.length > 0) { - throw new Error(`tools.restrict() names unknown global tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; known global tools: ${[...known].sort().join(', ') || '(none)'}`) + throw new Error(`tools.restrict() names unknown inherited tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; a restriction filters what this scope inherits, never what it registers itself. Restrictable tools: ${[...known].sort().join(', ') || '(none)'}`) } return this.layers.effect( this.ctx, @@ -1070,30 +1071,54 @@ export class ToolRegistry extends Service { /** * Resolve every registry fact one scope needs in one layer traversal. The - * visible map applies global restrictions, scoped shadowing, and the reserved - * presentation transport; the other sets retain the pre-restriction facts - * needed by restriction and prompt-order validation. + * visible map applies restrictions to the INHERITED surface, then the + * scope's own registrations and the reserved presentation transport; the + * other sets retain the pre-restriction facts needed by restriction and + * prompt-order validation. + * + * A restriction filters what a scope inherits — the global layer and every + * ancestor layer on its chain — and never what its OWN layer registers. + * That exemption is what a per-child capability filter has to keep intact: + * the delegation runtime registers a child's reporting and structured-output + * tools into the child's own layer, and a filter naming the capabilities the + * child may use must not strip the machinery it answers through. + * + * Reading the exempt set as "the global layer" instead of "not mine" held + * only while every model-facing tool sat in the host composition. Once + * presets moved them onto the agent plane they became an ANCESTOR + * contribution, so a child's filter silently stopped constraining anything + * it was given. * @param scope - the viewing scope (the agent), or undefined for the global view. * @returns the complete derived view for that scope. */ private view(scope?: ScopeKey): ToolView { // Scope-chain layers, farthest ancestor first, the exact scope last. const layers = this.layers.chainLayers(scope) + // Chain-blind on purpose: this is the ONE layer whose registrations the + // scope owns rather than inherits, and it is absent until the scope + // contributes something. + const own = this.layers.peek(scope) + // Inherited surface, nearest ancestor last: a nearer scope's same-name + // entry shadows a farther one, and the global layer is the farthest. + const inherited = new Map(this.layers.global.tools.entries()) + for (const layer of layers) { + if (layer === own) continue + for (const [name, definition] of layer.tools.entries()) inherited.set(name, definition) + } const visible = new Map() const knownNames = new Set() const restrictableNames = new Set() - for (const [name, definition] of this.layers.global.tools.entries()) { + for (const [name, definition] of inherited) { knownNames.add(name) restrictableNames.add(name) // Restrictions intersect across the whole chain: any scope on it may - // mask a global-surface name for everything nested inside it. + // mask an inherited name for everything nested inside it. if (layers.every(layer => layer.admits(name))) visible.set(name, definition) } - // Chain layers second, nearest last: same-name entries REPLACE (shadow) - // the global and farther-scope ones, and scope-local registrations are - // never part of the global filter above. - for (const layer of layers) { - for (const [name, definition] of layer.tools.entries()) { + // The scope's own registrations last, shadowing an inherited name and + // outside the filter above. + if (own !== undefined) { + for (const [name, definition] of own.tools.entries()) { knownNames.add(name) visible.set(name, definition) } diff --git a/packages/core/tools/tests/scoped.spec.ts b/packages/core/tools/tests/scoped.spec.ts index 922173653f..8f7be45b19 100644 --- a/packages/core/tools/tests/scoped.spec.ts +++ b/packages/core/tools/tests/scoped.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { Context } from 'cordis' import type { Events } from 'cordis' -import { createScope } from '@deepseek-ai/dsh-scope' +import { bindScopeParent, createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' @@ -181,21 +181,84 @@ describe('restrict()', () => { expect(ctx.tools.schemas(key).map(t => t.name)).toEqual(['b']) }) - it('fails loud on an unscoped call, an empty filter, and non-global names', async () => { + it('fails loud on an unscoped call, an empty filter, and names it does not inherit', async () => { const ctx = await mount() const { scope } = await mintAgentScope(ctx, 'a') ctx.tools.register(tool('real')) scope.ctx.tools.register(tool('local')) expect(() => ctx.tools.restrict({ deny: ['real'] })).toThrow(/requires a scoped context/) expect(() => scope.ctx.tools.restrict({})).toThrow(/no-op/) - expect(() => scope.ctx.tools.restrict({ allow: ['local'] })).toThrow(/unknown global tool "local"/) - expect(() => scope.ctx.tools.restrict({ allow: ['reall'] })).toThrow(/unknown global tool "reall"; known global tools: real/) - expect(() => scope.ctx.tools.restrict({ deny: ['ghost', 'wraith'] })).toThrow(/unknown global tools "ghost", "wraith"/) + // A scope's own registration is exempt from its own filter, so naming it + // is a caller error rather than a silent no-op. + expect(() => scope.ctx.tools.restrict({ allow: ['local'] })).toThrow(/unknown inherited tool "local"/) + expect(() => scope.ctx.tools.restrict({ allow: ['reall'] })).toThrow(/unknown inherited tool "reall".*Restrictable tools: real/s) + expect(() => scope.ctx.tools.restrict({ deny: ['ghost', 'wraith'] })).toThrow(/unknown inherited tools "ghost", "wraith"/) const emptyCtx = await mount() const { scope: emptyScope } = await mintAgentScope(emptyCtx, 'empty') expect(() => emptyScope.ctx.tools.restrict({ deny: ['ghost'] })) - .toThrow(/known global tools: \(none\)/) + .toThrow(/Restrictable tools: \(none\)/) + }) +}) + +describe('restrict() over an inherited scope layer', () => { + /** Mint a child scope parented to `parent`, as a subagent's creation window does. */ + async function mintChild(ctx: Context, parentKey: Agent, name: string): Promise<{ scope: Scope; key: Agent }> { + const key = { id: name as SessionId } as Agent + bindScopeParent(key, parentKey) + let scope!: Scope + await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, key) }, + { inject: ['tools', 'systemPrompt'] })) + return { scope, key } + } + + it('filters tools the child inherits from an ancestor scope, not only global ones', async () => { + // The shape every preset deployment has: no model-facing row in the global + // layer, all of them contributed by an ancestor scope the child joined. + const ctx = await mount() + const parent = await mintAgentScope(ctx, 'parent') + parent.scope.ctx.tools.register(tool('bash')) + parent.scope.ctx.tools.register(tool('read')) + const child = await mintChild(ctx, parent.key, 'child') + + expect(ctx.tools.schemas(child.key).map(t => t.name).sort()).toEqual(['bash', 'read']) + child.scope.ctx.tools.restrict({ deny: ['bash'] }) + + // Reading the exempt set as "the global layer" left this unfiltered, and + // the name unrestrictable in the first place. + expect(ctx.tools.schemas(child.key).map(t => t.name)).toEqual(['read']) + expect(await run(ctx, 'bash', child.key)).toBe('Error: unknown tool "bash"') + // The ancestor keeps its whole surface: a child's filter is its own. + expect(ctx.tools.schemas(parent.key).map(t => t.name).sort()).toEqual(['bash', 'read']) + }) + + it('keeps the child\'s own registrations outside its own filter', async () => { + // The delegation runtime registers a child's reporting and structured + // output tools into the child's own layer; an `allow` naming only the + // capabilities the child may use must not strip them. + const ctx = await mount() + const parent = await mintAgentScope(ctx, 'parent') + parent.scope.ctx.tools.register(tool('bash')) + parent.scope.ctx.tools.register(tool('read')) + const child = await mintChild(ctx, parent.key, 'child') + child.scope.ctx.tools.register(tool('report')) + + child.scope.ctx.tools.restrict({ allow: ['read'] }) + + expect(ctx.tools.schemas(child.key).map(t => t.name).sort()).toEqual(['read', 'report']) + expect(await run(ctx, 'report', child.key)).toBe('ran:report') + }) + + it('lets an ancestor\'s restriction reach every scope nested inside it', async () => { + const ctx = await mount() + ctx.tools.register(tool('web')) + const parent = await mintAgentScope(ctx, 'parent') + parent.scope.ctx.tools.register(tool('bash')) + const child = await mintChild(ctx, parent.key, 'child') + parent.scope.ctx.tools.restrict({ deny: ['web'] }) + + expect(ctx.tools.schemas(child.key).map(t => t.name)).toEqual(['bash']) + expect(ctx.tools.schemas(parent.key).map(t => t.name)).toEqual(['bash']) }) }) diff --git a/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts b/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts index 43061d46db..af199cd867 100644 --- a/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts @@ -103,6 +103,21 @@ describe('a child agent composed in-process', () => { await run.dispose() }) + it('honours a tool filter over the preset tools it inherited', async () => { + const { ctx, parent } = await setupPresetHost() + + const run = await startInProcessRun( + { ...spawnRequest(parent), toolFilter: { deny: ['preset_only'] } }, + {}, + ) + await run.result + + // The capability filter is the only thing bounding a delegated child, and + // every tool it can name now arrives from the preset rather than the host. + expect(ctx.tools.schemas(run.localAgent).map(schema => schema.name)).toEqual([]) + await run.dispose() + }) + it('follows a parent that switched preset while blank', async () => { const { ctx, parent } = await setupPresetHost() // A DIFFERENT preset, so the assertion below distinguishes reading the diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index d175322381..46b94f9f5a 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -298,7 +298,7 @@ describe('startInProcessRun', () => { await expect(startInProcessRun({ ...request(parent), toolFilter: { deny: ['unknown-tool'] }, - }, {})).rejects.toThrow('unknown global tool') + }, {})).rejects.toThrow('unknown inherited tool') expect(ctx.agents.list()).toHaveLength(beforeAgents) expect(ctx.sessions.list()).toHaveLength(beforeSessions) }) diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 0310c0b0b3..508d5132fb 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -436,7 +436,7 @@ describe('dsh-subagent-spawn', () => { prompt: [{ type: 'text', text: 'do X' }], parent, toolFilter: { deny: ['no_such_tool'] }, - })).rejects.toThrow(/unknown global tool "no_such_tool"/) + })).rejects.toThrow(/unknown inherited tool "no_such_tool"/) expect(ctx.agents.list().length).toBe(before) }) }) From b90cb1b0b0b5dd7c01c6af0eafd73fae78d6f4ca Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 20:55:32 +0800 Subject: [PATCH 23/25] test(web): re-record the subagent goldens against a rebuilt client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The child session now shows the preset badge its parent shows, which is the visible consequence of recording the composition it runs. The first recording of these two goldens was taken against a dist built before the master merge, so it captured the fallback Chinese label instead of the English one `newEnglishPage` pins — the web lane replays the BUILT client, and a stale build reads as a product difference. Re-recorded after `pnpm run build`. --- apps/web/tests/snapshots/subagent-conversation/ui.expected.md | 4 ++-- .../snapshots/subagent-interrupt/offline-composer.expected.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md index 4b71dfdc9c..cf22f5b566 100644 --- a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md @@ -3,11 +3,11 @@ - button "Ask a research subagent to" - text: / - button "event-sourcing researcher" [disabled] + - img + - text: Standard mode - button "1 subagent": - text: 1 subagent - img - - img - - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md b/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md index fbec36baea..7fe41a532d 100644 --- a/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md +++ b/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md @@ -4,7 +4,7 @@ - text: / - button "event-sourcing researcher" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" From 854f6623bb0334c6175df179d71d19fe345924f6 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:38:57 +0800 Subject: [PATCH 24/25] docs: rename client manifest field references --- ...19-gui-layering-and-rpc-protocol.i18n.yaml | 4 ++-- ...026-07-19-gui-layering-and-rpc-protocol.md | 8 ++++---- ...-07-19-gui-layering-and-rpc-protocol.zh.md | 8 ++++---- ...7-19-gui-web-client-architecture.i18n.yaml | 4 ++-- .../2026-07-19-gui-web-client-architecture.md | 4 ++-- ...26-07-19-gui-web-client-architecture.zh.md | 4 ++-- ...7-23-client-plugin-loading-model.i18n.yaml | 4 ++-- .../2026-07-23-client-plugin-loading-model.md | 20 +++++++++---------- ...26-07-23-client-plugin-loading-model.zh.md | 20 +++++++++---------- ...tree-boot-and-transport-layering.i18n.yaml | 4 ++-- ...config-tree-boot-and-transport-layering.md | 2 +- ...fig-tree-boot-and-transport-layering.zh.md | 2 +- ...07-24-web-session-model-selector.i18n.yaml | 4 ++-- .../2026-07-24-web-session-model-selector.md | 2 +- ...026-07-24-web-session-model-selector.zh.md | 2 +- docs/api-gateway.i18n.yaml | 4 ++-- docs/api-gateway.md | 2 +- docs/api-gateway.zh.md | 2 +- docs/capability-seams.md | 2 +- docs/cookbook/adding-a-package.i18n.yaml | 4 ++-- docs/cookbook/adding-a-package.md | 2 +- docs/cookbook/adding-a-package.zh.md | 2 +- packages/boot/app-boot/src/profile.ts | 9 ++++----- packages/bundle/web-app/cordis.patch.yml | 6 +++--- packages/client/AGENTS.md | 6 +++--- packages/client/modules/README.i18n.yaml | 4 ++-- packages/client/modules/README.md | 2 +- packages/client/modules/README.zh.md | 2 +- .../client/modules/src/client/manifest.ts | 2 +- .../client/runtime/tests/node-half.spec.ts | 2 +- packages/client/test-runtime/README.i18n.yaml | 4 ++-- packages/client/test-runtime/README.md | 2 +- packages/client/test-runtime/README.zh.md | 2 +- packages/client/test-runtime/src/index.ts | 2 +- packages/client/ui-command/src/index.ts | 2 +- packages/client/ui-deliverables/src/index.ts | 2 +- packages/client/ui-goal/src/index.ts | 2 +- packages/client/ui-model/src/index.ts | 2 +- packages/client/ui-permission/src/index.ts | 2 +- packages/client/ui-plan/src/index.ts | 2 +- .../client/ui-settings/src/client/index.ts | 2 +- packages/client/ui-skill/src/index.ts | 2 +- packages/client/ui-slash/src/index.ts | 2 +- packages/client/ui-subagent/src/index.ts | 2 +- .../client/ui-workspace/src/client/index.ts | 2 +- packages/client/ui-workspace/src/index.ts | 2 +- scripts/gen-doc-graphs.ts | 2 +- 47 files changed, 89 insertions(+), 90 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml index c7be4bbdf1..bc2d26325d 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.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 .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md -2026-07-19-gui-layering-and-rpc-protocol.md: f9c95176321496e965a95b6358d6feaa8466fe89 -2026-07-19-gui-layering-and-rpc-protocol.zh.md: 7d20c5a2662c9036382b30a96bc9973c8f0349bd +2026-07-19-gui-layering-and-rpc-protocol.md: 514deb890d4e08d465db869669078473d32fb215 +2026-07-19-gui-layering-and-rpc-protocol.zh.md: f6fa71e3dac25f48b2ad4744a0cc695417528b34 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md index f9c9517632..514deb890d 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md @@ -27,8 +27,8 @@ Directories layer as follows: - the unified backend protocol (fetch, HTTP, streaming interfaces…) — definitions and support, see the "Message protocol" sections below - `packages/client/*`: packages provide client-side capability only; every package stays single-sided. Three kinds live here (the axes are owned by the [client plugin loading note](2026-07-23-client-plugin-loading-model.md)): - **Pure libraries** (`ui-slots`, `web-react`, `ui-primitives`, plus the `loader` kernel package): ordinary root-index packages, statically bundled into the shell; the first three are seeded into the module table. - - **Static-arrival entry packages** (`connection`, `runtime`, `ui-theme`, `i18n`, `hmr`): no `dshClient` key and no browser bundle — the shell bundles their `src/client/` half and registers it with `ctx.modules`; they are governed as entries of the host-authored graph like everything else. - - **Fetch-arrival plugin packages** (`ui-layout`, `ui-sidebar`, `ui-conversation`, `ui-trajectory`): dual-entry — the root index is the node half (an empty `apply`, existing so the host Loader governs lifecycle and the web plugin registry discovers the package.json `dshClient` declaration); the implementation lives under `src/client/`, shipped as the `./client` subpath (a tsdown closure-factory bundle). Cross-plugin consumption of `/client` is type-only; value cooperation goes through cordis services. + - **Static-arrival entry packages** (`connection`, `runtime`, `ui-theme`, `i18n`, `hmr`): no `dsh.client` key and no browser bundle — the shell bundles their `src/client/` half and registers it with `ctx.modules`; they are governed as entries of the host-authored graph like everything else. + - **Fetch-arrival plugin packages** (`ui-layout`, `ui-sidebar`, `ui-conversation`, `ui-trajectory`): dual-entry — the root index is the node half (an empty `apply`, existing so the host Loader governs lifecycle and the web plugin registry discovers the package.json `dsh.client` declaration); the implementation lives under `src/client/`, shipped as the `./client` subpath (a tsdown closure-factory bundle). Cross-plugin consumption of `/client` is type-only; value cooperation goes through cordis services. - `apps/` holds the externally exported applications, assembled from Client / Host mixtures. - `apps/web` (`dsh-frontend`) is the vite application: a thin `main.ts` over the shell surface exported by `dsh-client-web`. - `apps/cli` (`@deepseek-ai/dsh`) dispatches commands: `dsh web` = Host + webserver + the built `dsh-frontend` dist; `dsh run` = [a direct core Agent/Session entry point](2026-08-09-headless-direct-core-entry-point.md), with zero Host, HTTP, or browser layer. @@ -40,7 +40,7 @@ apps/* (applications: apps/web = vite app, apps/cli = bin dispatch) ▼ packages/host/* packages/client/* apiproxy front layer: protocol pure libs: ui-slots / web-react / ui-primitives - runtime assembly / host entity dshClient plugins ×8 (node half = empty apply, + runtime assembly / host entity dsh.client plugins ×8 (node half = empty apply, webserver Web HTTP carriage client half = src/client/) │ ctx.plugin(...) ▲ import only apiproxy's /api /client subpaths ▼ │ (type-only + the client base class) @@ -63,7 +63,7 @@ On the protocol side: TS interfaces (`packages/host/apiproxy/src/api/`, zero Nod | Layer | Package | Responsibility | Key discipline | |---|---|---|---| | Front layer | `dsh-host-apiproxy` | TS/zod definitions (api/) + the fetch abstraction (fetch/: handler + client base class) | Keep it simple — every consumer needs it; importable from Node and browser alike; protocol content in the "Message protocol" sections below; clients must not bypass api through ctx | -| Assembly layer | `dsh-host-runtime` | Plugin composition + ApiProxy integration + the web UI plugin mount (in-memory Loader tree over the eight dshClient packages); home of host-level configuration (defaults/persistenceRoot, future user profile) | Which plugins mount and with what defaults is decided only here; shells must not alter the assembly | +| Assembly layer | `dsh-host-runtime` | Plugin composition + ApiProxy integration + the web UI plugin mount (in-memory Loader tree over the eight dsh.client packages); home of host-level configuration (defaults/persistenceRoot, future user profile) | Which plugins mount and with what defaults is decided only here; shells must not alter the assembly | | Carrier layer | `dsh-host-webserver` | Web HTTP and upgrade: static serving + `/api/*`→handler forwarding + WebSocket upgrade route + close semantics; plugin bundle endpoint + `__DSH_BOOT__` manifest injection (fed by the web plugin registry) | Web (browser access) only; zero workspace dependencies (the registry arrives by structural injection); Electron does not reuse it | | Client libraries | `dsh-client-ui-slots` / `dsh-client-web-react` / `dsh-client-ui-primitives` | Slot registry core / ctx↔React glue / pure React atoms | Zero cordis runtime dependency in components; seeded into the loader module table by the shell | | Client plugins | `dsh-client-connection` / `dsh-client-runtime` / `dsh-client-ui-theme` / `dsh-client-i18n` / `dsh-client-ui-layout` / `dsh-client-ui-sidebar` / `dsh-client-ui-conversation` / `dsh-client-ui-trajectory` | Browser-side cordis plugin tree (wire consumer, core services, theme, i18n, layout, sidebar, conversation, trajectory) — see the web client architecture note | Dual entry (node half = empty apply; implementation in `src/client/`); the consumption face goes exclusively through ApiProxy | diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md index 7d20c5a266..f6fa71e3da 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md @@ -25,8 +25,8 @@ Status: implemented - 统一后端协议(fetch、HTTP、流式接口等)定义和支持,见本篇「消息协议」起各节 - `packages/client/*`:包只提供 Client 侧能力,每包单边不混。这里住三类包(两条轴归 [client 插件装载笔记](2026-07-23-client-plugin-loading-model.md) 所有): - **纯库**(`ui-slots`、`web-react`、`ui-primitives`,外加内核包 `loader`):普通根入口包,静态打包进壳;前三者播种进模块表。 - - **静态到达 entry 包**(`connection`、`runtime`、`ui-theme`、`i18n`、`hmr`):无 `dshClient` 键、无浏览器 bundle——壳把它们的 `src/client/` 半边打进自己的 bundle 并向 `ctx.modules` 登记;它们与其余单元一样,作为 host 独家撰写的图里的 entry 受治理。 - - **fetch 到达插件包**(`ui-layout`、`ui-sidebar`、`ui-conversation`、`ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dshClient` 声明);实现住在 `src/client/` 下,经 `./client` 子路径发布(tsdown 闭包工厂 bundle)。跨插件消费 `/client` 只限类型;值层面的协作走 cordis 服务。 + - **静态到达 entry 包**(`connection`、`runtime`、`ui-theme`、`i18n`、`hmr`):无 `dsh.client` 键、无浏览器 bundle——壳把它们的 `src/client/` 半边打进自己的 bundle 并向 `ctx.modules` 登记;它们与其余单元一样,作为 host 独家撰写的图里的 entry 受治理。 + - **fetch 到达插件包**(`ui-layout`、`ui-sidebar`、`ui-conversation`、`ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dsh.client` 声明);实现住在 `src/client/` 下,经 `./client` 子路径发布(tsdown 闭包工厂 bundle)。跨插件消费 `/client` 只限类型;值层面的协作走 cordis 服务。 - `apps/` 作为对外导出的应用入口,可以由 Client / Host 混合组装。 - `apps/web`(`dsh-frontend`)是 vite 应用:`dsh-client-web` 导出的壳表面之上的一层薄 `main.ts`。 - `apps/cli`(`@deepseek-ai/dsh`)分发命令:`dsh web` = Host + webserver + 构建出的 `dsh-frontend` dist;`dsh run` = [直接使用核心 Agent/Session 的入口](2026-08-09-headless-direct-core-entry-point.md),不含 Host、HTTP 或浏览器层。 @@ -38,7 +38,7 @@ apps/* (applications: apps/web = vite app, apps/cli = bin dispatch) ▼ packages/host/* packages/client/* apiproxy front layer: protocol pure libs: ui-slots / web-react / ui-primitives - runtime assembly / host entity dshClient plugins ×8 (node half = empty apply, + runtime assembly / host entity dsh.client plugins ×8 (node half = empty apply, webserver Web HTTP carriage client half = src/client/) │ ctx.plugin(...) ▲ import only apiproxy's /api /client subpaths ▼ │ (type-only + the client base class) @@ -61,7 +61,7 @@ TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig. | 层 | 包 | 职责 | 关键纪律 | |---|---|---|---| | 前置层 | `dsh-host-apiproxy` | TS/zod 定义 (api/)+ fetch 抽象 (fetch/:handler + 客户端基类) | 做简单、所有接入方都要;Node/浏览器皆可 import;协议内容见下文「消息协议」起各节;client 不得经 ctx 绕开 api | -| 装配层 | `dsh-host-runtime` | 插件组合 + ApiProxy 集成 + web UI 插件挂载(覆盖八个 dshClient 包的内存 Loader 树);host 级配置归属地(defaults/persistenceRoot,将来用户 profile) | 装什么插件、给什么默认值只在这里定;壳不得改装配 | +| 装配层 | `dsh-host-runtime` | 插件组合 + ApiProxy 集成 + web UI 插件挂载(覆盖八个 dsh.client 包的内存 Loader 树);host 级配置归属地(defaults/persistenceRoot,将来用户 profile) | 装什么插件、给什么默认值只在这里定;壳不得改装配 | | 承载层 | `dsh-host-webserver` | Web HTTP 与 upgrade:静态服务 + `/api/*`→handler 转发 + WebSocket upgrade route + close 语义;插件 bundle 端点 + `__DSH_BOOT__` manifest(元数据清单)注入(由 web 插件注册表供给) | Web(浏览器访问)专用;零 workspace 依赖(注册表经结构注入到达);Electron 不复用它 | | client 库 | `dsh-client-ui-slots` / `dsh-client-web-react` / `dsh-client-ui-primitives` | slot 注册表核心 / ctx↔React 胶合 / 纯 React 原子组件 | 组件零 cordis 运行时依赖;由壳播种进 loader 模块表 | | client 插件 | `dsh-client-connection` / `dsh-client-runtime` / `dsh-client-ui-theme` / `dsh-client-i18n` / `dsh-client-ui-layout` / `dsh-client-ui-sidebar` / `dsh-client-ui-conversation` / `dsh-client-ui-trajectory` | 浏览器侧 cordis 插件树(wire 消费者、核心服务、主题、i18n、布局、侧栏、对话、轨迹)——见 Web 客户端架构笔记 | 双入口(node 半边=空 apply;实现在 `src/client/`);消费面唯一经 ApiProxy | diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml index 269933c1a7..1712fa8304 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.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 .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md -2026-07-19-gui-web-client-architecture.md: 82b2f85708c423748954644d4991e2d54d42874a -2026-07-19-gui-web-client-architecture.zh.md: c37252d1db291cae11db2a615c9e4005ece717da +2026-07-19-gui-web-client-architecture.md: bc61aab894d587820ef4cb568b6439993a27d30d +2026-07-19-gui-web-client-architecture.zh.md: 1f5bafe1dff878b5ca5ffcbdb9ed8ca38a863c9f diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md index 82b2f85708..bc61aab894 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md @@ -30,7 +30,7 @@ Both ends run cordis. The host is a cordis plugin tree; the browser runs a secon ## The client cordis tree and the loading chain -The loading chain — the two package kinds (plain vs dshClient plugin), the module-system/plugin-governor split, the two-phase boot over the host-authored entry graph with revisions, and hot reload — is owned by the [client plugin loading note](2026-07-23-client-plugin-loading-model.md). The load-bearing facts for this document: the browser boots the same vendored `@cordisjs/plugin-loader` as the host with a client module system (`ctx.modules`, `packages/client/modules`) filling its `internal` contract; every unit with product behavior is an entry in the host-authored `__DSH_BOOT__` graph — every production plugin package (infrastructure included) carries the `dshClient` declaration and arrives as a fetched `./client` tsdown closure bundle, `immediately` rows differing only in boot phase-one prefetch, while plain packages (react family, cordis, the not-yet-promoted libraries) stay shell-bundled, seeded, and invisible to the graph; bundles execute `window.__ModuleLoader__.load({ id, factory })` and their `require` is answered from the lazy CJS module table (seed words + registered factories, materialized and memoized on first require — cross-plugin value imports are a build error, cooperation goes through cordis services); plugin CSS is inlined in the bundle and injected as `