2026-06-25 16:04:42 +08:00
import { mkdtemp , mkdir , rm , writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach , describe , expect , it } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore , { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
2026-07-18 12:21:15 +08:00
import AgentRegistry from '@deepseek-ai/dsh-agent'
2026-06-25 16:04:42 +08:00
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
2026-07-10 14:32:44 +08:00
import * as WorkspaceContext from '@deepseek-ai/dsh-workspace-context'
2026-07-22 14:48:34 +08:00
import { candidateScopeKey } from '../src/render.ts'
2026-07-03 11:56:58 +08:00
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
2026-07-05 18:18:32 +08:00
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
2026-06-25 16:04:42 +08:00
import type { SessionEvent } from '@deepseek-ai/dsh-session'
2026-07-03 11:56:58 +08:00
const PROBE = 'banana-271828'
2026-07-05 18:18:32 +08:00
const NESTED_PROBE = 'papaya-314159'
2026-07-10 14:32:44 +08:00
const UPDATED_PROBE = 'guava-161803'
2026-06-25 16:04:42 +08:00
let ctx : Context | undefined
let workdir : string | undefined
afterEach ( async ( ) = > {
await ctx ? . fiber . dispose ( )
ctx = undefined
if ( workdir !== undefined ) await rm ( workdir , { recursive : true , force : true } )
workdir = undefined
} )
async function harness ( ) : Promise < { ctx : Context ; agent : Agent } > {
2026-07-10 14:32:44 +08:00
workdir = await mkdtemp ( join ( tmpdir ( ) , 'dsh-workspace-context-e2e-' ) )
2026-06-25 16:04:42 +08:00
await mkdir ( join ( workdir , '.git' ) , { recursive : true } )
2026-07-10 14:32:44 +08:00
await writeFile ( join ( workdir , 'AGENTS.md' ) , ` If the user asks for the workspace context handshake, reply with exactly this string and nothing else: ${ PROBE } . \ n ` )
2026-06-25 16:04:42 +08:00
ctx = new Context ( )
await ctx . plugin ( LlmService )
await ctx . plugin ( SessionStore )
2026-07-06 10:14:47 +08:00
await ctx . plugin ( SystemPrompt , { persona : 'Answer the user exactly and concisely.' } )
2026-06-25 16:04:42 +08:00
await ctx . plugin ( ToolRegistry )
await ctx . plugin ( AgentRegistry )
2026-07-03 11:56:58 +08:00
await ctx . plugin ( LocalFileSystem , { cwd : '/' } )
2026-07-05 18:18:32 +08:00
await ctx . plugin ( ToolFs )
2026-07-12 12:09:35 +08:00
await ctx . plugin ( WorkspaceContext , { maxBytes : 65536 } )
2026-06-25 16:04:42 +08:00
await ctx . plugin ( AgentLoop , { agents : [ ] } )
2026-07-17 21:56:10 +08:00
await ctx . plugin ( LlmDeepSeek , { models : [ { id : 'deepseek-v4-flash' } ] } )
2026-07-13 14:37:32 +08:00
const handle = await ctx . agents . create ( {
2026-07-10 14:32:44 +08:00
sessionId : SessionId ( 'workspace-context-e2e-session' ) ,
2026-06-25 16:04:42 +08:00
meta : { cwd : workdir } ,
2026-07-17 21:56:10 +08:00
agentOptions : { provider : 'deepseek' , model : 'deepseek-v4-flash' } ,
2026-06-25 16:04:42 +08:00
} )
return { ctx , agent : handle.agent }
}
function waitForIdle ( ctx : Context , agent : Agent ) : Promise < void > {
return new Promise ( ( resolve ) = > {
const dispose = ctx . on ( 'agent/status' , ( subject , status ) = > {
if ( subject === agent && status === 'idle' ) {
dispose ( )
resolve ( )
}
} )
} )
}
function finalText ( events : SessionEvent [ ] ) : string {
const message = events . findLast ( event = > event . type === 'assistant/message' )
if ( message ? . type !== 'assistant/message' ) return ''
return message . data . content
. filter ( block = > block . type === 'text' )
. map ( block = > block . text )
. join ( '' )
}
2026-07-10 14:32:44 +08:00
describe . skipIf ( ! process . env . DEEPSEEK_API_KEY ) ( 'workspace context e2e: real model sees AGENTS.md baseline' , ( ) = > {
2026-06-25 16:04:42 +08:00
it ( 'obeys a probe instruction loaded from the workspace' , async ( ) = > {
const live = await harness ( )
2026-07-24 15:08:36 +08:00
live . agent . followup ( [ { type : 'text' , text : 'Workspace context handshake?' } ] )
2026-06-25 16:04:42 +08:00
await waitForIdle ( live . ctx , live . agent )
expect ( finalText ( [ . . . live . agent . session . events ] ) ) . toContain ( PROBE )
} , 120 _000 )
2026-07-05 18:18:32 +08:00
it ( 'loads a nested AGENTS.md after the real read tool touches a descendant file' , async ( ) = > {
const live = await harness ( )
await mkdir ( join ( workdir ! , 'pkg/deep' ) , { recursive : true } )
await writeFile ( join ( workdir ! , 'pkg/AGENTS.md' ) , ` If the user asks for the nested instruction handshake, reply with exactly this string and nothing else: ${ NESTED_PROBE } . \ n ` )
2026-07-10 14:32:44 +08:00
await writeFile ( join ( workdir ! , 'pkg/deep/file.txt' ) , 'This file exists only to trigger nested workspace instructions.\n' )
2026-07-05 18:18:32 +08:00
2026-07-24 15:08:36 +08:00
live . agent . followup ( [ { type : 'text' , text : 'Use the read tool to inspect pkg/deep/file.txt. After reading it, answer: nested instruction handshake?' } ] )
2026-07-05 18:18:32 +08:00
await waitForIdle ( live . ctx , live . agent )
expect ( finalText ( [ . . . live . agent . session . events ] ) ) . toContain ( NESTED_PROBE )
} , 120 _000 )
2026-07-10 14:32:44 +08:00
it ( 'appends changed baseline instructions after a real file-tool touch without rewriting the frozen prefix' , async ( ) = > {
const live = await harness ( )
await writeFile ( join ( workdir ! , 'trigger.txt' ) , 'This file triggers workspace instruction reconciliation.\n' )
2026-07-24 15:08:36 +08:00
live . agent . followup ( [ { type : 'text' , text : 'Workspace context handshake?' } ] )
2026-07-10 14:32:44 +08:00
await waitForIdle ( live . ctx , live . agent )
await writeFile ( join ( workdir ! , 'AGENTS.md' ) , ` The old workspace handshake no longer applies. If the user asks for the updated workspace context handshake, reply with exactly this string and nothing else: ${ UPDATED_PROBE } . \ n ` )
2026-07-24 15:08:36 +08:00
live . agent . followup ( [ { type : 'text' , text : 'You must use the read tool to inspect trigger.txt. After reading it, answer: updated workspace context handshake?' } ] )
2026-07-10 14:32:44 +08:00
await waitForIdle ( live . ctx , live . agent )
const events = [ . . . live . agent . session . events ]
2026-07-23 19:15:45 +08:00
const update = events . find ( event = > event . type === 'user/message'
2026-07-10 14:32:44 +08:00
&& typeof event . data . meta === 'object'
&& event . data . meta !== null
&& ! Array . isArray ( event . data . meta )
&& event . data . meta . kind === 'workspace-instructions' )
2026-07-23 19:15:45 +08:00
expect ( update ? . type === 'user/message' && update . data . meta ) . toMatchObject ( {
2026-07-22 14:48:34 +08:00
changes : [ { action : 'replace' , scope : candidateScopeKey ( '.' , 'AGENTS.md' ) , path : 'AGENTS.md' } ] ,
2026-07-10 14:32:44 +08:00
} )
2026-07-23 19:15:45 +08:00
const updateText = update ? . type === 'user/message'
2026-07-10 14:32:44 +08:00
? update . data . content . filter ( block = > block . type === 'text' ) . map ( block = > block . text ) . join ( '' )
: ''
expect ( updateText ) . toContain ( 'Updated instructions from: AGENTS.md' )
expect ( finalText ( events ) ) . toContain ( UPDATED_PROBE )
} , 120 _000 )
2026-06-25 16:04:42 +08:00
} )