fix(workspace-context): recompose the baseline on resume, skip only on remount
The mount-local baseline guard was seeded from "a baseline already exists in the log", which a resumed session and a hot plugin remount both satisfy. That made a resume skip its baseline, so offline AGENTS.md edits or removals never reached the first resumed request — violating the documented resume contract. Distinguish the two by agent/session-start: a startup or resume emits it before the first step, while a remount attaches to an already-live session and never witnesses it. Only a remount (no witnessed start, baseline already logged) keeps the single logged baseline and skips; a resume falls through and re-composes from current files. Adds a regression that resumes a session with an offline baseline edit and asserts the fresh baseline reflects it.
This commit is contained in:
@@ -50,23 +50,35 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const instructionVersions: InstructionVersionCache = new WeakMap()
|
||||
const pendingVersionUpdates = new Map<ToolExecutionToken, InstructionVersionUpdate[]>()
|
||||
const baselineLoaded = new WeakSet<object>()
|
||||
// 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. That difference is the only
|
||||
// reliable way to tell a resumed session (re-compose the baseline from
|
||||
// current files) from a remount over a live one (keep the single baseline
|
||||
// already in the log) — the durable log looks identical in both cases.
|
||||
const lifecycleWitnessed = new WeakSet<object>()
|
||||
const pendingByParent = new Map<ToolExecutionToken, {
|
||||
agent: Agent
|
||||
changes: WorkspaceInstructionChange[]
|
||||
versionUpdates: InstructionVersionUpdate[]
|
||||
}>()
|
||||
|
||||
ctx.on('agent/session-start', (agent: Agent) => {
|
||||
lifecycleWitnessed.add(agent.session)
|
||||
})
|
||||
|
||||
ctx.on('session/event', (session, event) => {
|
||||
observeInstructionSessionEvent(session, event, pendingNestedChanges, instructionVersions)
|
||||
})
|
||||
|
||||
ctx.on('agent/step', async (agent: Agent, _turn, _step, signal): Promise<void> => {
|
||||
if (baselineLoaded.has(agent.session)) return
|
||||
// The guard is mount-local, but the baseline is durable: a hot remount
|
||||
// over a live session must fold the already-appended baseline from the
|
||||
// log instead of injecting a duplicate.
|
||||
if (agent.session.events.some(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin' && event.data.source.plugin === 'workspace-context')) {
|
||||
// A baseline already in the log with no witnessed lifecycle start is a hot
|
||||
// remount over a live session: keep that single baseline and skip. A
|
||||
// resumed session witnessed its start, so it falls through and re-composes.
|
||||
if (!lifecycleWitnessed.has(agent.session)
|
||||
&& agent.session.events.some(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin' && event.data.source.plugin === 'workspace-context')) {
|
||||
baselineLoaded.add(agent.session)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1005,6 +1005,47 @@ describe('workspace context request injection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('recomposes the baseline from current files when a resumed session edited it offline', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
try {
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
await write(join(root, 'AGENTS.md'), 'old root rule')
|
||||
const ctx = new Context()
|
||||
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
const original = stubAgent(root)
|
||||
await composeBaselinePrefix(ctx, original)
|
||||
|
||||
// Offline edit to the baseline file, then resume on a fresh session whose
|
||||
// seeded log already carries the original baseline. A resumed session is
|
||||
// registered after this mount's apply(), so the remount guard never seeds
|
||||
// it: its first step re-composes a fresh baseline from current files,
|
||||
// reflecting the offline edit before the first resumed request. The old
|
||||
// baseline stays in history unmutated (note: resume without mutating an
|
||||
// earlier history event).
|
||||
await write(join(root, 'AGENTS.md'), 'new root rule after offline edit')
|
||||
const resumed = stubAgent(root, [...original.session.events])
|
||||
|
||||
// Resume announces its lifecycle start before the first step.
|
||||
agentEvents(ctx, resumed).emit('agent/session-start', 'resume')
|
||||
await composeBaselinePrefix(ctx, resumed)
|
||||
|
||||
const pluginMessages = resumed.session.events.filter(event =>
|
||||
event.type === 'user/message' && event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === 'workspace-context')
|
||||
expect(pluginMessages).toHaveLength(2)
|
||||
const latest = pluginMessages.at(-1)
|
||||
expect(latest?.type === 'user/message' && blocksText(latest.data.content))
|
||||
.toContain('new root rule after offline edit')
|
||||
const original0 = pluginMessages[0]
|
||||
expect(original0?.type === 'user/message' && blocksText(original0.data.content))
|
||||
.toContain('old root rule')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('tracks only baseline files that were actually included under the byte budget', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
|
||||
Reference in New Issue
Block a user