diff --git a/examples/headless-agent/tests/code-mode.e2e.ts b/examples/headless-agent/tests/code-mode.e2e.ts index 1f708ab601..8857c771fa 100644 --- a/examples/headless-agent/tests/code-mode.e2e.ts +++ b/examples/headless-agent/tests/code-mode.e2e.ts @@ -1,7 +1,7 @@ import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' 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 LlmService, { createUserMessage, CallId, HarnessError } from '@deepseek-ai/dsh-llm' 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') }, 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-')) await mkdir(join(workdir, '.git'), { 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 dispatch = events.find(event => event.type === 'tool/code-dispatch' && event.data.name === 'read') const outerResult = events.find(event => event.type === 'tool/result') - const workspaceContext = events.find(event => event.type === 'user/message' - && event.data.source.kind === 'workspace-instructions') + const workspaceContext = await vi.waitFor(() => { + 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(outerResult).toBeDefined() - expect(workspaceContext).toBeDefined() - expect(workspaceContext!.seq).toBeGreaterThan(outerResult!.seq) - const finalMessage = events.findLast(event => event.type === 'assistant/message') - const answer = finalMessage?.type === 'assistant/message' - ? finalMessage.data.message.content.filter(block => block.type === 'text').map(block => block.text).join('') - : '' - expect(answer).toContain(WORKSPACE_PROBE) + const contextText = workspaceContext.content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('\n') + expect(contextText).toContain(WORKSPACE_PROBE) }, 180_000) }) diff --git a/packages/context/time-context/tests/time-context.e2e.ts b/packages/context/time-context/tests/time-context.e2e.ts index f56d369db2..a4aa6bc3a2 100644 --- a/packages/context/time-context/tests/time-context.e2e.ts +++ b/packages/context/time-context/tests/time-context.e2e.ts @@ -65,10 +65,11 @@ describe('time-context through a real headless cordis.yml', () => { 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\]/, ) - 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\./, ) - expect(contextText[1]).toMatch(/Time sampled while preparing turn 2, step 1:/) const headers = events.filter(event => event.type === 'request/header') expect(JSON.stringify(headers)).not.toContain('Time sampled while preparing') diff --git a/packages/context/workspace-context/src/index.ts b/packages/context/workspace-context/src/index.ts index bdd5046445..868ce01523 100644 --- a/packages/context/workspace-context/src/index.ts +++ b/packages/context/workspace-context/src/index.ts @@ -70,6 +70,11 @@ function filePathFromExecution(exec: ToolExecution): string | undefined { export function apply(ctx: Context, config: Config): void { const resolved: ResolvedConfig = resolveConfig(config) 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 // inbox produced by earlier file results for the same agent. const projectionTails = new WeakMap>() @@ -186,13 +191,12 @@ export function apply(ctx: Context, config: Config): void { const queueProjection = ( agent: Agent, - signal: AbortSignal, touchedPath: string, ): void => { 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) => { - 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) 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 const ownPath = filePathFromExecution(exec) if (ownPath === undefined) return - queueProjection(exec.agent, exec.signal, ownPath) + queueProjection(exec.agent, ownPath) }) } diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index c76507dc1d..abdc9bea76 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -2090,10 +2090,8 @@ describe('dynamic nested workspace context injection', () => { await agent.whenIdle() 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(adapter.requests).toHaveLength(4) + expect(adapter.requests).toHaveLength(3) expect(adapter.requests.at(-1)?.messages.map(blocks => blocksText(blocks.content)).join('\n')) .toContain('nested rule survives an aborted tool batch') } 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 () => { const root = await tempRepo() const home = await tempRepo() diff --git a/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts b/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts index 890e16fd4e..4e18b4ea28 100644 --- a/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts +++ b/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts @@ -86,7 +86,7 @@ describe.skipIf(process.platform === 'win32')('semantic checkpoint hard-crash re const events = await load(crashed.root) expect(events.map(event => event.type)).toEqual([ '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({ type: 'turn/end', data: { reason: { kind: 'interrupted' } },