66import type * as vscode from 'vscode' ;
77import { expect , suite , test } from 'vitest' ;
88import { ConfigKey } from '../../../../platform/configuration/common/configurationService' ;
9+ import { URI } from '../../../../util/vs/base/common/uri' ;
910import { toolCategories , ToolCategory , ToolName } from '../../common/toolNames' ;
1011import { ToolRegistry } from '../../common/toolsRegistry' ;
1112
1213// Ensure side-effect registration
1314import '../searchSubagentTool' ;
1415
16+ /**
17+ * Returns an invokeFunction stub that dequeues outcomes in call order.
18+ * Each outcome is either a value to resolve with, or a thunk that throws.
19+ */
20+ function sequencedInvokeFunction ( ...outcomes : Array < unknown | ( ( ) => never ) > ) {
21+ let i = 0 ;
22+ return async ( _fn : unknown ) => {
23+ const outcome = outcomes [ i ++ ] ;
24+ if ( typeof outcome === 'function' ) {
25+ return ( outcome as ( ) => unknown ) ( ) ;
26+ }
27+ return outcome ;
28+ } ;
29+ }
30+
31+ /** Minimal vscode.TextDocument-shaped object that satisfies TextDocumentSnapshot.create. */
32+ function makeFakeDocument ( uri : URI , text : string ) {
33+ return {
34+ uri,
35+ getText : ( ) => text ,
36+ languageId : 'typescript' ,
37+ eol : 1 ,
38+ version : 0 ,
39+ } as unknown as vscode . TextDocument ;
40+ }
41+
1542/** Minimal stub for LanguageModelToolInformation */
1643function makeToolInfo ( overrides : Partial < vscode . LanguageModelToolInformation > = { } ) : vscode . LanguageModelToolInformation {
1744 return {
@@ -25,7 +52,14 @@ function makeToolInfo(overrides: Partial<vscode.LanguageModelToolInformation> =
2552}
2653
2754/** Returns an instance of the (private) SearchSubagentTool via the registry */
28- function makeToolInstance ( thoroughnessEnabled : boolean , toolCallLimit : number = 4 ) {
55+ function makeToolInstance (
56+ thoroughnessEnabled : boolean ,
57+ toolCallLimit : number = 4 ,
58+ overrides : {
59+ invokeFunction ?: ( fn : unknown ) => Promise < unknown > ;
60+ openTextDocument ?: ( uri : URI ) => Promise < vscode . TextDocument > ;
61+ } = { } ,
62+ ) {
2963 const toolCtor = ToolRegistry . getTools ( ) . find ( t => t . toolName === ToolName . SearchSubagent ) ! ;
3064
3165 const configService = {
@@ -49,12 +83,18 @@ function makeToolInstance(thoroughnessEnabled: boolean, toolCallLimit: number =
4983 // Return a minimal stub that exposes run()
5084 return { run : async ( ) => ( { response : { type : 'error' , reason : 'stub' } , toolCallRounds : [ ] , round : { response : '' } } ) } ;
5185 } ,
86+ invokeFunction : overrides . invokeFunction ?? ( async ( ) => { throw new Error ( 'invokeFunction not stubbed' ) ; } ) ,
87+ } ;
88+
89+ const workspaceService = {
90+ getWorkspaceFolders : ( ) => [ ] ,
91+ openTextDocument : overrides . openTextDocument ?? ( async ( ) => { throw new Error ( 'openTextDocument not stubbed' ) ; } ) ,
5292 } ;
5393
5494 const tool = new ( toolCtor as any ) (
5595 instantiationService ,
5696 { captureInvocation : async ( _token : unknown , fn : ( ) => unknown ) => fn ( ) } , // requestLogger
57- { getWorkspaceFolders : ( ) => [ ] , openTextDocument : async ( ) => { throw new Error ( 'stub' ) ; } } , // workspaceService
97+ workspaceService ,
5898 configService ,
5999 experimentationService ,
60100 ) ;
@@ -182,21 +222,54 @@ suite('SearchSubagentTool', () => {
182222 '- /workspace/other.ts (lines 30-40): test2' ,
183223 ] . join ( '\n' ) ;
184224
185- const result = await tool [ 'parseFinalAnswerAndHydrate' ] ( response , '/workspace' , notCancelled ) ;
225+ const result = await tool [ 'parseFinalAnswerAndHydrate' ] ( response , '/workspace' , undefined , notCancelled ) ;
186226
187227 expect ( result ) . toBe ( response ) ;
188228 } ) ;
189229
190- test ( 'keeps a matching line verbatim when the captured path fails to open (no error suffix)' , async ( ) => {
191- const { tool } = makeToolInstance ( false ) ;
192- const response = [
193- '- /workspace/file.ts:10-20'
194- ] . join ( '\n' ) ;
230+ test ( 'drops the line when the path is outside the workspace' , async ( ) => {
231+ const { tool } = makeToolInstance ( false , 4 , {
232+ invokeFunction : sequencedInvokeFunction (
233+ ( ) => { throw new Error ( 'outside workspace' ) ; } ,
234+ true ,
235+ ) ,
236+ } ) ;
237+
238+ const response = '/external/secret.ts:5-10' ;
239+ const result = await tool [ 'parseFinalAnswerAndHydrate' ] ( response , '/workspace' , undefined , notCancelled ) ;
240+
241+ expect ( result ) . toBe ( '' ) ;
242+ } ) ;
243+
244+ test ( 'keeps the original line when an inside-workspace path fails to open' , async ( ) => {
245+ const { tool } = makeToolInstance ( false , 4 , {
246+ invokeFunction : sequencedInvokeFunction (
247+ undefined ,
248+ false ,
249+ ) ,
250+ openTextDocument : async ( ) => { throw new Error ( 'file not found' ) ; } ,
251+ } ) ;
195252
196- const result = await tool [ 'parseFinalAnswerAndHydrate' ] ( response , '/workspace' , notCancelled ) ;
253+ const response = 'inside/file.ts:5-10' ;
254+ const result = await tool [ 'parseFinalAnswerAndHydrate' ] ( response , '/workspace' , undefined , notCancelled ) ;
197255
198256 expect ( result ) . toBe ( response ) ;
199- expect ( result ) . not . toContain ( 'unable to read file' ) ;
257+ } ) ;
258+
259+ test ( 'hydrates the line with code when an inside-workspace path opens' , async ( ) => {
260+ const cwd = '/workspace' ;
261+ const filePath = 'inside/file.ts' ;
262+ const fileText = 'line1\nline2' ;
263+ const uri = URI . joinPath ( URI . file ( cwd ) , filePath ) ;
264+
265+ const { tool } = makeToolInstance ( false , 4 , {
266+ invokeFunction : sequencedInvokeFunction ( undefined ) ,
267+ openTextDocument : async ( ) => makeFakeDocument ( uri , fileText ) ,
268+ } ) ;
269+
270+ const result = await tool [ 'parseFinalAnswerAndHydrate' ] ( `${ filePath } :1-2` , cwd , undefined , notCancelled ) ;
271+
272+ expect ( result ) . toBe ( `File: \`${ uri . fsPath } \`, lines 1-2:\n\`\`\`\n${ fileText } \n\`\`\`` ) ;
200273 } ) ;
201274 } ) ;
202275} ) ;
0 commit comments