fix(context): preserve committed workspace projections
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import LlmService, { createUserMessage, CallId, HarnessError } from '@deepseek-ai/dsh-llm'
|
import LlmService, { createUserMessage, CallId, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||||
@@ -354,7 +354,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p
|
|||||||
expect(finalText).toContain('beta-9')
|
expect(finalText).toContain('beta-9')
|
||||||
}, 180_000)
|
}, 180_000)
|
||||||
|
|
||||||
it('delivers nested workspace instructions discovered by an fs sub-call after the outer result', async () => {
|
it('projects nested workspace instructions discovered by an fs sub-call', async () => {
|
||||||
workdir = await mkdtemp(join(tmpdir(), 'dsh-code-mode-workspace-e2e-'))
|
workdir = await mkdtemp(join(tmpdir(), 'dsh-code-mode-workspace-e2e-'))
|
||||||
await mkdir(join(workdir, '.git'), { recursive: true })
|
await mkdir(join(workdir, '.git'), { recursive: true })
|
||||||
await mkdir(join(workdir, 'pkg/deep'), { recursive: true })
|
await mkdir(join(workdir, 'pkg/deep'), { recursive: true })
|
||||||
@@ -377,16 +377,21 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p
|
|||||||
const events: SessionEvent[] = [...handle.agent.session.events]
|
const events: SessionEvent[] = [...handle.agent.session.events]
|
||||||
const dispatch = events.find(event => event.type === 'tool/code-dispatch' && event.data.name === 'read')
|
const dispatch = events.find(event => event.type === 'tool/code-dispatch' && event.data.name === 'read')
|
||||||
const outerResult = events.find(event => event.type === 'tool/result')
|
const outerResult = events.find(event => event.type === 'tool/result')
|
||||||
const workspaceContext = events.find(event => event.type === 'user/message'
|
const workspaceContext = await vi.waitFor(() => {
|
||||||
&& event.data.source.kind === 'workspace-instructions')
|
const splice = handle.agent.session.events.findLast(event => event.type === 'agent/inbox/spliced'
|
||||||
|
&& event.data.inserted.some(message => message.source.kind === 'workspace-instructions'))
|
||||||
|
const inserted = splice?.type === 'agent/inbox/spliced'
|
||||||
|
? splice.data.inserted.find(message => message.source.kind === 'workspace-instructions')
|
||||||
|
: undefined
|
||||||
|
expect(inserted).toBeDefined()
|
||||||
|
return inserted!
|
||||||
|
})
|
||||||
expect(dispatch).toBeDefined()
|
expect(dispatch).toBeDefined()
|
||||||
expect(outerResult).toBeDefined()
|
expect(outerResult).toBeDefined()
|
||||||
expect(workspaceContext).toBeDefined()
|
const contextText = workspaceContext.content
|
||||||
expect(workspaceContext!.seq).toBeGreaterThan(outerResult!.seq)
|
.filter(block => block.type === 'text')
|
||||||
const finalMessage = events.findLast(event => event.type === 'assistant/message')
|
.map(block => block.text)
|
||||||
const answer = finalMessage?.type === 'assistant/message'
|
.join('\n')
|
||||||
? finalMessage.data.message.content.filter(block => block.type === 'text').map(block => block.text).join('')
|
expect(contextText).toContain(WORKSPACE_PROBE)
|
||||||
: ''
|
|
||||||
expect(answer).toContain(WORKSPACE_PROBE)
|
|
||||||
}, 180_000)
|
}, 180_000)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -65,10 +65,11 @@ describe('time-context through a real headless cordis.yml', () => {
|
|||||||
expect(contextText[0]).toMatch(
|
expect(contextText[0]).toMatch(
|
||||||
/Time sampled while preparing turn 1, step 1: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+08:00\[Asia\/Shanghai\]/,
|
/Time sampled while preparing turn 1, step 1: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+08:00\[Asia\/Shanghai\]/,
|
||||||
)
|
)
|
||||||
expect(contextText[0]).toMatch(
|
expect(contextText[0]).toContain('Elapsed since the preceding model-visible message: unavailable.')
|
||||||
|
expect(contextText[1]).toMatch(/Time sampled while preparing turn 2, step 1:/)
|
||||||
|
expect(contextText[1]).toMatch(
|
||||||
/Elapsed since the preceding model-visible message: (?:\d+d )?(?:\d+h )?(?:\d+m )?\d+s\./,
|
/Elapsed since the preceding model-visible message: (?:\d+d )?(?:\d+h )?(?:\d+m )?\d+s\./,
|
||||||
)
|
)
|
||||||
expect(contextText[1]).toMatch(/Time sampled while preparing turn 2, step 1:/)
|
|
||||||
|
|
||||||
const headers = events.filter(event => event.type === 'request/header')
|
const headers = events.filter(event => event.type === 'request/header')
|
||||||
expect(JSON.stringify(headers)).not.toContain('Time sampled while preparing')
|
expect(JSON.stringify(headers)).not.toContain('Time sampled while preparing')
|
||||||
|
|||||||
@@ -70,6 +70,11 @@ function filePathFromExecution(exec: ToolExecution): string | undefined {
|
|||||||
export function apply(ctx: Context, config: Config): void {
|
export function apply(ctx: Context, config: Config): void {
|
||||||
const resolved: ResolvedConfig = resolveConfig(config)
|
const resolved: ResolvedConfig = resolveConfig(config)
|
||||||
const instructionVersions: InstructionVersionCache = new WeakMap()
|
const instructionVersions: InstructionVersionCache = new WeakMap()
|
||||||
|
const projectionLifecycle = new AbortController()
|
||||||
|
ctx.effect(
|
||||||
|
() => () =>{ projectionLifecycle.abort(new Error('workspace-context disposed')); },
|
||||||
|
'workspace-context.projectionLifecycle',
|
||||||
|
)
|
||||||
// Emit listeners are not awaited, so each projection must compose against the
|
// Emit listeners are not awaited, so each projection must compose against the
|
||||||
// inbox produced by earlier file results for the same agent.
|
// inbox produced by earlier file results for the same agent.
|
||||||
const projectionTails = new WeakMap<Agent, Promise<void>>()
|
const projectionTails = new WeakMap<Agent, Promise<void>>()
|
||||||
@@ -186,13 +191,12 @@ export function apply(ctx: Context, config: Config): void {
|
|||||||
|
|
||||||
const queueProjection = (
|
const queueProjection = (
|
||||||
agent: Agent,
|
agent: Agent,
|
||||||
signal: AbortSignal,
|
|
||||||
touchedPath: string,
|
touchedPath: string,
|
||||||
): void => {
|
): void => {
|
||||||
const previous = projectionTails.get(agent) ?? Promise.resolve()
|
const previous = projectionTails.get(agent) ?? Promise.resolve()
|
||||||
const current = previous.then(() => composeAndSync(agent, signal, [], [touchedPath]))
|
const current = previous.then(() => composeAndSync(agent, projectionLifecycle.signal, [], [touchedPath]))
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
if (!signal.aborted) ctx.logger.warn('workspace instruction refresh failed: %o', error)
|
if (!projectionLifecycle.signal.aborted) ctx.logger.warn('workspace instruction refresh failed: %o', error)
|
||||||
})
|
})
|
||||||
projectionTails.set(agent, current)
|
projectionTails.set(agent, current)
|
||||||
void current.then(() => {
|
void current.then(() => {
|
||||||
@@ -221,6 +225,6 @@ export function apply(ctx: Context, config: Config): void {
|
|||||||
if (result.isError || exec.agent === undefined || exec.signal.aborted) return
|
if (result.isError || exec.agent === undefined || exec.signal.aborted) return
|
||||||
const ownPath = filePathFromExecution(exec)
|
const ownPath = filePathFromExecution(exec)
|
||||||
if (ownPath === undefined) return
|
if (ownPath === undefined) return
|
||||||
queueProjection(exec.agent, exec.signal, ownPath)
|
queueProjection(exec.agent, ownPath)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2090,10 +2090,8 @@ describe('dynamic nested workspace context injection', () => {
|
|||||||
await agent.whenIdle()
|
await agent.whenIdle()
|
||||||
|
|
||||||
const contexts = agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')
|
const contexts = agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')
|
||||||
// Cancellation discards the aborted step's pending context. The next
|
|
||||||
// successful read discovers and durably injects it once.
|
|
||||||
expect(contexts).toHaveLength(1)
|
expect(contexts).toHaveLength(1)
|
||||||
expect(adapter.requests).toHaveLength(4)
|
expect(adapter.requests).toHaveLength(3)
|
||||||
expect(adapter.requests.at(-1)?.messages.map(blocks => blocksText(blocks.content)).join('\n'))
|
expect(adapter.requests.at(-1)?.messages.map(blocks => blocksText(blocks.content)).join('\n'))
|
||||||
.toContain('nested rule survives an aborted tool batch')
|
.toContain('nested rule survives an aborted tool batch')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2219,6 +2217,34 @@ describe('dynamic nested workspace context injection', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('finishes a committed file-result projection after the tool signal ends', 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 package rule')
|
||||||
|
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||||
|
const agent = stubAgent(root)
|
||||||
|
const controller = new AbortController()
|
||||||
|
|
||||||
|
ctx.emit('tools/result', stubToolExecution({
|
||||||
|
signal: controller.signal,
|
||||||
|
callId: CallId('read-before-signal-end'),
|
||||||
|
name: 'read',
|
||||||
|
arguments: { file_path: join('pkg', 'file.txt') },
|
||||||
|
agent,
|
||||||
|
}), { content: [{ type: 'text', text: 'ok' }], isError: false, value: null })
|
||||||
|
controller.abort(new Error('tool execution ended'))
|
||||||
|
|
||||||
|
expect(blocksText((await workspaceContextOf(agent)).content)).toContain('nested package rule')
|
||||||
|
} finally {
|
||||||
|
await ctx.fiber.dispose()
|
||||||
|
await rm(root, { recursive: true, force: true })
|
||||||
|
await rm(home, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('loads every configured instruction candidate present in a nested scope', async () => {
|
it('loads every configured instruction candidate present in a nested scope', async () => {
|
||||||
const root = await tempRepo()
|
const root = await tempRepo()
|
||||||
const home = await tempRepo()
|
const home = await tempRepo()
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ describe.skipIf(process.platform === 'win32')('semantic checkpoint hard-crash re
|
|||||||
const events = await load(crashed.root)
|
const events = await load(crashed.root)
|
||||||
expect(events.map(event => event.type)).toEqual([
|
expect(events.map(event => event.type)).toEqual([
|
||||||
'agent/inbox/spliced', 'agent/inbox/spliced',
|
'agent/inbox/spliced', 'agent/inbox/spliced',
|
||||||
'turn/start', 'user/message', 'step/start', 'request/header', 'request/context', 'step/end', 'turn/end',
|
'turn/start', 'step/start', 'user/message', 'request/header', 'request/context', 'step/end', 'turn/end',
|
||||||
])
|
])
|
||||||
expect(events.at(-1)).toMatchObject({
|
expect(events.at(-1)).toMatchObject({
|
||||||
type: 'turn/end', data: { reason: { kind: 'interrupted' } },
|
type: 'turn/end', data: { reason: { kind: 'interrupted' } },
|
||||||
|
|||||||
Reference in New Issue
Block a user