fix inbox lifecycle downstream contracts

This commit is contained in:
_Kerman
2026-07-31 22:00:39 +08:00
parent 8e88b17c9f
commit afedf18ccf
219 changed files with 5660 additions and 4556 deletions

View File

@@ -137,6 +137,9 @@ describe('time-context invariants', () => {
const ended = preparing(1, 1)
ended.append('step/end', { turn: 1, step: 1 })
expect(() => { ctx.emit('session/event', ended, event(reading())) }).toThrow(/at a prompt boundary/)
const notEntered = new Session(SessionId('time-invariant-turn-only'))
notEntered.append('turn/start', { turn: 1 })
expect(() => { ctx.emit('session/event', notEntered, event(reading())) }).toThrow(/at a prompt boundary/)
expect(() => {
ctx.emit('session/event', new Session(SessionId('time-invariant-empty')), event(reading()))
}).toThrow(/at a prompt boundary/)

View File

@@ -2,35 +2,28 @@
* Workspace instruction loader for AGENTS.md-compatible files.
*
* Baseline instructions enter durable context before the first request; successful fs
* tool touches reconcile nested, changed, and removed instructions through
* `tools/post-execute` for the next model request. Plugin lifecycle reads use
* tool touches mark nested, changed, and removed instructions for reconciliation
* at the next pre-step. Plugin lifecycle reads use
* the optional `ctx.fs` provider, so providerless products mount it as a no-op.
*
* @module @deepseek-ai/dsh-workspace-context
*/
import type { Context } from 'cordis'
import { isDeepStrictEqual } from 'node:util'
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
import { createUserMessage, type MessageId } from '@deepseek-ai/dsh-llm'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { UserMessage } from '@deepseek-ai/dsh-session'
import type { PostToolDecision, ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
import type { ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
import { Config, resolveConfig, type ResolvedConfig } from './config.ts'
import { loadBaselineInstructionSet } from './files.ts'
import {
applyInstructionVersionUpdates,
baselineInstructionState,
commitPendingInstructionContexts,
dynamicInstructionContext,
name,
observeInstructionSessionEvent,
reconcileInstructionContext,
retainedInstructionVersionUpdates,
rollbackPendingInstructionChanges,
workspaceContextMessage,
type InstructionVersionCache,
type InstructionVersionState,
type InstructionVersionUpdate,
type PendingInstructionChange,
} from './state.ts'
import type { WorkspaceInstructionChange } from './render.ts'
@@ -55,200 +48,198 @@ function hasVisibleBaseline(agent: Agent): boolean {
})
}
function isWorkspaceContext(message: UserMessage): boolean {
return message.source.kind === 'workspace-instructions'
}
function sameContextPayload(left: UserMessage, right: UserMessage): boolean {
return isDeepStrictEqual(left.content, right.content)
&& isDeepStrictEqual(left.source, right.source)
}
const FILE_TOUCH_TOOL_NAMES = new Set(['read', 'write', 'edit'])
function filePathFromExecution(exec: ToolExecution): string | undefined {
if (!FILE_TOUCH_TOOL_NAMES.has(exec.name)) return undefined
if (typeof exec.arguments !== 'object' || exec.arguments === null) return undefined
if (!('file_path' in exec.arguments) || typeof exec.arguments.file_path !== 'string') return undefined
const filePath = exec.arguments.file_path.trim()
return filePath.length > 0 ? filePath : undefined
}
export function apply(ctx: Context, config: Config): void {
const resolved: ResolvedConfig = resolveConfig(config)
const pendingNestedChanges = new WeakMap<object, Map<string, PendingInstructionChange>>()
const baselineSessions = new WeakSet<object>()
const instructionVersions: InstructionVersionCache = new WeakMap()
const pendingVersionUpdates = new Map<ToolExecutionToken, InstructionVersionUpdate[]>()
const baselineLoaded = new WeakSet<object>()
const pendingBaselineCommits = new WeakMap<object, {
messageIds: Set<MessageId>
versions: Map<string, InstructionVersionState>
}>()
// Sessions whose lifecycle start this mount witnessed. A startup or resume
// emits agent/session-start before the first step; a hot remount attaches to
// an already-live session and never sees it. Resumes always re-compose the
// baseline from current files. Hot remounts retain a baseline only while its
// typed event remains model-visible.
const lifecycleWitnessed = new WeakSet<object>()
const pendingByParent = new Map<ToolExecutionToken, {
agent: Agent
changes: WorkspaceInstructionChange[]
versionUpdates: InstructionVersionUpdate[]
}>()
const pendingTouches = new Map<ToolExecutionToken, { agent: Agent; paths: Set<string> }>()
const touchedPaths = new WeakMap<Agent, Set<string>>()
ctx.on('agent/session-start', (agent: Agent) => {
lifecycleWitnessed.add(agent.session)
})
const compose = async (
agent: Agent,
signal: AbortSignal,
claimed: readonly UserMessage[],
pending: readonly UserMessage[],
touchedPaths: readonly string[] = [],
): Promise<{
desired?: UserMessage
versions: Map<string, import('./state.ts').InstructionVersionState>
}> => {
signal.throwIfAborted()
const candidateVersions: InstructionVersionCache = new WeakMap()
const candidateVersionStates = new Map(instructionVersions.get(agent.session) ?? [])
candidateVersions.set(agent.session, candidateVersionStates)
if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) {
return { versions: new Map() }
}
const fileSystem = ctx.get('fs')
if (fileSystem === undefined) return { versions: new Map() }
const content: UserMessage['content'][number][] = []
const changes: WorkspaceInstructionChange[] = []
let desiredBaseline = false
const authorityMessages = [...claimed]
const baselinePresent = hasVisibleBaseline(agent) || claimed.some(message =>
message.source.kind === 'workspace-instructions' && message.source.baseline === true)
if (!baselinePresent) {
/* v8 ignore next -- normal agents carry an absolute session cwd. */
const cwd = agent.session.header.cwd ?? process.cwd()
const instructions = await loadBaselineInstructionSet({
cwd,
dshHome: resolved.dshHome,
projectRootMarkers: resolved.projectRootMarkers,
maxBytes: resolved.maxBytes,
maxSourceBytes: resolved.maxSourceBytes,
instructionFileCandidates: resolved.instructionFileCandidates,
localInstructionFileCandidates: resolved.localInstructionFileCandidates,
signal,
}, fileSystem)
const baseline = baselineInstructionState(instructions?.included ?? [])
for (const [scope, state] of baseline.versions) candidateVersionStates.set(scope, state)
if (instructions !== undefined && instructions.rendered.text.length > 0) {
content.push(...workspaceContextMessage(instructions.rendered.text).content)
changes.push(...baseline.changes.values())
desiredBaseline = true
}
}
const update = await reconcileInstructionContext(
agent,
resolved,
candidateVersions,
fileSystem,
{ authorityMessages, scopeMessages: pending, includeBaselineScopes: baselinePresent, touchedPaths, signal },
)
if (update !== undefined) {
content.push(...update.context.content)
/* v8 ignore next -- reconciliation constructs only workspace-instructions contexts. */
if (update.context.source.kind === 'workspace-instructions') {
changes.push(...update.context.source.changes)
}
applyInstructionVersionUpdates(agent.session, update.versionUpdates, candidateVersions)
}
const versions = new Map(candidateVersions.get(agent.session) ?? [])
return content.length === 0
? { versions }
: {
desired: createUserMessage({
content,
source: {
kind: 'workspace-instructions',
...desiredBaseline ? { baseline: true } : {},
changes,
},
}),
versions,
}
}
ctx.on('session/event', (session, event) => {
observeInstructionSessionEvent(session, event, pendingNestedChanges, instructionVersions)
const pending = pendingBaselineCommits.get(session)
if (pending === undefined || event.type !== 'user/message'
|| !pending.messageIds.delete(event.data.id) || pending.messageIds.size > 0) return
baselineSessions.add(session)
if (pending.versions.size === 0) instructionVersions.delete(session)
else instructionVersions.set(session, pending.versions)
baselineLoaded.add(session)
pendingBaselineCommits.delete(session)
})
const syncInbox = (agent: Agent, claimed: readonly UserMessage[], desired: UserMessage | undefined): void => {
const pending = agent.inbox.nextStep.filter(isWorkspaceContext)
const alreadySupplied = desired !== undefined && (
claimed.some(message => sameContextPayload(message, desired))
|| agent.session.surface.nodes.some((seq) => {
const event = agent.session.events[seq]
return event?.type === 'user/message' && sameContextPayload(event.data, desired)
})
)
if (desired === undefined || alreadySupplied) {
for (const message of pending) agent.inbox.remove('next-step', message.id)
return
}
const reusable = pending.find(message => sameContextPayload(message, desired))
if (reusable !== undefined) {
for (const message of pending) {
if (message !== reusable) agent.inbox.remove('next-step', message.id)
}
return
}
const replaced = pending[0]
if (replaced === undefined) agent.inbox.prepend('next-step', desired)
else agent.inbox.update('next-step', replaced.id, desired)
for (const message of pending.slice(1)) agent.inbox.remove('next-step', message.id)
}
const commitSync = (
agent: Agent,
claimed: readonly UserMessage[],
desired: UserMessage | undefined,
versions: Map<string, import('./state.ts').InstructionVersionState>,
): void => {
syncInbox(agent, claimed, desired)
if (versions.size === 0) instructionVersions.delete(agent.session)
else instructionVersions.set(agent.session, versions)
}
const restoreTouchedPaths = (agent: Agent, paths: Set<string> | undefined): void => {
if (paths === undefined || paths.size === 0) return
const current = touchedPaths.get(agent)
if (current === undefined) touchedPaths.set(agent, paths)
else for (const path of paths) current.add(path)
}
ctx.on('agent/pre-step', async (
agent: Agent,
_messages,
messages,
{ signal },
next,
): Promise<PreStepDecision> => {
const decision = await next()
if (signal.aborted || baselineLoaded.has(agent.session)) return decision
const previous = pendingBaselineCommits.get(agent.session)
if (decision.kind === 'enter' && previous !== undefined
&& [...previous.messageIds].every(id => decision.messages.some(message => message.id === id))) {
const pending = agent.inbox.nextStep.filter(isWorkspaceContext)
const paths = touchedPaths.get(agent)
touchedPaths.delete(agent)
try {
const composed = await compose(agent, signal, messages, pending, [...paths ?? []])
/* v8 ignore next 4 -- every awaited filesystem operation checks this signal before settling. */
if (signal.aborted) {
restoreTouchedPaths(agent, paths)
return decision
}
commitSync(agent, messages, composed.desired, composed.versions)
return decision
}
if (previous !== undefined) {
for (const id of previous.messageIds) agent.inbox.remove('next-step', id)
pendingBaselineCommits.delete(agent.session)
}
if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) {
baselineLoaded.add(agent.session)
pendingBaselineCommits.delete(agent.session)
return decision
}
const fileSystem = ctx.get('fs')
if (fileSystem === undefined) {
baselineLoaded.add(agent.session)
pendingBaselineCommits.delete(agent.session)
return decision
}
/* v8 ignore next -- normal agents carry an absolute session cwd. */
const cwd = agent.session.header.cwd ?? process.cwd()
const instructions = await loadBaselineInstructionSet({
cwd,
dshHome: resolved.dshHome,
projectRootMarkers: resolved.projectRootMarkers,
maxBytes: resolved.maxBytes,
maxSourceBytes: resolved.maxSourceBytes,
instructionFileCandidates: resolved.instructionFileCandidates,
localInstructionFileCandidates: resolved.localInstructionFileCandidates,
signal,
}, fileSystem)
const baseline = baselineInstructionState(instructions?.included ?? [])
const candidateVersions: InstructionVersionCache = new WeakMap()
candidateVersions.set(agent.session, new Map(baseline.versions))
const contexts: UserMessage[] = []
const update = await reconcileInstructionContext(
agent,
resolved,
pendingNestedChanges,
candidateVersions,
fileSystem,
{ includeBaselineScopes: false, signal },
)
if (update !== undefined) {
contexts.push(update.context)
applyInstructionVersionUpdates(agent.session, update.versionUpdates, candidateVersions)
}
const keepVisibleBaseline = !lifecycleWitnessed.has(agent.session) && hasVisibleBaseline(agent)
if (!keepVisibleBaseline && instructions !== undefined && instructions.rendered.text.length > 0) {
const baselineMessage = workspaceContextMessage(instructions.rendered.text)
contexts.push(createUserMessage({
content: baselineMessage.content,
source: {
kind: 'workspace-instructions',
baseline: true,
changes: [...baseline.changes.values()],
},
}))
}
const versions = candidateVersions.get(agent.session)
?? new Map<string, InstructionVersionState>()
if (contexts.length === 0) {
baselineSessions.add(agent.session)
if (versions.size === 0) instructionVersions.delete(agent.session)
else instructionVersions.set(agent.session, versions)
baselineLoaded.add(agent.session)
pendingBaselineCommits.delete(agent.session)
return decision
}
pendingBaselineCommits.set(agent.session, {
messageIds: new Set(contexts.map(context => context.id)),
versions,
})
for (const context of contexts.toReversed()) {
agent.inbox.prepend('next-step', context)
}
return decision
})
ctx.on('tools/post-execute', async (
exec: ToolExecution,
result: ToolExecutionResult,
next,
): Promise<PostToolDecision> => {
const downstream = await next()
// A downstream listener/policy blocked this call: the registry turns it
// into a final `isError` result, so treat it like a failed fs touch and
// load nothing. Reconciling here would surface workspace instructions from
// a call the pipeline rejected, violating the "successful fs tool touches"
// contract, and would advance the nested/baseline tracking state off a
// touch that never really happened.
if (downstream.kind === 'block') return downstream
const fileSystem = ctx.get('fs')
if (fileSystem === undefined) return downstream
const update = await dynamicInstructionContext(
exec.agent,
exec,
result,
resolved,
pendingNestedChanges,
baselineSessions,
instructionVersions,
fileSystem,
)
if (update === undefined) return downstream
pendingVersionUpdates.set(exec.token, update.versionUpdates)
return {
...downstream,
additionalContexts: [update.context, ...downstream.additionalContexts ?? []],
} catch (error: unknown) {
restoreTouchedPaths(agent, paths)
throw error
}
})
ctx.on('tools/result', (exec: ToolExecution, result: ToolExecutionResult) => {
const ownVersionUpdates = pendingVersionUpdates.get(exec.token) ?? []
pendingVersionUpdates.delete(exec.token)
const staged = pendingTouches.get(exec.token)
pendingTouches.delete(exec.token)
if (exec.parent !== undefined) {
if (exec.agent === undefined) return
// Child contexts participate in duplicate suppression within one composite
// run, but remain provisional until the parent reaches its final policy.
const changes = commitPendingInstructionContexts(exec.agent, result.additionalContexts, pendingNestedChanges)
if (changes.length === 0) return
const versionUpdates = retainedInstructionVersionUpdates(ownVersionUpdates, changes)
const staged = pendingByParent.get(exec.parent)
if (staged === undefined) pendingByParent.set(exec.parent, { agent: exec.agent, changes, versionUpdates })
else {
staged.changes.push(...changes)
staged.versionUpdates.push(...versionUpdates)
const paths = new Set(staged?.paths ?? [])
const ownPath = result.isError ? undefined : filePathFromExecution(exec)
if (ownPath !== undefined) paths.add(ownPath)
if (!result.isError && exec.agent !== undefined && paths.size > 0) {
const parent = pendingTouches.get(exec.parent)
if (parent === undefined) pendingTouches.set(exec.parent, { agent: exec.agent, paths })
else for (const path of paths) parent.paths.add(path)
}
return
}
// The parent result is authoritative: remove every provisional child change,
// then commit only contexts that survived outer post-execute policy.
const staged = pendingByParent.get(exec.token)
if (staged !== undefined) {
pendingByParent.delete(exec.token)
rollbackPendingInstructionChanges(staged.agent, staged.changes, pendingNestedChanges)
}
if (exec.agent === undefined) return
const committed = commitPendingInstructionContexts(exec.agent, result.additionalContexts, pendingNestedChanges)
const stagedVersionUpdates = staged?.versionUpdates ?? []
const versionUpdates = retainedInstructionVersionUpdates(
[...stagedVersionUpdates, ...ownVersionUpdates],
committed,
)
applyInstructionVersionUpdates(exec.agent.session, versionUpdates, instructionVersions)
if (result.isError || exec.agent === undefined) return
const paths = new Set(staged?.paths ?? [])
const ownPath = filePathFromExecution(exec)
if (ownPath !== undefined) paths.add(ownPath)
if (paths.size === 0) return
const pending = touchedPaths.get(exec.agent)
if (pending === undefined) touchedPaths.set(exec.agent, paths)
else for (const path of paths) pending.add(path)
})
}

View File

@@ -7,9 +7,8 @@
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { Message } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent, UserMessage } from '@deepseek-ai/dsh-session'
import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
import type { FileSystem, FsVersion } from '@deepseek-ai/dsh-fs'
import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import type { ResolvedConfig } from './config.ts'
import { instructionContentSha1, trimmedInstructionDigest } from './digest.ts'
import {
@@ -34,8 +33,6 @@ import {
export const name = 'workspace-context'
const FILE_TOUCH_TOOL_NAMES = new Set(['read', 'write', 'edit'])
/** Durable provenance and reconciliation facts for one workspace context. */
export interface WorkspaceInstructionSource {
kind: 'workspace-instructions'
@@ -50,13 +47,6 @@ declare module '@deepseek-ai/dsh-llm' {
}
}
/** Dynamic state waiting for the loop to append its returned context event. */
export interface PendingInstructionChange {
change: WorkspaceInstructionChange
afterSeq: number
step?: { turn: number; step: number }
}
/** Per-scope metadata cache; instruction prose is deliberately not retained. */
export interface InstructionVersionState {
path: string
@@ -103,14 +93,6 @@ export function workspaceContextMessage(text: string): Message {
})
}
function filePathFromExecution(exec: ToolExecution): string | undefined {
if (!FILE_TOUCH_TOOL_NAMES.has(exec.name)) return undefined
if (typeof exec.arguments !== 'object' || exec.arguments === null) return undefined
if (!('file_path' in exec.arguments) || typeof exec.arguments.file_path !== 'string') return undefined
const filePath = exec.arguments.file_path.trim()
return filePath.length > 0 ? filePath : undefined
}
function isWorkspaceContextSource(
source: unknown,
): source is { kind: 'workspace-instructions'; changes: unknown[] } {
@@ -149,7 +131,7 @@ function sameInstructionChange(a: WorkspaceInstructionChange, b: WorkspaceInstru
function visibleInstructionChanges(
agent: Agent,
pending: Map<string, PendingInstructionChange>,
authorityMessages: readonly UserMessage[],
): Map<string, WorkspaceInstructionChange> {
const visibleSeqs = new Set(agent.session.surface.nodes)
const visible = new Map<string, WorkspaceInstructionChange>()
@@ -157,14 +139,15 @@ function visibleInstructionChanges(
if (event.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) continue
const changes = workspaceInstructionChanges(event.data.source)
for (const change of changes) {
const waiting = pending.get(change.scope)
if (waiting !== undefined && seq >= waiting.afterSeq && sameInstructionChange(waiting.change, change)) {
pending.delete(change.scope)
}
if (visibleSeqs.has(seq)) visible.set(change.scope, change)
}
}
for (const { change } of pending.values()) visible.set(change.scope, change)
for (const message of authorityMessages) {
if (!isWorkspaceContextSource(message.source)) continue
for (const change of workspaceInstructionChanges(message.source)) {
visible.set(change.scope, change)
}
}
return visible
}
@@ -242,164 +225,35 @@ export function applyInstructionVersionUpdates(
if (states.size === 0) cache.delete(session)
}
function pendingChangesFor(
session: object,
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
): Map<string, PendingInstructionChange> {
let pending = pendingBySession.get(session)
if (pending === undefined) {
pending = new Map()
pendingBySession.set(session, pending)
}
return pending
}
function openStep(session: Session): { turn: number; step: number } | undefined {
const boundary = session.events.findLast(event => event.type === 'step/start' || event.type === 'step/end')
return boundary?.type === 'step/start' ? boundary.data : undefined
}
function invalidateInstructionVersions(
session: Session,
scopes: readonly string[],
cache: InstructionVersionCache,
): void {
const states = cache.get(session)
if (states === undefined) return
for (const scope of scopes) states.delete(scope)
if (states.size === 0) cache.delete(session)
}
/**
* Settle provisional tool-result state against durable session events.
* A matching context event confirms the transition. If its owning step closes
* first, both duplicate suppression and the metadata fast path are re-armed for
* the next successful touch.
* @param session - session whose append-only log emitted `event`.
* @param event - newly committed session event.
* @param pendingBySession - provisional transitions awaiting log confirmation.
* @param versionCache - metadata fast path coupled to those transitions.
*/
export function observeInstructionSessionEvent(
session: Session,
event: SessionEvent,
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
versionCache: InstructionVersionCache,
): void {
const pending = pendingBySession.get(session)
if (pending === undefined) return
switch (event.type) {
case 'user/message': {
if (!isWorkspaceContextSource(event.data.source)) return
for (const change of workspaceInstructionChanges(event.data.source)) {
const waiting = pending.get(change.scope)
if (waiting !== undefined && event.seq >= waiting.afterSeq && sameInstructionChange(waiting.change, change)) {
pending.delete(change.scope)
}
}
if (pending.size === 0) pendingBySession.delete(session)
return
}
case 'step/end': {
const discardedScopes: string[] = []
for (const [scope, waiting] of pending) {
const step = waiting.step
if (step === undefined || step.turn !== event.data.turn || step.step !== event.data.step) continue
pending.delete(scope)
discardedScopes.push(scope)
}
if (pending.size === 0) pendingBySession.delete(session)
invalidateInstructionVersions(session, discardedScopes, versionCache)
return
}
default:
// SessionEventMap is merge-extensible; unrelated events do not settle workspace state.
return
}
}
/**
* Commit only workspace contexts that survived the complete tool pipeline.
* The observe-only `tools/result` notification calls this before the loop can
* append the returned contexts, closing that short pending window without
* trusting an intermediate post-execute decision.
* @param agent - session that will receive the final result contexts.
* @param contexts - immutable contexts on the authoritative top-level result.
* @param pendingBySession - per-session pending transition maps.
* @returns transitions committed into the short pending window.
*/
export function commitPendingInstructionContexts(
agent: Agent,
contexts: readonly UserMessage[] | undefined,
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
): WorkspaceInstructionChange[] {
const committed: WorkspaceInstructionChange[] = []
const step = openStep(agent.session)
for (const context of contexts ?? []) {
if (!isWorkspaceContextSource(context.source)) continue
const changes = workspaceInstructionChanges(context.source)
if (changes.length === 0) continue
const pending = pendingChangesFor(agent.session, pendingBySession)
for (const change of changes) {
pending.set(change.scope, {
change,
afterSeq: agent.session.seq,
...step === undefined ? {} : { step },
})
committed.push(change)
}
}
return committed
}
/**
* Roll back parent-token state when an enclosing tool result discards deferred
* contexts. A newer transition for the same scope is left intact.
* @param agent - session whose pending state was staged.
* @param changes - exact staged transitions to remove when still current.
* @param pendingBySession - per-session pending transition maps.
*/
export function rollbackPendingInstructionChanges(
agent: Agent,
changes: readonly WorkspaceInstructionChange[],
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
): void {
const pending = pendingBySession.get(agent.session)
if (pending === undefined) return
for (const change of changes) {
const current = pending.get(change.scope)
if (current !== undefined && sameInstructionChange(current.change, change)) pending.delete(change.scope)
}
if (pending.size === 0) pendingBySession.delete(agent.session)
}
function relativeScope(projectRoot: string, dir: string): string {
const scope = relativeDisplay(projectRoot, dir)
return scope.length === 0 ? '.' : scope
}
/**
* Compare visible/pending state with provider-visible files and render transitions.
* Compare visible state with provider-visible files and render transitions.
* @param agent - session owner whose visible surface supplies durable state.
* @param resolved - normalized plugin configuration.
* @param pendingBySession - short pending window before returned context is logged.
* @param versionCache - per-session scope metadata used to skip unchanged reads.
* @param fileSystem - provider used for current file probes.
* @param options - touched path and whether baseline scopes should participate.
* @param options - authoritative claimed context, pending scope hints, touched paths, and baseline participation.
* @returns rendered context plus deferred cache updates, or undefined when unchanged/unavailable.
*/
export async function reconcileInstructionContext(
agent: Agent,
resolved: ResolvedConfig,
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
versionCache: InstructionVersionCache,
fileSystem: FileSystem,
options: { touchedPath?: string; includeBaselineScopes: boolean; signal?: AbortSignal },
options: {
authorityMessages: readonly UserMessage[]
scopeMessages: readonly UserMessage[]
touchedPaths: readonly string[]
includeBaselineScopes: boolean
signal?: AbortSignal
},
): Promise<ReconciledInstructionContext | undefined> {
const session = agent.session
const pending = pendingChangesFor(session, pendingBySession)
const effective = visibleInstructionChanges(agent, pending)
const effective = visibleInstructionChanges(agent, options.authorityMessages)
/* v8 ignore next -- normal agents carry an absolute session cwd. */
const cwd = session.header.cwd ?? process.cwd()
// TODO(frozen-project-root): retain the baseline root for the loop instance;
@@ -419,14 +273,22 @@ export async function reconcileInstructionContext(
if (options.includeBaselineScopes) {
for (const scope of baselineScopes) scopes.add(scope)
}
for (const message of options.scopeMessages) {
/* v8 ignore next -- the plugin passes its workspace-only pending projection. */
if (!isWorkspaceContextSource(message.source)) continue
for (const change of workspaceInstructionChanges(message.source)) {
if (!options.includeBaselineScopes && baselineScopes.has(change.scope)) continue
scopes.add(change.scope)
}
}
for (const scope of effective.keys()) {
if (!options.includeBaselineScopes && baselineScopes.has(scope)) continue
const { directory } = decodeScopeKey(scope)
if (directory === USER_GLOBAL_DIRECTORY) scopes.add(candidateScopeKey(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE))
else addDirScopes(scopes, directory)
}
if (options.touchedPath !== undefined) {
for (const dir of descendantDirsBetween(cwd, options.touchedPath)) addProjectScopes(scopes, dir)
for (const touchedPath of options.touchedPaths) {
for (const dir of descendantDirsBetween(cwd, touchedPath)) addProjectScopes(scopes, dir)
}
const versions = versionStatesFor(session, versionCache)
@@ -452,116 +314,97 @@ export async function reconcileInstructionContext(
items.push({ change, file: { absolutePath: `removed:${scope}`, displayPath: path, content: '' } })
versionUpdates.push({ change })
}
const scopesByDirectory = new Map<string, string[]>()
for (const scope of scopes) {
const { directory } = decodeScopeKey(scope)
const previous = effective.get(scope)
const probe = await probeScopeInstruction(scope, projectRoot, resolved, fileSystem, options.signal)
if (probe.kind === 'unavailable') {
// Last-good-state: the candidate stays effective, so its cached trimmed
// digest must keep occupying the directory's dedup slot — otherwise an
// identical later sibling would be emitted as a duplicate `set` until the
// next successful reconciliation removed it again.
const cached = versions.get(scope)
if (cached !== undefined && previous !== undefined && previous.action !== 'remove') {
registerKeptTrimmed(directory, cached.trimmedDigest)
const directoryScopes = scopesByDirectory.get(directory)
if (directoryScopes === undefined) scopesByDirectory.set(directory, [scope])
else directoryScopes.push(scope)
}
for (const [directory, directoryScopes] of scopesByDirectory) {
const itemStart = items.length
const versionUpdateStart = versionUpdates.length
const addedAbsolutePaths: string[] = []
const priorVersions = new Map(directoryScopes.map(scope => [scope, versions.get(scope)]))
for (const scope of directoryScopes) {
const previous = effective.get(scope)
const probe = await probeScopeInstruction(scope, projectRoot, resolved, fileSystem, options.signal)
if (probe.kind === 'unavailable') {
if (previous === undefined || previous.action === 'remove') continue
// Same-directory candidates form one deduplicated authority group. If an
// active member cannot be observed, preserve the entire last-good group;
// cache warmth must never decide whether a sibling transition is emitted.
items.splice(itemStart)
versionUpdates.splice(versionUpdateStart)
for (const [candidateScope, prior] of priorVersions) {
if (prior === undefined) versions.delete(candidateScope)
else versions.set(candidateScope, prior)
}
for (const absolutePath of addedAbsolutePaths) seenAbsolutePaths.delete(absolutePath)
keptTrimmedByDir.delete(directory)
break
}
if (probe.kind === 'absent') {
if (previous === undefined || previous.action === 'remove') versions.delete(scope)
else pushRemoval(scope, previous.path)
continue
}
const { file: probedFile } = probe
if (seenAbsolutePaths.has(probedFile.absolutePath)) continue
seenAbsolutePaths.add(probedFile.absolutePath)
addedAbsolutePaths.push(probedFile.absolutePath)
const cached = versions.get(scope)
if (
cached !== undefined
&& cached.path === probedFile.displayPath
&& cached.version === probedFile.version
&& previous !== undefined
&& previous.action !== 'remove'
&& previous.path === cached.path
&& previous.digest === cached.digest
) {
// Unchanged and previously rendered: keep it, but an earlier sibling that
// now matches its trimmed content makes this the duplicate to remove.
if (registerKeptTrimmed(directory, cached.trimmedDigest)) pushRemoval(scope, previous.path)
continue
}
continue
}
if (probe.kind === 'absent') {
if (previous === undefined || previous.action === 'remove') versions.delete(scope)
else pushRemoval(scope, previous.path)
continue
}
const { file: probedFile } = probe
if (seenAbsolutePaths.has(probedFile.absolutePath)) continue
seenAbsolutePaths.add(probedFile.absolutePath)
const cached = versions.get(scope)
if (
cached !== undefined
&& cached.path === probedFile.displayPath
&& cached.version === probedFile.version
&& previous !== undefined
&& previous.action !== 'remove'
&& previous.path === cached.path
&& previous.digest === cached.digest
) {
// Unchanged and previously rendered: keep it, but an earlier sibling that
// now matches its trimmed content makes this the duplicate to remove.
if (registerKeptTrimmed(directory, cached.trimmedDigest)) pushRemoval(scope, previous.path)
continue
}
const file = await readScopeInstruction(probedFile, resolved.maxSourceBytes, fileSystem, options.signal)
if (file === undefined) continue
const currentDigest = instructionContentSha1(file.content)
const trimmedDigest = trimmedInstructionDigest(file.content)
if (registerKeptTrimmed(directory, trimmedDigest)) {
// A distinct file whose trimmed content already appeared earlier in this
// directory: drop it, removing any copy that was previously rendered.
if (previous !== undefined && previous.action !== 'remove') pushRemoval(scope, previous.path)
else versions.delete(scope)
continue
const file = await readScopeInstruction(probedFile, resolved.maxSourceBytes, fileSystem, options.signal)
if (file === undefined) continue
const currentDigest = instructionContentSha1(file.content)
const trimmedDigest = trimmedInstructionDigest(file.content)
if (registerKeptTrimmed(directory, trimmedDigest)) {
// A distinct file whose trimmed content already appeared earlier in this
// directory: drop it, removing any copy that was previously rendered.
if (previous !== undefined && previous.action !== 'remove') pushRemoval(scope, previous.path)
else versions.delete(scope)
continue
}
const nextVersion: InstructionVersionState = {
path: file.displayPath,
version: probedFile.version,
digest: currentDigest,
trimmedDigest,
}
if (previous !== undefined && previous.action !== 'remove' && previous.path === file.displayPath && previous.digest === currentDigest) {
versions.set(scope, nextVersion)
continue
}
const action = previous === undefined || previous.action === 'remove' ? 'set' : 'replace'
const change: WorkspaceInstructionChange = {
action,
scope,
path: file.displayPath,
digest: currentDigest,
}
items.push({ change, file })
versionUpdates.push({ change, state: nextVersion })
}
const nextVersion: InstructionVersionState = {
path: file.displayPath,
version: probedFile.version,
digest: currentDigest,
trimmedDigest,
}
if (previous !== undefined && previous.action !== 'remove' && previous.path === file.displayPath && previous.digest === currentDigest) {
versions.set(scope, nextVersion)
continue
}
const action = previous === undefined || previous.action === 'remove' ? 'set' : 'replace'
const change: WorkspaceInstructionChange = {
action,
scope,
path: file.displayPath,
digest: currentDigest,
}
items.push({ change, file })
versionUpdates.push({ change, state: nextVersion })
}
if (items.length === 0) return undefined
const rendered = renderInstructionChanges(items, resolved.maxBytes)
if (rendered.text.length === 0 || rendered.changes.length === 0) return undefined
return {
context: workspaceContextHook(rendered.text, rendered.changes),
versionUpdates: retainedInstructionVersionUpdates(versionUpdates, rendered.changes),
}
}
/**
* Validate a successful structured file touch and reconcile its applicable scopes.
* @param agent - optional agent attached to the tool execution.
* @param exec - completed tool execution descriptor.
* @param result - original tool result before post-execute decisions.
* @param resolved - normalized plugin configuration.
* @param pendingNestedChanges - per-session pending transition maps.
* @param baselineSessions - sessions whose configured baseline scopes should be probed.
* @param versionCache - per-session scope metadata used to skip unchanged reads.
* @param fileSystem - provider used for current file probes.
* @returns rendered context plus deferred cache updates, or undefined for irrelevant/failed/unchanged calls.
*/
export async function dynamicInstructionContext(
agent: Agent | undefined,
exec: ToolExecution,
result: ToolExecutionResult,
resolved: ResolvedConfig,
pendingNestedChanges: WeakMap<object, Map<string, PendingInstructionChange>>,
baselineSessions: WeakSet<object>,
versionCache: InstructionVersionCache,
fileSystem: FileSystem,
): Promise<ReconciledInstructionContext | undefined> {
if (agent === undefined || result.isError) return undefined
const touchedPath = filePathFromExecution(exec)
if (touchedPath === undefined) return undefined
return reconcileInstructionContext(
agent, resolved, pendingNestedChanges, versionCache, fileSystem,
{
touchedPath,
includeBaselineScopes: baselineSessions.has(agent.session),
signal: exec.signal,
},
)
}