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 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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 })