Fix workspace instruction lifecycle edge cases
This commit is contained in:
@@ -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>>()
|
||||
|
||||
Reference in New Issue
Block a user