@@ -5,6 +5,7 @@ import { TelemetryEventName } from "@roo-code/types"
55import { describe , it , expect , beforeEach , vi , type Mock } from "vitest"
66import { presentAssistantMessage } from "../presentAssistantMessage"
77import { validateToolUse } from "../../tools/validateToolUse"
8+ import { ReadFileTool } from "../../tools/file-reading/ReadFileTool"
89import { getModeBySlug } from "../../../shared/modes"
910import type { Task } from "../../task/Task"
1011import type { RequestPolicySnapshot } from "../../prompts/tools/effective-tool-policy"
@@ -168,6 +169,127 @@ describe("presentAssistantMessage - tool usage attribution", () => {
168169 expect ( TelemetryService . instance . captureToolUsage ) . toHaveBeenCalledWith ( mockTask . taskId , "read_file" )
169170 } )
170171
172+ describe ( "shared read_file tool" , ( ) => {
173+ it ( "reuses one instance for partial and complete calls across tasks" , async ( ) => {
174+ const handle = vi . spyOn ( ReadFileTool . prototype , "handle" ) . mockResolvedValue ( undefined )
175+ const otherTask = { ...mockTask , taskId : "other-task-id" , instanceId : "other-instance" }
176+
177+ try {
178+ for ( const task of [ mockTask , otherTask ] ) {
179+ for ( const partial of [ true , false ] ) {
180+ task . assistantMessageContent = [
181+ {
182+ type : "tool_use" ,
183+ id : `call_${ task . taskId } ` ,
184+ name : "read_file" ,
185+ params : { path : "test.txt" } ,
186+ nativeArgs : { path : "test.txt" } ,
187+ partial,
188+ } ,
189+ ]
190+
191+ // This dispatch double omits Task fields unrelated to tool presentation.
192+ await presentAssistantMessage ( task as unknown as Task , baseSnapshot )
193+ }
194+ }
195+
196+ expect ( handle ) . toHaveBeenCalledTimes ( 4 )
197+ const sharedInstance = handle . mock . contexts [ 0 ]
198+ expect ( sharedInstance ) . toBeInstanceOf ( ReadFileTool )
199+ for ( const instance of handle . mock . contexts ) {
200+ expect ( instance ) . toBe ( sharedInstance )
201+ }
202+ } finally {
203+ handle . mockRestore ( )
204+ }
205+ } )
206+
207+ it ( "preserves the injected reader when recursively processing tool calls" , async ( ) => {
208+ const reader = {
209+ handle : vi . fn < ReadFileTool [ "handle" ] > ( ) . mockResolvedValue ( undefined ) ,
210+ getReadFileToolDescription : vi . fn ( ( ) => "" ) ,
211+ }
212+ mockTask . assistantMessageContent = [ "first.txt" , "second.txt" ] . map ( ( path , index ) => ( {
213+ type : "tool_use" ,
214+ id : `call_read_${ index } ` ,
215+ name : "read_file" ,
216+ params : { path } ,
217+ nativeArgs : { path } ,
218+ partial : false ,
219+ } ) )
220+
221+ // This dispatch double omits Task fields unrelated to tool presentation.
222+ await presentAssistantMessage ( mockTask as unknown as Task , baseSnapshot , reader )
223+
224+ expect ( reader . handle ) . toHaveBeenCalledTimes ( 2 )
225+ for ( const instance of reader . handle . mock . contexts ) {
226+ expect ( instance ) . toBe ( reader )
227+ }
228+ } )
229+
230+ it ( "preserves the injected reader when processing pending streaming updates" , async ( ) => {
231+ const reader = {
232+ handle : vi
233+ . fn < ReadFileTool [ "handle" ] > ( )
234+ . mockResolvedValue ( undefined )
235+ . mockImplementationOnce ( async ( task ) => {
236+ task . presentAssistantMessageHasPendingUpdates = true
237+ } ) ,
238+ getReadFileToolDescription : vi . fn ( ( ) => "" ) ,
239+ }
240+ mockTask . assistantMessageContent = [
241+ {
242+ type : "tool_use" ,
243+ id : "call_streaming_read" ,
244+ name : "read_file" ,
245+ params : { path : "test.txt" } ,
246+ nativeArgs : { path : "test.txt" } ,
247+ partial : true ,
248+ } ,
249+ ]
250+
251+ // This dispatch double omits Task fields unrelated to tool presentation.
252+ await presentAssistantMessage ( mockTask as unknown as Task , baseSnapshot , reader )
253+
254+ expect ( reader . handle ) . toHaveBeenCalledTimes ( 2 )
255+ for ( const instance of reader . handle . mock . contexts ) {
256+ expect ( instance ) . toBe ( reader )
257+ }
258+ } )
259+
260+ it . each ( [
261+ { nativeArgs : { path : "native.txt" } , expectedPath : "native.txt" } ,
262+ { nativeArgs : undefined , expectedPath : "legacy.txt" } ,
263+ ] ) ( "uses the injected reader to describe $expectedPath" , async ( { nativeArgs, expectedPath } ) => {
264+ const reader = new ReadFileTool ( )
265+ const describeTool = vi . spyOn ( reader , "getReadFileToolDescription" )
266+ mockTask . didRejectTool = true
267+ mockTask . assistantMessageContent = [
268+ {
269+ type : "tool_use" ,
270+ id : "call_rejected_read" ,
271+ name : "read_file" ,
272+ params : { path : "legacy.txt" } ,
273+ nativeArgs,
274+ partial : false ,
275+ } ,
276+ ]
277+
278+ try {
279+ // This dispatch double omits Task fields unrelated to tool presentation.
280+ await presentAssistantMessage ( mockTask as unknown as Task , baseSnapshot , reader )
281+
282+ expect ( describeTool ) . toHaveBeenCalledExactlyOnceWith ( "read_file" , { path : expectedPath } )
283+ expect ( describeTool . mock . contexts [ 0 ] ) . toBe ( reader )
284+ expect ( mockTask . pushToolResultToUserContent ) . toHaveBeenCalledWith (
285+ expect . objectContaining ( { content : expect . stringContaining ( expectedPath ) } ) ,
286+ )
287+ } finally {
288+ describeTool . mockRestore ( )
289+ }
290+ } )
291+ } )
292+
171293 it ( "records a valid dynamic mcp_ tool name as use_mcp_tool" , async ( ) => {
172294 mockTask . assistantMessageContent = [
173295 {
0 commit comments