@@ -17,6 +17,7 @@ import {
1717 cliLocalFiles ,
1818 isWritablePath ,
1919 writeFlags ,
20+ viewSourcePaths ,
2021 CACHE_INACTIVITY_TIMEOUT_MS ,
2122 CACHE_MAX_LIFETIME_MS ,
2223 CACHE_MAX_PDF_SIZE_BYTES ,
@@ -1055,6 +1056,294 @@ describe("interact tool", () => {
10551056 } ) ;
10561057 } ) ;
10571058
1059+ describe ( "save_as" , ( ) => {
1060+ // Roundtrip tests need: writable scope, kick off interact WITHOUT awaiting
1061+ // (it blocks until the view replies), poll → submit → await. The poll()
1062+ // call also registers the uuid in viewsPolled, satisfying
1063+ // ensureViewerIsPolling — without it interact would hang ~8s and fail.
1064+
1065+ let tmpDir : string ;
1066+ let savedDirs : Set < string > ;
1067+
1068+ beforeEach ( ( ) => {
1069+ tmpDir = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , "pdf-saveas-" ) ) ;
1070+ savedDirs = new Set ( allowedLocalDirs ) ;
1071+ allowedLocalDirs . add ( tmpDir ) ; // make tmpDir a directory root → writable
1072+ } ) ;
1073+
1074+ afterEach ( ( ) => {
1075+ allowedLocalDirs . clear ( ) ;
1076+ for ( const x of savedDirs ) allowedLocalDirs . add ( x ) ;
1077+ viewSourcePaths . clear ( ) ;
1078+ fs . rmSync ( tmpDir , { recursive : true , force : true } ) ;
1079+ } ) ;
1080+
1081+ it ( "no path, no source tracked → tells model to provide a path" , async ( ) => {
1082+ // Fresh UUID never seen by display_pdf → viewSourcePaths has no entry.
1083+ // Same condition as a remote (https://) PDF or a stale viewUUID.
1084+ const { server, client } = await connect ( ) ;
1085+ const r = await client . callTool ( {
1086+ name : "interact" ,
1087+ arguments : { viewUUID : "saveas-nosource" , action : "save_as" } ,
1088+ } ) ;
1089+ expect ( r . isError ) . toBe ( true ) ;
1090+ expect ( firstText ( r ) ) . toContain ( "no local source file" ) ;
1091+ expect ( firstText ( r ) ) . toContain ( "Provide an explicit `path`" ) ;
1092+ await client . close ( ) ;
1093+ await server . close ( ) ;
1094+ } ) ;
1095+
1096+ it ( "no path, source tracked, overwrite omitted → asks for confirmation" , async ( ) => {
1097+ const { server, client } = await connect ( ) ;
1098+ const source = path . join ( tmpDir , "original.pdf" ) ;
1099+ fs . writeFileSync ( source , "%PDF-1.4\noriginal" ) ;
1100+ viewSourcePaths . set ( "saveas-noconfirm" , source ) ;
1101+
1102+ const r = await client . callTool ( {
1103+ name : "interact" ,
1104+ arguments : { viewUUID : "saveas-noconfirm" , action : "save_as" } ,
1105+ } ) ;
1106+ expect ( r . isError ) . toBe ( true ) ;
1107+ expect ( firstText ( r ) ) . toContain ( "overwrites the original" ) ;
1108+ expect ( firstText ( r ) ) . toContain ( source ) ;
1109+ expect ( firstText ( r ) ) . toContain ( "overwrite: true" ) ;
1110+ // Nothing enqueued, file untouched
1111+ expect ( fs . readFileSync ( source , "utf8" ) ) . toBe ( "%PDF-1.4\noriginal" ) ;
1112+ await client . close ( ) ;
1113+ await server . close ( ) ;
1114+ } ) ;
1115+
1116+ it ( "no path, source not writable → same gate as save button" , async ( ) => {
1117+ const { server, client } = await connect ( ) ;
1118+ // Source outside any directory root → isWritablePath false → save button
1119+ // would be hidden in the viewer. save_as should refuse for the same reason.
1120+ const outside = path . join ( os . tmpdir ( ) , "saveas-outside.pdf" ) ;
1121+ fs . writeFileSync ( outside , "x" ) ;
1122+ viewSourcePaths . set ( "saveas-buttongate" , outside ) ;
1123+
1124+ try {
1125+ const r = await client . callTool ( {
1126+ name : "interact" ,
1127+ arguments : {
1128+ viewUUID : "saveas-buttongate" ,
1129+ action : "save_as" ,
1130+ overwrite : true ,
1131+ } ,
1132+ } ) ;
1133+ expect ( r . isError ) . toBe ( true ) ;
1134+ expect ( firstText ( r ) ) . toContain ( "not writable" ) ;
1135+ expect ( firstText ( r ) ) . toContain ( "save button is hidden" ) ;
1136+ } finally {
1137+ fs . rmSync ( outside , { force : true } ) ;
1138+ }
1139+ await client . close ( ) ;
1140+ await server . close ( ) ;
1141+ } ) ;
1142+
1143+ it ( "no path, overwrite: true → roundtrip overwrites the original" , async ( ) => {
1144+ const { server, client } = await connect ( ) ;
1145+ const uuid = "saveas-original" ;
1146+ const source = path . join ( tmpDir , "report.pdf" ) ;
1147+ fs . writeFileSync ( source , "%PDF-1.4\noriginal contents" ) ;
1148+ viewSourcePaths . set ( uuid , source ) ;
1149+
1150+ const interactPromise = client . callTool ( {
1151+ name : "interact" ,
1152+ arguments : { viewUUID : uuid , action : "save_as" , overwrite : true } ,
1153+ } ) ;
1154+
1155+ const cmds = await poll ( client , uuid ) ;
1156+ expect ( cmds ) . toHaveLength ( 1 ) ;
1157+ expect ( cmds [ 0 ] . type ) . toBe ( "save_as" ) ;
1158+ await client . callTool ( {
1159+ name : "submit_save_data" ,
1160+ arguments : {
1161+ requestId : cmds [ 0 ] . requestId as string ,
1162+ data : Buffer . from ( "%PDF-1.4\nannotated" ) . toString ( "base64" ) ,
1163+ } ,
1164+ } ) ;
1165+
1166+ const r = await interactPromise ;
1167+ expect ( r . isError ) . toBeFalsy ( ) ;
1168+ expect ( firstText ( r ) ) . toContain ( source ) ;
1169+ expect ( fs . readFileSync ( source , "utf8" ) ) . toBe ( "%PDF-1.4\nannotated" ) ;
1170+
1171+ await client . close ( ) ;
1172+ await server . close ( ) ;
1173+ } ) ;
1174+
1175+ it ( "rejects non-absolute path" , async ( ) => {
1176+ const { server, client } = await connect ( ) ;
1177+ const r = await client . callTool ( {
1178+ name : "interact" ,
1179+ arguments : {
1180+ viewUUID : "saveas-rel" ,
1181+ action : "save_as" ,
1182+ path : "relative.pdf" ,
1183+ } ,
1184+ } ) ;
1185+ expect ( r . isError ) . toBe ( true ) ;
1186+ expect ( firstText ( r ) ) . toContain ( "absolute" ) ;
1187+ await client . close ( ) ;
1188+ await server . close ( ) ;
1189+ } ) ;
1190+
1191+ it ( "rejects non-writable path" , async ( ) => {
1192+ const { server, client } = await connect ( ) ;
1193+ // Path outside any directory root → not writable. Validation is sync,
1194+ // so nothing is enqueued and the queue stays empty.
1195+ const r = await client . callTool ( {
1196+ name : "interact" ,
1197+ arguments : {
1198+ viewUUID : "saveas-nowrite" ,
1199+ action : "save_as" ,
1200+ path : "/somewhere/else/out.pdf" ,
1201+ } ,
1202+ } ) ;
1203+ expect ( r . isError ) . toBe ( true ) ;
1204+ expect ( firstText ( r ) ) . toContain ( "not under a mounted directory root" ) ;
1205+ await client . close ( ) ;
1206+ await server . close ( ) ;
1207+ } ) ;
1208+
1209+ it ( "rejects existing file when overwrite is false (default)" , async ( ) => {
1210+ const { server, client } = await connect ( ) ;
1211+ const target = path . join ( tmpDir , "exists.pdf" ) ;
1212+ fs . writeFileSync ( target , "old contents" ) ;
1213+
1214+ const r = await client . callTool ( {
1215+ name : "interact" ,
1216+ arguments : {
1217+ viewUUID : "saveas-exists" ,
1218+ action : "save_as" ,
1219+ path : target ,
1220+ } ,
1221+ } ) ;
1222+ expect ( r . isError ) . toBe ( true ) ;
1223+ expect ( firstText ( r ) ) . toContain ( "already exists" ) ;
1224+ expect ( firstText ( r ) ) . toContain ( "overwrite: true" ) ;
1225+ // Existence check is sync — nothing enqueued, file untouched.
1226+ expect ( fs . readFileSync ( target , "utf8" ) ) . toBe ( "old contents" ) ;
1227+ await client . close ( ) ;
1228+ await server . close ( ) ;
1229+ } ) ;
1230+
1231+ it ( "full roundtrip: enqueue → poll → submit → file written" , async ( ) => {
1232+ const { server, client } = await connect ( ) ;
1233+ const uuid = "saveas-roundtrip" ;
1234+ const target = path . join ( tmpDir , "out.pdf" ) ;
1235+ const pdfBytes = "%PDF-1.4\nfake-annotated-contents\n%%EOF" ;
1236+
1237+ // interact blocks in waitForSaveData until submit_save_data resolves it
1238+ const interactPromise = client . callTool ( {
1239+ name : "interact" ,
1240+ arguments : { viewUUID : uuid , action : "save_as" , path : target } ,
1241+ } ) ;
1242+
1243+ // Viewer polls → receives the save_as command with a requestId
1244+ const cmds = await poll ( client , uuid ) ;
1245+ expect ( cmds ) . toHaveLength ( 1 ) ;
1246+ expect ( cmds [ 0 ] . type ) . toBe ( "save_as" ) ;
1247+ const requestId = cmds [ 0 ] . requestId as string ;
1248+ expect ( typeof requestId ) . toBe ( "string" ) ;
1249+
1250+ // Viewer submits bytes
1251+ const submit = await client . callTool ( {
1252+ name : "submit_save_data" ,
1253+ arguments : {
1254+ requestId,
1255+ data : Buffer . from ( pdfBytes ) . toString ( "base64" ) ,
1256+ } ,
1257+ } ) ;
1258+ expect ( submit . isError ) . toBeFalsy ( ) ;
1259+
1260+ // interact now unblocks with success
1261+ const r = await interactPromise ;
1262+ expect ( r . isError ) . toBeFalsy ( ) ;
1263+ expect ( firstText ( r ) ) . toContain ( "Saved" ) ;
1264+ expect ( firstText ( r ) ) . toContain ( target ) ;
1265+ expect ( fs . readFileSync ( target , "utf8" ) ) . toBe ( pdfBytes ) ;
1266+
1267+ await client . close ( ) ;
1268+ await server . close ( ) ;
1269+ } ) ;
1270+
1271+ it ( "overwrite: true replaces an existing file" , async ( ) => {
1272+ const { server, client } = await connect ( ) ;
1273+ const uuid = "saveas-overwrite" ;
1274+ const target = path . join ( tmpDir , "replace.pdf" ) ;
1275+ fs . writeFileSync ( target , "old contents" ) ;
1276+
1277+ const interactPromise = client . callTool ( {
1278+ name : "interact" ,
1279+ arguments : {
1280+ viewUUID : uuid ,
1281+ action : "save_as" ,
1282+ path : target ,
1283+ overwrite : true ,
1284+ } ,
1285+ } ) ;
1286+
1287+ const cmds = await poll ( client , uuid ) ;
1288+ const requestId = cmds [ 0 ] . requestId as string ;
1289+ await client . callTool ( {
1290+ name : "submit_save_data" ,
1291+ arguments : {
1292+ requestId,
1293+ data : Buffer . from ( "%PDF-1.4\nnew" ) . toString ( "base64" ) ,
1294+ } ,
1295+ } ) ;
1296+
1297+ const r = await interactPromise ;
1298+ expect ( r . isError ) . toBeFalsy ( ) ;
1299+ expect ( fs . readFileSync ( target , "utf8" ) ) . toBe ( "%PDF-1.4\nnew" ) ;
1300+
1301+ await client . close ( ) ;
1302+ await server . close ( ) ;
1303+ } ) ;
1304+
1305+ it ( "propagates viewer-reported errors to the model" , async ( ) => {
1306+ const { server, client } = await connect ( ) ;
1307+ const uuid = "saveas-viewerr" ;
1308+ const target = path . join ( tmpDir , "wontwrite.pdf" ) ;
1309+
1310+ const interactPromise = client . callTool ( {
1311+ name : "interact" ,
1312+ arguments : { viewUUID : uuid , action : "save_as" , path : target } ,
1313+ } ) ;
1314+
1315+ const cmds = await poll ( client , uuid ) ;
1316+ // Viewer hit an error building bytes → reports it instead of timing out
1317+ await client . callTool ( {
1318+ name : "submit_save_data" ,
1319+ arguments : {
1320+ requestId : cmds [ 0 ] . requestId as string ,
1321+ error : "pdf-lib choked on a comb field" ,
1322+ } ,
1323+ } ) ;
1324+
1325+ const r = await interactPromise ;
1326+ expect ( r . isError ) . toBe ( true ) ;
1327+ expect ( firstText ( r ) ) . toContain ( "pdf-lib choked on a comb field" ) ;
1328+ expect ( fs . existsSync ( target ) ) . toBe ( false ) ;
1329+
1330+ await client . close ( ) ;
1331+ await server . close ( ) ;
1332+ } ) ;
1333+
1334+ it ( "submit_save_data with unknown requestId returns isError" , async ( ) => {
1335+ const { server, client } = await connect ( ) ;
1336+ const r = await client . callTool ( {
1337+ name : "submit_save_data" ,
1338+ arguments : { requestId : "never-created" , data : "AAAA" } ,
1339+ } ) ;
1340+ expect ( r . isError ) . toBe ( true ) ;
1341+ expect ( firstText ( r ) ) . toContain ( "No pending request" ) ;
1342+ await client . close ( ) ;
1343+ await server . close ( ) ;
1344+ } ) ;
1345+ } ) ;
1346+
10581347 describe ( "viewer liveness" , ( ) => {
10591348 // get_screenshot/get_text fail fast when the iframe never polled, instead
10601349 // of waiting 45s for a viewer that isn't there. Reproduces the case where
0 commit comments