fix(workspace-context): commit only represented changes
This commit is contained in:
@@ -12,7 +12,7 @@ import { assertNever } from '@deepseek-ai/dsh-llm'
|
|||||||
import { dshHomeDisplay } from '@deepseek-ai/dsh-paths'
|
import { dshHomeDisplay } from '@deepseek-ai/dsh-paths'
|
||||||
import { resolveConfig, resolveDiscoveryConfig, type ResolvedConfig } from './config.ts'
|
import { resolveConfig, resolveDiscoveryConfig, type ResolvedConfig } from './config.ts'
|
||||||
import { trimmedInstructionDigest } from './digest.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. */
|
/** An instruction candidate identified by absolute and model-facing paths. */
|
||||||
export interface InstructionFile {
|
export interface InstructionFile {
|
||||||
@@ -413,9 +413,7 @@ export async function loadBaselineInstructionSet(
|
|||||||
}
|
}
|
||||||
const deduped = dedupInstructionFilesByDirectory(loaded)
|
const deduped = dedupInstructionFilesByDirectory(loaded)
|
||||||
if (deduped.length === 0) return undefined
|
if (deduped.length === 0) return undefined
|
||||||
const rendered = renderWorkspaceContext(deduped, { maxBytes: config.maxBytes })
|
return renderWorkspaceInstructionSet(deduped, { maxBytes: config.maxBytes })
|
||||||
const omitted = new Set(rendered.omitted.map(file => file.absolutePath))
|
|
||||||
return { rendered, included: deduped.filter(file => !omitted.has(file.absolutePath)) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ export interface RenderedWorkspaceContext {
|
|||||||
truncated: TruncatedInstruction[]
|
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. */
|
/** Structured dynamic state persisted outside model-visible prompt prose. */
|
||||||
export interface WorkspaceInstructionChange {
|
export interface WorkspaceInstructionChange {
|
||||||
action: 'set' | 'replace' | 'remove'
|
action: 'set' | 'replace' | 'remove'
|
||||||
@@ -174,13 +179,10 @@ export function renderInstructionChanges(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
const rendered = renderInstructionContext(items.map(item => item.file), maxBytes, style)
|
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 {
|
return {
|
||||||
text: rendered.text,
|
text: rendered.text,
|
||||||
// TODO(rendered-change-proof): retain a transition only when its semantic
|
changes: items.filter(item => included.has(item.file.absolutePath)).map(item => item.change),
|
||||||
// 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),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,22 +250,26 @@ function renderInstructionContext(
|
|||||||
files: LoadedInstructionFile[],
|
files: LoadedInstructionFile[],
|
||||||
maxBytes: number,
|
maxBytes: number,
|
||||||
style: RenderStyle,
|
style: RenderStyle,
|
||||||
): RenderedWorkspaceContext {
|
): RenderedInstructionContext {
|
||||||
if (maxBytes <= 0 || !Number.isFinite(maxBytes)) return { text: '', omitted: files, truncated: [] }
|
if (maxBytes <= 0 || !Number.isFinite(maxBytes)) {
|
||||||
|
return { text: '', omitted: files, truncated: [], included: [] }
|
||||||
|
}
|
||||||
|
|
||||||
const fullText = buildInstructionText(files, maxBytes, [], [], style)
|
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) {
|
for (let start = 1; start < files.length; start += 1) {
|
||||||
const included = files.slice(start)
|
const included = files.slice(start)
|
||||||
const omitted = files.slice(0, start).map(file => ({ absolutePath: file.absolutePath, displayPath: file.displayPath }))
|
const omitted = files.slice(0, start).map(file => ({ absolutePath: file.absolutePath, displayPath: file.displayPath }))
|
||||||
const suffixText = buildInstructionText(included, maxBytes, omitted, [], style)
|
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)
|
const mostSpecific = files.at(-1)
|
||||||
/* v8 ignore next -- callers only reach this after a non-empty fullText was built. */
|
/* 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 }))
|
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 }]) {
|
for (const candidateStyle of [style, { ...style, intro: COMPACT_WORKSPACE_CONTEXT_INTRO }]) {
|
||||||
@@ -274,7 +280,7 @@ function renderInstructionContext(
|
|||||||
includedBytes: byteLength(truncatedFile.content),
|
includedBytes: byteLength(truncatedFile.content),
|
||||||
}]
|
}]
|
||||||
const text = buildInstructionText([truncatedFile], maxBytes, omitted, truncated, candidateStyle)
|
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 = [{
|
const truncated = [{
|
||||||
@@ -286,9 +292,26 @@ function renderInstructionContext(
|
|||||||
const compactWithHeading = escapeInstructionFrameBody(
|
const compactWithHeading = escapeInstructionFrameBody(
|
||||||
[compactNotice, style.section(withTruncatedContent(mostSpecific, 0))].join('\n\n'),
|
[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)
|
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[],
|
files: LoadedInstructionFile[],
|
||||||
options: { maxBytes: number },
|
options: { maxBytes: number },
|
||||||
): RenderedWorkspaceContext {
|
): RenderedWorkspaceContext {
|
||||||
return renderInstructionContext(files, options.maxBytes, BASELINE_RENDER_STYLE)
|
return renderWorkspaceInstructionSet(files, options).rendered
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -828,6 +828,38 @@ describe('workspace context rendering', () => {
|
|||||||
expect(Buffer.byteLength(rendered.text, 'utf8')).toBe(20)
|
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', () => {
|
it('keeps compact truncation notices within budget when a multibyte display path is cut', () => {
|
||||||
const rendered = renderWorkspaceContext([
|
const rendered = renderWorkspaceContext([
|
||||||
{ absolutePath: '/repo/路径/AGENTS.md', displayPath: '路径/AGENTS.md', content: 'x'.repeat(1000) },
|
{ absolutePath: '/repo/路径/AGENTS.md', displayPath: '路径/AGENTS.md', content: 'x'.repeat(1000) },
|
||||||
@@ -1298,9 +1330,12 @@ describe('workspace context request injection', () => {
|
|||||||
|
|
||||||
await composeBaselinePrefix(ctx, agent)
|
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',
|
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:')
|
expect(derivedText(agent)).not.toContain('workspace-context:')
|
||||||
} finally {
|
} finally {
|
||||||
await rm(root, { recursive: true, force: true })
|
await rm(root, { recursive: true, force: true })
|
||||||
|
|||||||
Reference in New Issue
Block a user