@@ -117,19 +117,31 @@ describe("NativeOllamaHandler", () => {
117117 // consume stream
118118 }
119119
120- // Text blocks are joined with "\n"; the image emits a placeholder and is
121- // flushed separately via the `images` field rather than inlined.
120+ // Text blocks are joined with "\n"; the image emits a placeholder.
121+ // Tool results are text-only in Ollama, so the image is flushed onto a
122+ // separate adjacent user message via the `images` field rather than
123+ // inlined into the tool result.
122124 expect ( mockChat ) . toHaveBeenCalledWith (
123125 expect . objectContaining ( {
124126 messages : expect . arrayContaining ( [
125127 expect . objectContaining ( {
126128 role : "user" ,
127129 content : "line one\n(see following user message for image)\nline two" ,
130+ } ) ,
131+ expect . objectContaining ( {
132+ role : "user" ,
128133 images : [ "imgdata" ] ,
129134 } ) ,
130135 ] ) ,
131136 } ) ,
132137 )
138+
139+ // The tool result message itself must not carry an images field.
140+ const callArgs = mockChat . mock . calls [ 0 ] [ 0 ] as any
141+ const toolResultMessage = callArgs . messages . find (
142+ ( m : any ) => typeof m . content === "string" && m . content . includes ( "line one" ) ,
143+ )
144+ expect ( toolResultMessage . images ) . toBeUndefined ( )
133145 } )
134146
135147 it ( "should drop unknown block types in tool_result content (empty string contribution)" , async ( ) => {
@@ -1238,5 +1250,355 @@ describe("NativeOllamaHandler", () => {
12381250 const firstEndIndex = results . findIndex ( ( r ) => r . type === "tool_call_end" )
12391251 expect ( firstEndIndex ) . toBeGreaterThan ( lastPartialIndex )
12401252 } )
1253+
1254+ it ( "should send tool results with role 'tool' and tool_name when preceded by a tool_use" , async ( ) => {
1255+ mockChat . mockImplementation ( async function * ( ) {
1256+ yield { message : { content : "ok" } }
1257+ } )
1258+
1259+ const messages : Anthropic . Messages . MessageParam [ ] = [
1260+ {
1261+ role : "assistant" ,
1262+ content : [
1263+ {
1264+ type : "tool_use" ,
1265+ id : "tool-abc" ,
1266+ name : "apply_diff" ,
1267+ input : {
1268+ path : "foo.ts" ,
1269+ diff : "SEARCH_REPLACE_DIFF_CONTENT" ,
1270+ } ,
1271+ } ,
1272+ ] ,
1273+ } ,
1274+ {
1275+ role : "user" ,
1276+ content : [
1277+ {
1278+ type : "tool_result" ,
1279+ tool_use_id : "tool-abc" ,
1280+ content : "Diff applied successfully" ,
1281+ } ,
1282+ ] ,
1283+ } ,
1284+ ]
1285+
1286+ const stream = handler . createMessage ( "System" , messages )
1287+ for await ( const _ of stream ) {
1288+ // consume stream
1289+ }
1290+
1291+ // The tool result should use Ollama's native "tool" role with tool_name
1292+ expect ( mockChat ) . toHaveBeenCalledWith (
1293+ expect . objectContaining ( {
1294+ messages : expect . arrayContaining ( [
1295+ expect . objectContaining ( {
1296+ role : "tool" ,
1297+ tool_name : "apply_diff" ,
1298+ content : "Diff applied successfully" ,
1299+ } ) ,
1300+ ] ) ,
1301+ } ) ,
1302+ )
1303+ } )
1304+
1305+ it ( "should fall back to role 'user' for tool results when no matching tool_use is found" , async ( ) => {
1306+ mockChat . mockImplementation ( async function * ( ) {
1307+ yield { message : { content : "ok" } }
1308+ } )
1309+
1310+ const messages : Anthropic . Messages . MessageParam [ ] = [
1311+ {
1312+ role : "user" ,
1313+ content : [
1314+ {
1315+ type : "tool_result" ,
1316+ tool_use_id : "unknown-id" ,
1317+ content : "orphan result" ,
1318+ } ,
1319+ ] ,
1320+ } ,
1321+ ]
1322+
1323+ const stream = handler . createMessage ( "System" , messages )
1324+ for await ( const _ of stream ) {
1325+ // consume stream
1326+ }
1327+
1328+ // No preceding tool_use -> fall back to "user" role
1329+ expect ( mockChat ) . toHaveBeenCalledWith (
1330+ expect . objectContaining ( {
1331+ messages : expect . arrayContaining ( [
1332+ expect . objectContaining ( {
1333+ role : "user" ,
1334+ content : "orphan result" ,
1335+ } ) ,
1336+ ] ) ,
1337+ } ) ,
1338+ )
1339+ } )
1340+
1341+ it ( "should strip additionalProperties from tool schema parameters" , async ( ) => {
1342+ mockGetOllamaModels . mockResolvedValue ( {
1343+ "llama3.2" : {
1344+ contextWindow : 128000 ,
1345+ maxTokens : 4096 ,
1346+ supportsImages : true ,
1347+ supportsPromptCache : false ,
1348+ } ,
1349+ } )
1350+
1351+ const options : ApiHandlerOptions = {
1352+ apiModelId : "llama3.2" ,
1353+ ollamaModelId : "llama3.2" ,
1354+ ollamaBaseUrl : "http://localhost:11434" ,
1355+ }
1356+
1357+ handler = new NativeOllamaHandler ( options )
1358+
1359+ mockChat . mockImplementation ( async function * ( ) {
1360+ yield { message : { content : "ok" } }
1361+ } )
1362+
1363+ const tools = [
1364+ {
1365+ type : "function" as const ,
1366+ function : {
1367+ name : "apply_diff" ,
1368+ description : "Apply a diff" ,
1369+ parameters : {
1370+ type : "object" ,
1371+ properties : {
1372+ path : { type : "string" , description : "File path" } ,
1373+ diff : { type : "string" , description : "Diff content" } ,
1374+ } ,
1375+ required : [ "path" , "diff" ] ,
1376+ additionalProperties : false ,
1377+ } ,
1378+ } ,
1379+ } ,
1380+ ]
1381+
1382+ const stream = handler . createMessage ( "System" , [ { role : "user" as const , content : "Edit the file" } ] , {
1383+ taskId : "test" ,
1384+ tools,
1385+ } )
1386+
1387+ for await ( const _ of stream ) {
1388+ // consume stream
1389+ }
1390+
1391+ // additionalProperties should be stripped from the parameters
1392+ expect ( mockChat ) . toHaveBeenCalledWith (
1393+ expect . objectContaining ( {
1394+ tools : [
1395+ {
1396+ type : "function" ,
1397+ function : {
1398+ name : "apply_diff" ,
1399+ description : "Apply a diff" ,
1400+ parameters : {
1401+ type : "object" ,
1402+ properties : {
1403+ path : { type : "string" , description : "File path" } ,
1404+ diff : { type : "string" , description : "Diff content" } ,
1405+ } ,
1406+ required : [ "path" , "diff" ] ,
1407+ } ,
1408+ } ,
1409+ } ,
1410+ ] ,
1411+ } ) ,
1412+ )
1413+
1414+ // Explicitly verify additionalProperties is not present
1415+ const callArgs = mockChat . mock . calls [ 0 ] [ 0 ] as any
1416+ expect ( callArgs . tools [ 0 ] . function . parameters ) . not . toHaveProperty ( "additionalProperties" )
1417+ } )
1418+
1419+ it ( "should recursively strip additionalProperties from nested tool schema parameters" , async ( ) => {
1420+ mockGetOllamaModels . mockResolvedValue ( {
1421+ "llama3.2" : {
1422+ contextWindow : 128000 ,
1423+ maxTokens : 4096 ,
1424+ supportsImages : true ,
1425+ supportsPromptCache : false ,
1426+ } ,
1427+ } )
1428+
1429+ const options : ApiHandlerOptions = {
1430+ apiModelId : "llama3.2" ,
1431+ ollamaModelId : "llama3.2" ,
1432+ ollamaBaseUrl : "http://localhost:11434" ,
1433+ }
1434+
1435+ handler = new NativeOllamaHandler ( options )
1436+
1437+ mockChat . mockImplementation ( async function * ( ) {
1438+ yield { message : { content : "ok" } }
1439+ } )
1440+
1441+ const tools = [
1442+ {
1443+ type : "function" as const ,
1444+ function : {
1445+ name : "apply_diff" ,
1446+ description : "Apply a diff" ,
1447+ parameters : {
1448+ type : "object" ,
1449+ properties : {
1450+ path : { type : "string" , description : "File path" } ,
1451+ options : {
1452+ type : "object" ,
1453+ properties : {
1454+ dry_run : { type : "boolean" } ,
1455+ backup : { type : "boolean" } ,
1456+ } ,
1457+ required : [ "dry_run" ] ,
1458+ additionalProperties : false ,
1459+ } ,
1460+ } ,
1461+ required : [ "path" , "options" ] ,
1462+ additionalProperties : false ,
1463+ } ,
1464+ } ,
1465+ } ,
1466+ ]
1467+
1468+ const stream = handler . createMessage ( "System" , [ { role : "user" as const , content : "Edit the file" } ] , {
1469+ taskId : "test" ,
1470+ tools,
1471+ } )
1472+
1473+ for await ( const _ of stream ) {
1474+ // consume stream
1475+ }
1476+
1477+ const callArgs = mockChat . mock . calls [ 0 ] [ 0 ] as any
1478+ const params = callArgs . tools [ 0 ] . function . parameters
1479+
1480+ // Top-level additionalProperties stripped
1481+ expect ( params ) . not . toHaveProperty ( "additionalProperties" )
1482+
1483+ // Nested additionalProperties also stripped
1484+ expect ( params . properties . options ) . not . toHaveProperty ( "additionalProperties" )
1485+ expect ( params . properties . options . properties . dry_run ) . toEqual ( { type : "boolean" } )
1486+ } )
1487+
1488+ it ( "should keep tool results text-only and move images onto the adjacent user message" , async ( ) => {
1489+ mockChat . mockImplementation ( async function * ( ) {
1490+ yield { message : { content : "ok" } }
1491+ } )
1492+
1493+ const messages : Anthropic . Messages . MessageParam [ ] = [
1494+ {
1495+ role : "assistant" ,
1496+ content : [
1497+ {
1498+ type : "tool_use" ,
1499+ id : "tool-img" ,
1500+ name : "read_file" ,
1501+ input : { path : "foo.ts" } ,
1502+ } ,
1503+ ] ,
1504+ } ,
1505+ {
1506+ role : "user" ,
1507+ content : [
1508+ {
1509+ type : "tool_result" ,
1510+ tool_use_id : "tool-img" ,
1511+ content : [
1512+ { type : "text" , text : "screenshot" } ,
1513+ {
1514+ type : "image" ,
1515+ source : {
1516+ type : "base64" ,
1517+ media_type : "image/png" ,
1518+ data : "imgdata" ,
1519+ } ,
1520+ } ,
1521+ ] ,
1522+ } ,
1523+ { type : "text" , text : "please continue" } ,
1524+ ] ,
1525+ } ,
1526+ ]
1527+
1528+ const stream = handler . createMessage ( "System" , messages )
1529+ for await ( const _ of stream ) {
1530+ // consume stream
1531+ }
1532+
1533+ const callArgs = mockChat . mock . calls [ 0 ] [ 0 ] as any
1534+
1535+ // The tool result uses the native "tool" role and is text-only.
1536+ const toolMessage = callArgs . messages . find ( ( m : any ) => m . role === "tool" )
1537+ expect ( toolMessage ) . toBeDefined ( )
1538+ expect ( toolMessage . tool_name ) . toBe ( "read_file" )
1539+ expect ( toolMessage . images ) . toBeUndefined ( )
1540+
1541+ // The image is carried by the adjacent user message.
1542+ const userMessage = callArgs . messages . find (
1543+ ( m : any ) => m . role === "user" && Array . isArray ( m . images ) && m . images . includes ( "imgdata" ) ,
1544+ )
1545+ expect ( userMessage ) . toBeDefined ( )
1546+ } )
1547+
1548+ it ( "should not leak images from one tool result into another" , async ( ) => {
1549+ mockChat . mockImplementation ( async function * ( ) {
1550+ yield { message : { content : "ok" } }
1551+ } )
1552+
1553+ const messages : Anthropic . Messages . MessageParam [ ] = [
1554+ {
1555+ role : "assistant" ,
1556+ content : [
1557+ { type : "tool_use" , id : "tool-a" , name : "read_file" , input : { path : "a.ts" } } ,
1558+ { type : "tool_use" , id : "tool-b" , name : "read_file" , input : { path : "b.ts" } } ,
1559+ ] ,
1560+ } ,
1561+ {
1562+ role : "user" ,
1563+ content : [
1564+ {
1565+ type : "tool_result" ,
1566+ tool_use_id : "tool-a" ,
1567+ content : [
1568+ { type : "text" , text : "a" } ,
1569+ {
1570+ type : "image" ,
1571+ source : { type : "base64" , media_type : "image/png" , data : "img-a" } ,
1572+ } ,
1573+ ] ,
1574+ } ,
1575+ {
1576+ type : "tool_result" ,
1577+ tool_use_id : "tool-b" ,
1578+ content : [ { type : "text" , text : "b" } ] ,
1579+ } ,
1580+ ] ,
1581+ } ,
1582+ ]
1583+
1584+ const stream = handler . createMessage ( "System" , messages )
1585+ for await ( const _ of stream ) {
1586+ // consume stream
1587+ }
1588+
1589+ const callArgs = mockChat . mock . calls [ 0 ] [ 0 ] as any
1590+ const toolMessages = callArgs . messages . filter ( ( m : any ) => m . role === "tool" )
1591+
1592+ // Neither tool result should carry an images field.
1593+ expect ( toolMessages ) . toHaveLength ( 2 )
1594+ for ( const m of toolMessages ) {
1595+ expect ( m . images ) . toBeUndefined ( )
1596+ }
1597+
1598+ // The single image is delivered once via the adjacent user message.
1599+ const userImageMessages = callArgs . messages . filter ( ( m : any ) => m . role === "user" && Array . isArray ( m . images ) )
1600+ expect ( userImageMessages ) . toHaveLength ( 1 )
1601+ expect ( userImageMessages [ 0 ] . images ) . toEqual ( [ "img-a" ] )
1602+ } )
12411603 } )
12421604} )
0 commit comments