Fix workspace instruction lifecycle edge cases

This commit is contained in:
Yichen Jiang
2026-07-13 20:39:32 +08:00
parent a0e917ffe3
commit c2f2740a3e
10 changed files with 431 additions and 45 deletions

View File

@@ -5,10 +5,10 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
import { CallId, type Message } from '@deepseek-ai/dsh-llm'
import { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session'
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
import { AgentId } from '@deepseek-ai/dsh-agent'
import LlmService, { CallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session'
import AgentRegistry, { AgentId, type Agent, type HookContext } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
import type {
FsDirEntry,
@@ -33,9 +33,12 @@ import {
import {
baselineInstructionState,
commitPendingInstructionContexts,
observeInstructionSessionEvent,
rollbackPendingInstructionChanges,
type InstructionVersionCache,
type PendingInstructionChange,
} from '../src/state.ts'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
async function tempRepo(): Promise<string> {
return mkdtemp(join(tmpdir(), 'dsh-workspace-context-'))
@@ -243,6 +246,20 @@ function expectNoDerivedMessages(agent: Agent): void {
}
describe('workspace context instruction discovery', () => {
it('treats ENOTDIR while probing a host candidate as confirmed absence', async () => {
const root = await tempRepo()
const homeFile = join(root, 'not-a-directory')
try {
await writeFile(homeFile, 'file')
const files = await discoverBaselineInstructionFiles({ cwd: root, dshHome: homeFile })
expect(files).toEqual([])
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('loads user-global first, then root-to-cwd workspace instructions using the default candidate order', async () => {
const root = await tempRepo()
const home = await tempRepo()
@@ -1292,6 +1309,30 @@ describe('workspace context request injection', () => {
}
})
it('does not fall through to a lower-priority candidate when the winning provider file becomes unavailable', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
const ctx = new Context()
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.lstatTypes.set(join(root, 'AGENTS.md'), 'file')
fs.throwOnStat.add(join(root, 'AGENTS.md'))
fs.entries.set(join(root, 'CLAUDE.md'), { type: 'file', content: 'must not bypass AGENTS failure' })
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent)
expectNoDerivedMessages(agent)
expect(fs.readTargets).not.toContain(join(root, 'CLAUDE.md'))
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('treats ctx.fs marker lookup failures as absent root markers', async () => {
const root = await tempRepo()
const home = await tempRepo()
@@ -1488,9 +1529,100 @@ describe('workspace context request injection', () => {
await rm(home, { recursive: true, force: true })
}
})
it('does not bypass an unavailable host AGENTS.md with a lower-priority candidate', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'CLAUDE.md'), 'must not bypass unavailable AGENTS')
vi.resetModules()
vi.doMock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>()
return {
...actual,
lstat: async (path: string) => {
if (path === join(root, 'AGENTS.md')) {
throw Object.assign(new Error('permission denied'), { code: 'EACCES' })
}
return actual.lstat(path)
},
}
})
const isolated = await import('@deepseek-ai/dsh-workspace-context')
const rendered = await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536 })
expect(rendered).toBeUndefined()
} finally {
vi.doUnmock('node:fs/promises')
vi.resetModules()
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
})
describe('dynamic nested workspace context injection', () => {
it('re-arms a buffered instruction change when a later tool aborts the step before context append', async () => {
const root = await tempRepo()
const home = await tempRepo()
const ctx = new Context()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'pkg/AGENTS.md'), 'nested rule survives an aborted tool batch')
await write(join(root, 'pkg/deep/file.txt'), 'hello')
const adapter = new MockAdapter([
[
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('read-before-abort'), name: 'read', arguments: '{"file_path":"pkg/deep/file.txt"}' } },
{ type: 'block-start', index: 1, blockType: 'tool-call' },
{ type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('abort-after-read'), name: 'abort_step', arguments: '{}' } },
{ type: 'finish', reason: { kind: 'tool-calls' } },
] satisfies StreamChunk[],
toolCallResponse('read-after-abort', 'read', { file_path: 'pkg/deep/file.txt' }),
textResponse('done'),
])
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalFileSystem, { cwd: '/' })
await ctx.plugin(ToolFs)
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('workspace-context-abort'), { model: 'mock' }, { cwd: root })
ctx.tools.register(defineTool({
name: 'abort_step',
description: 'Abort the current test step.',
parameters: {},
async execute() {
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('test abort')
return [{ type: 'text', text: 'aborted' }]
},
}))
agent.send([{ type: 'text', text: 'read and abort' }])
await agent.whenIdle()
expect(agent.session.events.filter(event => event.type === 'context/message')).toHaveLength(0)
agent.send([{ type: 'text', text: 'retry the read' }])
await agent.whenIdle()
const contexts = agent.session.events.filter(event => event.type === 'context/message')
expect(contexts).toHaveLength(1)
expect(adapter.requests).toHaveLength(3)
expect(adapter.requests[2]?.messages.map(blocks => blocksText(blocks.content)).join('\n'))
.toContain('nested rule survives an aborted tool batch')
} finally {
await ctx.fiber.dispose()
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('builds persisted digest state without inventing a provider version', () => {
const state = baselineInstructionState([{
absolutePath: '/repo/AGENTS.md',
@@ -2631,6 +2763,84 @@ describe('dynamic nested workspace context injection', () => {
})
describe('workspace context pending state', () => {
it('leaves pending transitions from other or untracked steps untouched', () => {
const agent = stubAgent('/')
const change = (scope: string) => ({
action: 'set' as const, scope, path: `${scope}/AGENTS.md`, digest: scope,
})
const pending = new WeakMap<object, Map<string, PendingInstructionChange>>([[
agent.session,
new Map([
['untracked', { change: change('untracked'), afterSeq: 0 }],
['other-turn', { change: change('other-turn'), afterSeq: 0, step: { turn: 2, step: 1 } }],
['other-step', { change: change('other-step'), afterSeq: 0, step: { turn: 1, step: 2 } }],
['current', { change: change('current'), afterSeq: 0, step: { turn: 1, step: 1 } }],
]),
]])
const versions: InstructionVersionCache = new WeakMap()
const ended = agent.session.append('step/end', { turn: 1, step: 1 })
observeInstructionSessionEvent(agent.session, ended, pending, versions)
expect([...pending.get(agent.session)?.keys() ?? []]).toEqual(['untracked', 'other-turn', 'other-step'])
})
it('confirms a pending transition only when its matching workspace context reaches the log', () => {
const agent = stubAgent('/')
const pending = new WeakMap<object, Map<string, PendingInstructionChange>>()
const versions: InstructionVersionCache = new WeakMap()
const [change] = commitPendingInstructionContexts(agent, [workspaceChangeContext('pkg', 'one')], pending)
expect(change).toBeDefined()
versions.set(agent.session, new Map([['pkg', {
path: 'pkg/AGENTS.md', version: FsVersion('v1'), digest: 'one',
}]]))
const unrelated = agent.session.append('context/message', {
content: [], source: { kind: 'plugin', plugin: 'other' },
}, { surfaceOp: 'append' })
observeInstructionSessionEvent(agent.session, unrelated, pending, versions)
expect(pending.get(agent.session)?.has('pkg')).toBe(true)
const otherContext = workspaceChangeContext('other', 'other')
const otherWorkspaceEvent = agent.session.append('context/message', {
content: otherContext.content,
source: otherContext.source,
...otherContext.envelope !== undefined ? { envelope: otherContext.envelope } : {},
...otherContext.meta !== undefined ? { meta: otherContext.meta } : {},
}, { surfaceOp: 'append' })
observeInstructionSessionEvent(agent.session, otherWorkspaceEvent, pending, versions)
expect(pending.get(agent.session)?.has('pkg')).toBe(true)
const context = workspaceChangeContext('pkg', 'one')
const confirmed = agent.session.append('context/message', {
content: context.content,
source: context.source,
...context.envelope !== undefined ? { envelope: context.envelope } : {},
...context.meta !== undefined ? { meta: context.meta } : {},
}, { surfaceOp: 'append' })
observeInstructionSessionEvent(agent.session, confirmed, pending, versions)
expect(pending.has(agent.session)).toBe(false)
expect(versions.get(agent.session)?.has('pkg')).toBe(true)
})
it('discards pending state and its version fast path when the owning step closes first', () => {
const agent = stubAgent('/')
const pending = new WeakMap<object, Map<string, PendingInstructionChange>>()
const versions: InstructionVersionCache = new WeakMap()
agent.session.append('step/start', { turn: 1, step: 1 })
commitPendingInstructionContexts(agent, [workspaceChangeContext('pkg', 'one')], pending)
versions.set(agent.session, new Map([['pkg', {
path: 'pkg/AGENTS.md', version: FsVersion('v1'), digest: 'one',
}]]))
const ended = agent.session.append('step/end', { turn: 1, step: 1 })
observeInstructionSessionEvent(agent.session, ended, pending, versions)
expect(pending.has(agent.session)).toBe(false)
expect(versions.has(agent.session)).toBe(false)
})
it('rolls back only the exact current transition and releases empty session state', () => {
const agent = stubAgent('/')
const pending = new WeakMap<object, Map<string, PendingInstructionChange>>()